fix(ui): right-clicks with Prepare, the Link tool and Add text as before

Routing right-clicks by what lies on top under the pointer changed three cases beyond the
rulings:
- **Prepare.** A right-click while a field was dragged selected the field under the pointer, and
  the release then moved that one. Prepare used to ignore right-clicks during a drag; it does
  again (`prepare::context` returns while a grab is held).
- **The Link tool.** A note over a link took the right-click, though the Link tool's press there
  is the link's. The Link tool's right-click on a link is the link's again, whatever lies over it.
- **Add text.** Comments took right-clicks away from paragraphs and added items. Add text used to
  take every right-click on a page it can change; it does again.

Tests: those three, and two that pin ruling 6's right-click half with the Select tool: no link
menu on a link with no action, and none on a form field over a link. Each was seen failing with
its fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alehelandClaude Opus 5.5 committed 2026-11-02 20:39:10 +01:00
1 parent 734cb4c13a
commit b1a0657c42
4 files changed
+116 -5

No files matched your search

+16 -4
View File
@@ -316,9 +316,13 @@ fn tool_input(
/// selected; links take theirs from what this returns.
fn right_click(hits: &PageHits<'_>, view: &mut DocView, p: Pos2) -> Option<Owner> {
let context = hits.context_at(view, p);
// Edit text's own paragraphs and pictures keep the right-click as they take it today, and a
// Prepare field tool the whole page.
let editing_here = matches!(hits.tool, QuickTool::AddText | QuickTool::EditText) && top(hits.at(view, p), hits.tool).is_some();
// Add text takes every right-click on a page it can change, Edit text those on its own
// paragraphs and pictures, and a Prepare field tool the whole page, as before.
let editing_here = match hits.tool {
QuickTool::AddText => hits.can_modify || top(hits.at(view, p), hits.tool).is_some(),
QuickTool::EditText => top(hits.at(view, p), hits.tool).is_some(),
_ => false,
};
if !editing_here && hits.field_tool.is_none() {
let comment = match context {
Some(Owner::Comment { index, .. }) => Some(index),
@@ -385,8 +389,16 @@ impl PageHits<'_> {
/// What a right-click at `p` is on: what lies on top there, as for a press, but with every
/// tool reaching comments and links, as a comment's menu shows whatever the tool. A highlight
/// over a link is the highlight's ([`comments::context_hit`]).
/// The Link tool edits links, so its right-click on one is the link's whatever lies over it, as
/// its press there is.
fn context_at(&self, view: &DocView, p: Pos2) -> Option<Owner> {
top(self.hits(view, p, true), QuickTool::Select).map(|h| h.owner)
let hits = self.hits(view, p, true);
if self.tool == QuickTool::Link
&& let Some(link) = hits.iter().rev().find(|h| matches!(h.owner, Owner::Link { .. }))
{
return Some(link.owner);
}
top(hits, QuickTool::Select).map(|h| h.owner)
}
/// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text
+5 -1
View File
@@ -462,11 +462,15 @@ pub(crate) fn page_input(
/// A right-click on widget `widget` of field `field` (the top hit there, [`hit`]): the field is
/// selected, so the menu acts on it. Part of a selection of several, it becomes the anchor the
/// others line up with.
/// others line up with. Not while a field is dragged or a rectangle drawn: the release acts on
/// the selection the drag began with.
pub(crate) fn context(form: &[FormField], field: usize, widget: usize, view: &mut DocView) {
let Some(f) = form.get(field) else { return };
let key = (f.name.clone(), widget);
let prep = &mut view.prepare;
if prep.grab.is_some() || prep.ignore_release {
return;
}
if let Some(i) = prep.also.iter().position(|k| *k == key) {
prep.also.remove(i);
if let Some(old) = prep.selected.replace(key) {
+58
View File
@@ -1226,3 +1226,61 @@ fn a_fields_tooltip_shows_only_where_the_field_takes_the_press() {
rest_at(&mut h, on_field);
assert!(h.query_all_by_label("Your name").next().is_some(), "the field's tooltip where it takes the press");
}
/// With the Link tool a right-click on a link is the link's, as its press there is, though a note
/// lies over it: the Link tool edits links and reaches no comment.
#[test]
fn a_right_click_with_the_link_tool_on_a_note_over_a_link_selects_the_link() {
let mut h = open(note_over_link_pdf(), "150", false);
link_tool(&mut h);
let on_both = at(&h, 150.0, 325.0);
right_click(&mut h, on_both);
let v = &h.state().views[0];
assert_eq!((v.links.selected, v.links.context, v.comments.selected), (Some((0, 0)), Some((0, 0)), None), "the link, not the note");
}
/// With Add text a right-click leaves comments alone, as before: Add text takes the page's
/// right-clicks.
#[test]
fn a_right_click_with_add_text_selects_no_comment() {
let mut h = open(comment_on_text_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::AddText;
h.run_steps(4);
// Above the line of text the comment covers, where no paragraph is.
let on_comment = at(&h, 100.0, 317.0);
right_click(&mut h, on_comment);
assert_eq!(h.state().views[0].comments.selected, None, "the comment under the pointer is not selected");
}
/// With the Select tool a right-click finds a link only where a click follows it: a link that
/// does nothing has no menu. The Link tool's own test used to give it one.
#[test]
fn a_right_click_with_the_select_tool_on_a_link_with_no_action_opens_no_link_menu() {
let mut h = open(dead_link_pdf(), "150", false);
let dead = at(&h, 25.0, 325.0);
right_click(&mut h, dead);
assert_eq!(h.state().views[0].links.context, None);
assert!(h.query_by_label("Remove all links").is_none(), "the page's menu has no link items");
}
/// [`link_pdf`], with a check box over the middle of its link.
fn field_over_link_pdf() -> Vec<u8> {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [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 /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [140 315 160 335] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>",
])
}
/// A right-click on a form field over a link is the field's, as a press there is: no link menu.
#[test]
fn a_right_click_on_a_field_over_a_link_opens_no_link_menu() {
let mut h = open(field_over_link_pdf(), "150", false);
let on_field = at(&h, 150.0, 325.0);
right_click(&mut h, on_field);
assert_eq!(h.state().views[0].links.context, None, "the field is on top");
assert!(h.query_by_label("Remove all links").is_none());
}
+37
View File
@@ -1562,3 +1562,40 @@ fn button_export_properties_apply_cancel_and_undo() {
assert_eq!(f.export_of(0), Some(original.as_str()));
}
}
/// The screen rectangle of widget 0 of field `name` on the first view.
fn field_rect(h: &Harness<'static, PdfCraftApp>, name: &str) -> egui::Rect {
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
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")
}
/// A right-click while a field is dragged leaves the dragged field selected, so the drag moves it
/// and not the field right-clicked. The right-click used to select the other field, and the release
/// moved that one.
#[test]
fn a_right_click_during_a_field_drag_moves_the_dragged_field() {
let mut h = harness();
h.state_mut().execute("form.prepare");
h.run_steps(3);
let (name, city) = (field_rect(&h, "name"), field_rect(&h, "city"));
let a = name.center();
h.hover_at(a);
h.run_steps(1);
h.drag_at(a);
h.run_steps(1);
h.hover_at(a + egui::vec2(0.0, 30.0));
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: city.center(), button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() });
}
h.run_steps(1);
let end = a + egui::vec2(0.0, 60.0);
h.hover_at(end);
h.run_steps(1);
h.drop_at(end);
h.run_steps(3);
assert_eq!(field_rect(&h, "city"), city, "the field right-clicked stays where it was");
assert_ne!(field_rect(&h, "name"), name, "the dragged field moved");
}