fix(ui): check boxes over text show the pointing hand, not the I-beam (#2099)

Hovering a check box or radio button that sits on page text showed the
text-selection I-beam. The form layer set the pointing hand, but on a
plain hover it reported the pointer as not consumed, so the text layer
ran afterwards and replaced the cursor whenever a glyph was under it.
Character sheets hit this on every dot: the boxes are set on printed
"OOOOO", and boxes whose checked look is a font glyph (an "X") count as
text too, since page text includes annotation appearances.

forms_ui::page_input now says whether the pointer is merely over a field
or the field owns the press, and the text layer leaves the cursor alone
while a field is under the pointer. Presses, clicks and drags behave as
before; text fields keep their I-beam.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aleksander Helgaker
2026-10-15 02:43:49 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d09eabd664
commit b0a692c139
3 changed files with 107 additions and 14 deletions
+15 -7
View File
@@ -2823,7 +2823,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
view.current = i;
open_boxes = true;
}
let on_field = if preparing {
// `over_field`: a form field is under the pointer and has set its cursor.
let (on_field, over_field) = if preparing {
let field_tool = match tool {
QuickTool::Field(f) => Some(f),
_ => None,
@@ -2831,11 +2832,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let o = crate::prepare::page_input(ui, &resp, &xf, i, info, &form, field_tool, can_modify, view);
field_props |= o.properties;
field_placed |= o.placed;
o.consumed || field_tool.is_some()
(o.consumed || field_tool.is_some(), false)
} else {
tool == QuickTool::Select
&& !comments::select_grabs(ui, &pcx, view)
&& crate::forms_ui::page_input(ui, &resp, &xf, i, info, &form, can_fill, view)
use crate::forms_ui::FieldPointer;
let field = if tool == QuickTool::Select && !comments::select_grabs(ui, &pcx, view) {
crate::forms_ui::page_input(ui, &resp, &xf, i, info, &form, can_fill, view)
} else {
FieldPointer::Away
};
(field == FieldPointer::Owned, field == FieldPointer::Over)
};
// Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat).
let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed;
@@ -2937,9 +2942,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// Column select (#740): the Column select tool, or Alt (Option on macOS) held
// while a drag starts, as in Acrobat.
let column = tool == QuickTool::ColumnSelect || ui.input(|inp| inp.modifiers.alt);
if column && !over_link {
// A link or a form field under the pointer keeps its own cursor: a check box
// drawn with a glyph, or set on printed text, shows the hand, not the I-beam.
let cursor_free = !over_link && !over_field;
if column && cursor_free {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
} else if over_text && !over_link {
} else if over_text && cursor_free {
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
}
let press_here = ui.input(|inp| inp.pointer.press_origin()).is_some_and(|o| r.contains(o));
+20 -7
View File
@@ -84,7 +84,20 @@ fn open_focus(f: &FormField, widget: usize) -> Focus {
}
}
/// Clicks on form widgets of one page. Returns `true` when the click belongs to a field.
/// Where the pointer is relative to one page's form widgets.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FieldPointer {
/// Not over a field.
Away,
/// Over a field, which has set the cursor. Nothing is pressed, so the layers below still see
/// the hover, but they must not replace the cursor: a check box drawn with a glyph, or set
/// on printed text, is a button, not text to select.
Over,
/// The field owns the press or click.
Owned,
}
/// Clicks on form widgets of one page.
#[allow(clippy::too_many_arguments)]
pub(crate) fn page_input(
ui: &egui::Ui,
@@ -95,9 +108,9 @@ pub(crate) fn page_input(
form: &[FormField],
allowed: bool,
view: &mut DocView,
) -> bool {
) -> FieldPointer {
let pointer = ui.input(|i| i.pointer.hover_pos());
let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false };
let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return FieldPointer::Away };
let hit = form.iter().find_map(|f| {
f.widgets
.iter()
@@ -105,7 +118,7 @@ pub(crate) fn page_input(
.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 };
let Some((f, wi, w)) = hit else { return FieldPointer::Away };
// 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();
@@ -116,11 +129,11 @@ pub(crate) fn page_input(
_ => egui::CursorIcon::PointingHand,
});
if !resp.clicked() {
return resp.is_pointer_button_down_on();
return if resp.is_pointer_button_down_on() { FieldPointer::Owned } else { FieldPointer::Over };
}
if !allowed {
view.forms.notice = Some(FormNotice::Security);
return true;
return FieldPointer::Owned;
}
match f.kind {
_ if f.read_only() => view.forms.notice = Some(FormNotice::ReadOnly(f.name.clone())),
@@ -149,7 +162,7 @@ pub(crate) fn page_input(
}
}
}
true
FieldPointer::Owned
}
/// The edit the focused field's draft makes, if it differs from the field's value.
+72
View File
@@ -399,6 +399,78 @@ fn pdf_from_objects(objs: &[&str]) -> Vec<u8> {
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");