From b0a692c1391fbb25f42263d653d1b7c9e7157bcd Mon Sep 17 00:00:00 2001 From: Aleksander Helgaker Date: Thu, 15 Oct 2026 08:43:49 +0200 Subject: [PATCH] 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 --- crates/ui-egui/src/canvas.rs | 22 +++++++---- crates/ui-egui/src/forms_ui.rs | 27 +++++++++---- crates/ui-egui/tests/forms.rs | 72 ++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6c812da70..30b652b36 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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)); diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index e95f4f0eb..bcaeac220 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -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. diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 89bd0b850..2abc3eff2 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -399,6 +399,78 @@ fn pdf_from_objects(objs: &[&str]) -> Vec { 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 { + 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::>()) +} + +#[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");