mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 04:28:20 -06:00
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:
co-authored by
Claude Opus 5.5
parent
d09eabd664
commit
b0a692c139
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user