diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f2b5720190..4ec1ea6f4e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1129,7 +1129,24 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< let PageFrame { resp, ptr, pointer, .. } = *frame; let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; - let hits = |view: &DocView, p: Pos2| [link_hit(info, &xf, i, p), comments::hit(&pcx, view, p)].into_iter().flatten(); + let field_tool = match tool { + QuickTool::Field(f) => Some(f), + _ => None, + }; + // While a table is being set up, nothing else is drawn or moved. + let prepare_allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); + // What lies under a point, bottom up: links, comments, then form fields (Prepare's fields, + // being moved, over the comments; filled ones under the comments that move). + let hits = |view: &DocView, p: Pos2| { + let fields = if preparing { + crate::prepare::hit(&xf, info, i, form, field_tool, prepare_allowed, &view.prepare, p) + } else if tool == QuickTool::Select { + crate::forms_ui::hit(&xf, info, i, form, can_fill, p) + } else { + None + }; + [link_hit(info, &xf, i, p), comments::hit(&pcx, view, p), fields].into_iter().flatten() + }; if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) { let at = hits(view, o).collect::>(); view.router.press(i, at, tool); @@ -1231,13 +1248,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< frame.out.open_boxes = true; } let on_field = if preparing { - let field_tool = match tool { - QuickTool::Field(f) => Some(f), - _ => None, - }; - // While a table is being set up, nothing else is drawn or moved. - let allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); - let o = crate::prepare::page_input(cx, form, field_tool, allowed, view); + let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, view); frame.out.field_props |= o.properties; frame.out.field_placed |= o.placed; frame.out.open_table = frame.out.open_table.or(o.table); @@ -1245,7 +1256,6 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< } else { tool == QuickTool::Select && !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, ptr, i, info, added, view)) - && !comments::select_grabs(&pcx, view) && crate::forms_ui::page_input(cx, form, can_fill, view) }; // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 82769bd8f4..c5751cdf14 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -20,6 +20,12 @@ pub(crate) enum Owner { Comment { index: usize, part: CommentPart }, /// A link, by its index in the document's links. Link { index: usize }, + /// A form field's widget, by the field's index in the form and the widget's in the field. + Field { field: usize, widget: usize }, + /// Prepare Form: a field's widget, to select and move. + PrepareField { field: usize, widget: usize }, + /// Prepare Form: a corner handle of the selected field, to resize it. + PrepareCorner { left: bool, top: bool }, } /// The part of a comment a hit is on. diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index ac1d82ea97..a6e718ce7f 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -37,7 +37,7 @@ pub(crate) use geometry::{corner_ratio, page_rotation, resized}; pub use geometry::{drawn_text_box_rect, text_box_rect}; #[cfg(test)] use input::hover_tip; -pub(crate) use input::{describe, fill_grabs, hit, keys, nudge_keys, page_after_text, page_input, select_grabs}; +pub(crate) use input::{describe, fill_grabs, hit, keys, nudge_keys, page_after_text, page_input}; pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid}; #[cfg(test)] use menu::{RECENT_CUSTOM, same_colour}; diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 102767b6cb..17e1e102c3 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -98,8 +98,9 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2) -> Option { } let markup = is_markup(&a.subtype); let icon = if cx.allowed && !markup { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand }; - // Signatures and Fill & Sign's marks are placed on the page rather than comments on it. - let layer = if a.fill_sign { Layer::Placed } else { Layer::Comment }; + // What a drag moves whole (a note, a shape, a signature, a Fill & Sign mark) is placed on the + // page, over a form field; text markup lies on the text, under one. + let layer = if a.fill_sign || !markup { Layer::Placed } else { Layer::Comment }; // Text markup does not move: a click selects it, a drag across it selects the text. Some(Hit { declines_drag: markup, ..Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, icon) }) } @@ -124,12 +125,6 @@ pub(crate) fn fill_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { matches!(cx.tool, QuickTool::Fill(_)) && grabs(cx, view) } -/// With the Select tool, a comment that would move (not text markup) is picked up before a form -/// field under it, so a signature placed over a field can still be dragged off it. -pub(crate) fn select_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { - cx.tool == QuickTool::Select && grabs(cx, view) -} - /// With a shape tool, the selected comment's handles are picked up ([`hit`]). fn draw_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { matches!(cx.tool, QuickTool::Comment(_)) && grabs(cx, view) diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 8b897d8207..077ad8550c 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -15,6 +15,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::page_cursor::Layer; +use crate::page_router::{Hit, Owner}; use crate::theme::Tokens; const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -151,48 +152,64 @@ fn toggle_edit(f: &FormField, w: &FormWidget) -> Option { Some(Edit::SetFieldValue { name: f.name.clone(), value }) } -/// Clicks on form widgets of one page. `true` when a field owns the press or click. -pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: bool, view: &mut DocView) -> bool { - let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; - let Some(p) = ptr.within(xf.rect) else { return false }; - let hit = form.iter().find_map(|f| { +/// Whether `w`, a widget of `f`, takes a press: fillable, a push button with an action, or an +/// empty signature field (signed by clicking it, Use a certificate). A radio button of a group can +/// be read-only on its own. +fn usable(f: &FormField, w: &FormWidget, allowed: bool) -> bool { + let pressable = f.kind == FormFieldKind::PushButton && !f.read_only() && f.button.is_some(); + allowed && !w.read_only && (fillable(f) || pressable || f.kind == FormFieldKind::Signature) +} + +/// The form widget under `p` on `page`: the one hit test of the fields, for the cursor and the +/// press. Over the page's text too: a check box drawn with a glyph, or set on printed text, is a +/// button, not text to select. +pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, form: &[FormField], allowed: bool, p: Pos2) -> Option { + if !xf.rect.contains(p) { + return None; + } + let (field, f, widget, w) = form.iter().enumerate().find_map(|(fi, f)| { f.widgets .iter() .enumerate() .find(|(_, w)| w.page == Some(page) && !w.hidden && widget_rect(xf, info, page, w.rect).contains(p)) - .map(|(i, w)| (f, i, w)) - }); - let Some((f, wi, w)) = hit else { return false }; - // The field's tooltip (`/TU`), as Acrobat shows it on hover (#3854). ISO 32000-2 Table 226 - // makes it the name the user interface identifies the field by. - if let Some(tip) = f.tooltip.as_deref().filter(|t| !t.trim().is_empty()) { + .map(|(wi, w)| (fi, f, wi, w)) + })?; + let icon = match (usable(f, w, allowed), f.kind) { + (false, _) => egui::CursorIcon::NotAllowed, + (true, FormFieldKind::Text) => egui::CursorIcon::Text, + _ => egui::CursorIcon::PointingHand, + }; + Some(Hit::new(Owner::Field { field, widget }, Layer::Field, icon)) +} + +/// Presses and clicks on form widgets of one page, on the field the press was on ([`hit`]). +/// `true` while a field holds the press. +pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: bool, view: &mut DocView) -> bool { + let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; + // The field's tooltip (`/TU`) under the pointer, as Acrobat shows it on hover (#3854). ISO + // 32000-2 Table 226 makes it the name the user interface identifies the field by. + if let Some(p) = ptr.within(xf.rect) + && let Some(Owner::Field { field, .. }) = hit(xf, info, page, form, allowed, p).map(|h| h.owner) + && let Some(tip) = form.get(field).and_then(|f| f.tooltip.as_deref()).filter(|t| !t.trim().is_empty()) + { resp.clone().on_hover_text_at_pointer(tip); } - // Empty signature fields are signed by clicking them (Use a certificate); push buttons run - // their action. - let pressable = f.kind == FormFieldKind::PushButton && !f.read_only() && f.button.is_some(); - // A radio button of a group can be read-only on its own. - let usable = allowed && !w.read_only && (fillable(f) || pressable || f.kind == FormFieldKind::Signature); - // Over the page's text too: a check box drawn with a glyph, or set on printed text, is a - // button, not text to select. - view.cursor.claim( - Layer::Field, - match (usable, f.kind) { - (false, _) => egui::CursorIcon::NotAllowed, - (true, FormFieldKind::Text) => egui::CursorIcon::Text, - _ => egui::CursorIcon::PointingHand, - }, - ); + let Some(Owner::Field { field, widget: wi }) = view.router.owner(page) else { return false }; + let Some((f, w)) = form.get(field).and_then(|f| f.widgets.get(wi).map(|w| (f, w))) else { return false }; + let usable = usable(f, w, allowed); // A text field opens on the press rather than the click, so a drag that starts on it // selects its text (#2213). - let pressed = f.kind == FormFieldKind::Text && usable && resp.is_pointer_button_down_on() && ptr.pressed().is_some(); - if pressed && view.forms.focus.as_ref().is_none_or(|x| x.name != f.name || x.widget != wi) { + if f.kind == FormFieldKind::Text + && usable + && let Some(at) = ptr.pressed() + && view.forms.focus.as_ref().is_none_or(|x| x.name != f.name || x.widget != wi) + { commit(view, form); - view.forms.focus = Some(Focus { press: Some(p), ..open_focus(f, wi) }); + view.forms.focus = Some(Focus { press: Some(at), ..open_focus(f, wi) }); view.forms.pressed = true; } if !resp.clicked() { - return resp.is_pointer_button_down_on(); + return true; } if !allowed { view.forms.notice = Some(FormNotice::Security); @@ -231,6 +248,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: b } } FormFieldKind::List => { + let Some(p) = ptr.within(xf.rect) else { return true }; commit(view, form); view.forms.focus = Some(open_focus(f, wi)); let r = widget_rect(xf, info, page, w.rect); diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index c5fa2b684e..e8861b5cf6 100644 --- a/crates/ui-egui/src/tools/prepare.rs +++ b/crates/ui-egui/src/tools/prepare.rs @@ -13,6 +13,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::comments; use crate::page_cursor::Layer; +use crate::page_router::{Hit, Owner}; use crate::theme; use crate::widgets; @@ -252,6 +253,38 @@ pub(crate) struct Outcome { pub table: Option<(usize, [f64; 4])>, } +/// What Prepare Form's Select tool has under `p` on `page`: the selected field's corner handles, +/// then a field. A field being moved is placed on the page over the comments; nothing with a field +/// tool, which draws anywhere. +#[allow(clippy::too_many_arguments)] +pub(crate) fn hit( + xf: &PageXform, + info: &DocInfo, + page: usize, + form: &[FormField], + tool: Option, + allowed: bool, + prep: &PrepareView, + p: Pos2, +) -> Option { + if tool.is_some() || !allowed || !xf.rect.contains(p) { + return None; + } + let selected_rect = prep + .selected + .as_ref() + .and_then(|(n, wi)| form.iter().find(|f| &f.name == n)?.widgets.get(*wi).filter(|w| w.page == Some(page))) + .map(|w| screen_rect(xf, info, page, w.rect)); + if let Some((_, left, top)) = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(p) <= 6.0)) { + let icon = if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }; + return Some(Hit::new(Owner::PrepareCorner { left, top }, Layer::Handle, icon)); + } + let (field, widget) = form.iter().enumerate().find_map(|(fi, f)| { + f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && screen_rect(xf, info, page, w.rect).contains(p)).map(|(wi, _)| (fi, wi)) + })?; + Some(Hit::new(Owner::PrepareField { field, widget }, Layer::Placed, egui::CursorIcon::Move)) +} + /// Pointer input on one page while preparing a form. pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Option, allowed: bool, view: &mut DocView) -> Outcome { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; @@ -383,33 +416,21 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti prep.also.clear(); } } - // The Select tool: corner handles of the selection, then fields. - let selected_rect = prep - .selected - .as_ref() - .and_then(|(n, wi)| form.iter().find(|f| &f.name == n)?.widgets.get(*wi).filter(|w| w.page == Some(page))) - .map(|w| screen_rect(xf, info, page, w.rect)); - let corner = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(p) <= 6.0)); - let hit = form.iter().find_map(|f| { - f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && screen_rect(xf, info, page, w.rect).contains(p)).map(|(i, _)| (f, i)) - }); - if let Some((_, left, top)) = corner { - view.cursor.claim(Layer::Handle, if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); - } else if hit.is_some() { - view.cursor.claim(Layer::Field, egui::CursorIcon::Move); - } - if resp.drag_started() { + // The Select tool: what the press was on ([`hit`]), a corner handle or a field; elsewhere a + // drag draws a selection rectangle. A press held by something drawn over the fields (a + // comment) is not Prepare's. + let owner = view.router.owner(page); + let held = matches!(owner, Some(Owner::PrepareField { .. } | Owner::PrepareCorner { .. })); + let hit = match owner { + Some(Owner::PrepareField { field, widget }) => form.get(field).map(|f| (f, widget)), + _ => None, + }; + if resp.drag_started() && (held || owner.is_none()) { let origin = ptr.origin.unwrap_or(p); - if let Some((_, left, top)) = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(origin) <= 6.0)) { + if let Some(Owner::PrepareCorner { left, top }) = owner { prep.grab = Some((page, Grab::Corner(left, top), origin)); out.consumed = true; - } else if let Some((f, wi)) = form.iter().find_map(|f| { - f.widgets - .iter() - .enumerate() - .find(|(_, w)| w.page == Some(page) && screen_rect(xf, info, page, w.rect).contains(origin)) - .map(|(i, _)| (f, i)) - }) { + } else if let Some((f, wi)) = hit { prep.selected = Some((f.name.clone(), wi)); prep.grab = Some((page, Grab::Move, origin)); out.consumed = true; @@ -429,7 +450,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti out.properties = true; return out; } - if resp.clicked() { + if resp.clicked() && (held || owner.is_none()) { let extend = ui.input(|i| i.modifiers.shift || i.modifiers.command); match hit { // Shift/⌘-click adds or removes a field from the selection. @@ -437,14 +458,14 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti prep.select((f.name.clone(), wi), extend); out.consumed = true; } - None if corner.is_none() => { + None if !held => { prep.selected = None; prep.also.clear(); } None => out.consumed = true, } } - out.consumed |= hit.is_some() && resp.is_pointer_button_down_on(); + out.consumed |= held; out } diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 4a28943b11..b110041986 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -107,6 +107,31 @@ fn checkbox_pdf() -> Vec { ]) } +/// A 300×400 page with two unchecked check boxes side by side: `agree` at x 100..120 and +/// `other` at x 120..140, both at y 200..220. +fn two_checkboxes_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] /DA (/ZaDb 0 Tf 0 g) /DR << /Font << /ZaDb 6 0 R >> >> >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [100 200 120 220] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (other) /V /Off /AS /Off /Rect [120 200 140 220] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>", + ]) +} + +/// The check box `agree` of [`checkbox_pdf`], with a highlight drawn over it. +fn highlighted_checkbox_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/ZaDb 0 Tf 0 g) /DR << /Font << /ZaDb 5 0 R >> >> >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [100 200 120 220] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>", + "<< /Type /Annot /Subtype /Highlight /Rect [95 195 125 225] /QuadPoints [95 225 125 225 95 195 125 195] /C [1 1 0] /F 4 /T (Ana) >>", + ]) +} + /// The app with `pdf` open at `zoom` percent and its first frames run. The left panel stays /// open only where a test places the page relative to the window's chrome. fn open(pdf: Vec, zoom: &'static str, left_open: bool) -> Harness<'static, PdfCraftApp> { @@ -688,6 +713,40 @@ fn coalesced_click_places_a_stamp() { assert_eq!(stamps(&h), 1, "a click whose press and release share a frame places the stamp"); } +/// The value of the field named `name` on the first view's document. +fn field_value(h: &Harness<'_, PdfCraftApp>, name: &str) -> Vec { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone() +} + +/// A press belongs to the field it lands on: pressed at the right edge of `agree` and released just +/// over the edge of `other` beside it (a click still, to egui), it ticks neither, as a button +/// pressed and released off itself does nothing. The release used to tick `other`. +#[test] +fn a_press_on_one_check_box_released_over_another_ticks_neither() { + let mut h = open(two_checkboxes_pdf(), "150", false); + let (press, release) = (at(&h, 119.0, 210.0), at(&h, 121.0, 210.0)); + h.hover_at(press); + h.run_steps(1); + h.drag_at(press); + h.run_steps(1); + h.drop_at(release); + h.run_steps(4); + assert_eq!((field_value(&h, "agree"), field_value(&h, "other")), (Vec::::new(), Vec::new()), "neither box is ticked"); +} + +/// Text markup lies on the text, under a form field: a click on a check box under a highlight +/// ticks the box, and the field's hand shows there. +#[test] +fn a_check_box_under_a_highlight_takes_the_click() { + let mut h = open(highlighted_checkbox_pdf(), "150", false); + let p = at(&h, 110.0, 210.0); + assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::PointingHand); + mouse_click(&mut h, p); + assert_eq!(field_value(&h, "agree"), ["Yes"], "the box is ticked"); + assert_eq!(h.state().views[0].comments.selected, None, "and the highlight is not selected"); +} + #[test] fn mouse_click_ticks_a_check_box() { // Control for the tap below.