fix(ui): form fields and Prepare decide a press by one hit test

Forms: forms_ui::hit is the fields' only hit test (cursor and press). The press opens a text
field (#2213) and its click acts on the field the press was on, from the page router, instead
of the field under the release point: a press on one check box released over the one beside it
ticked the second. Now it ticks neither, as a button pressed and released off itself does
nothing. Forms no longer wait on comments::select_grabs: a comment that moves (a note, a shape,
a signature, a Fill & Sign mark) hits on the Placed layer, over form fields, so it still takes a
press over a field (a signature can be dragged off the field it covers), and text markup stays
on the Comment layer, under them, so a click on a box under a highlight ticks the box.
select_grabs goes.

Prepare Form: prepare::hit (the selected field's corner handles, then a field, on Placed so a
field being moved stays over the comments as before) gives the cursor, and the drag, click and
double click act on what the press was on; a press held by something else is not Prepare's.

New tests: a_press_on_one_check_box_released_over_another_ticks_neither (fails without this
commit: the second box is ticked) and a_check_box_under_a_highlight_takes_the_click (fails with
markup on the Placed layer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alehelandClaude Opus 5.5 committed 2026-10-29 12:29:57 +01:00
1 parent 7d72f21da3
commit 63fa5010d8
7 files changed
+184 -75

No files matched your search

+19 -9
View File
@@ -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::<Vec<_>>();
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).
+6
View File
@@ -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.
+1 -1
View File
@@ -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};
+3 -8
View File
@@ -98,8 +98,9 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2) -> Option<Hit> {
}
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)
+48 -30
View File
@@ -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<Edit> {
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<Hit> {
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);
+48 -27
View File
@@ -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<FieldTool>,
allowed: bool,
prep: &PrepareView,
p: Pos2,
) -> Option<Hit> {
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<FieldTool>, 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
}
+59
View File
@@ -107,6 +107,31 @@ fn checkbox_pdf() -> Vec<u8> {
])
}
/// 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<u8> {
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<u8> {
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<u8>, 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<String> {
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::<String>::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.