refactor(ui): route right-clicks by what lies on top under the pointer

Each right-click handler tested the pointer itself, and the chain's order decided which ran:
comments only when no earlier module took the frame, Prepare by its own widget scan.

A right-click now goes to what lies on top under the pointer (`PageHits::context_at`), by the
layer ranking a press uses, with two differences. Comments count with every tool, as a
comment's menu opens whatever tool is active (`comments::context_hit`). A highlight over a link
is the highlight's: a left click there follows the link, a right-click opens the highlight's
menu. On every right-click the page's menu is anchored at the pointer, and the comment it is
on is selected, or none (`comments::context`); a Prepare field is selected (`prepare::context`);
links take theirs from the same answer. Edit text's own paragraphs and pictures, and a Prepare
field tool, keep their right-clicks as before.

Behaviour change (the user's ruling, 2026-10-31): a right-click on a highlight over a link opens
the highlight's menu, not the link's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alehelandClaude Opus 5.5 committed 2026-11-02 20:38:40 +01:00
1 parent 2b256ab0c7
commit bab615fed1
6 files changed
+120 -30

No files matched your search

+37 -4
View File
@@ -98,8 +98,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
let at = hits.at(view, o);
view.router.press(i, at, tool);
}
// A right-click goes to what a press there would go to: the top hit under the pointer.
let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| top(hits.at(view, p), tool)).map(|h| h.owner);
let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, view, p));
if let QuickTool::Measure(measure_tool) = tool {
crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units);
}
@@ -303,6 +302,27 @@ fn link_of(owner: Option<Owner>) -> Option<usize> {
}
}
/// A right-click at `p`: it goes to what lies on top there ([`PageHits::context_at`]), which the
/// page's menu then acts on. A comment there is selected, and none elsewhere; a Prepare field is
/// 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();
if !editing_here && hits.field_tool.is_none() {
let comment = match context {
Some(Owner::Comment { index, .. }) => Some(index),
_ => None,
};
comments::context(hits.pcx, p, comment, view);
}
if let Some(Owner::PrepareField { field, widget }) = context {
crate::prepare::context(hits.form, field, widget, view);
}
context
}
/// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged.
fn edit_inventory(
view: &mut DocView,
@@ -348,12 +368,25 @@ struct PageHits<'a> {
}
impl PageHits<'_> {
/// The hits at `p`, bottom up ([`Self::hits`]).
fn at(&self, view: &DocView, p: Pos2) -> Vec<crate::page_router::Hit> {
self.hits(view, p, false)
}
/// 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`]).
fn context_at(&self, view: &DocView, p: Pos2) -> Option<Owner> {
top(self.hits(view, p, true), QuickTool::Select).map(|h| h.owner)
}
/// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text
/// on top of artwork takes the press), added content, the objects selected together and the
/// open paragraph's frame, the selected picture's grips over them all; then form fields
/// (Prepare's, being moved, over the comments; filled ones under added content and the
/// comments that move).
fn at(&self, view: &DocView, p: Pos2) -> Vec<crate::page_router::Hit> {
/// For a right-click (`context`), comments by [`comments::context_hit`].
fn hits(&self, view: &DocView, p: Pos2, context: bool) -> Vec<crate::page_router::Hit> {
let PageHits {
page,
xf,
@@ -415,7 +448,7 @@ impl PageHits<'_> {
let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText);
let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten();
let link = crate::link_ui::hit(xf, info, page, doc_links, tool, p);
let comment = comments::hit(pcx, view, p, link.is_some());
let comment = if context { comments::context_hit(pcx, p) } else { comments::hit(pcx, view, p, link.is_some()) };
[link, comment, pictures, paragraphs, added, objects, grips, fields].into_iter().flatten().collect()
}
}
@@ -417,7 +417,11 @@ fn hit_test_calls(line: &str) -> Vec<String> {
/// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the
/// press point; the others ask where the pointer is.
const HIT_TESTERS: [(&str, &str, &str); 4] = [
("canvas/page_input.rs", "at", "`PageHits::at`: every module's hits at a point, for the pointer's cursor and tip and, once, the press's owner"),
(
"canvas/page_input.rs",
"hits",
"`PageHits::hits`: every module's hits at a point, for the pointer's cursor and tip, a right-click and, once, the press's owner",
),
("canvas/page_input.rs", "page_overlays", "a link's click counts only released over the link its press was on"),
("tools/forms_ui.rs", "page_input", "the field's tooltip under the pointer, and a click counts only released over the widget its press was on"),
("tools/comments/input.rs", "grabs", "with no button down, whether a comment would take a press under the pointer, for hover painting"),
+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};
pub(crate) use input::{context, context_hit, 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};
+19 -6
View File
@@ -27,12 +27,6 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>,
if cv.cancelled_drag {
return true;
}
if resp.secondary_clicked()
&& let Some(p) = pointer.filter(|p| page_rect.contains(*p))
{
cv.context_at = Some((cx.page, cx.to_user(p)));
cv.selected = cx.hit(p).map(|a| (cx.page, a.index));
}
let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page };
match cx.tool {
QuickTool::Comment(_) if draw_grab => select_input(ui, resp, cx, view, pointer, origin),
@@ -106,6 +100,25 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op
Some(Hit { declines_drag: markup, ..Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, icon) })
}
/// The comment a right-click at `p` is on: the topmost, with every tool, as a comment's menu shows
/// whatever tool is active. A highlight over a link is the highlight's here (a left click on it
/// follows the link, [`hit`]). `None` off the page or with comments hidden.
pub(crate) fn context_hit(cx: &PageCx<'_>, p: Pos2) -> Option<Hit> {
if cx.hidden || !cx.xf.rect.contains(p) {
return None;
}
let a = cx.hit(p)?;
let layer = if a.fill_sign || !is_markup(&a.subtype) { Layer::Placed } else { Layer::Comment };
Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, egui::CursorIcon::Default))
}
/// A right-click at `p` on this page: the page's menu is anchored there (Copy Image), and the
/// comment it is on ([`context_hit`], when it wins there) is selected, or none.
pub(crate) fn context(cx: &PageCx<'_>, p: Pos2, index: Option<usize>, view: &mut DocView) {
view.comments.context_at = Some((cx.page, cx.to_user(p)));
view.comments.selected = index.map(|i| (cx.page, i));
}
/// Whether the comments take the press on this page (or, with no button down, would take one
/// under the pointer), so the tools beneath leave it: a live move, resize or reshape, or a press
/// held by a comment that keeps its drag.
+18 -18
View File
@@ -398,24 +398,6 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti
return out;
}
// Right-click a field: select it (the context menu acts on it).
if resp.secondary_clicked()
&& 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(p)).map(|(i, _)| (f, i))
})
{
let key = (f.name.clone(), wi);
if let Some(i) = prep.also.iter().position(|k| *k == key) {
// Part of the selection: it becomes the anchor the others line up with.
prep.also.remove(i);
if let Some(old) = prep.selected.replace(key) {
prep.also.push(old);
}
} else if prep.selected.as_ref() != Some(&key) {
prep.selected = Some(key);
prep.also.clear();
}
}
// 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.
@@ -469,6 +451,24 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti
out
}
/// 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.
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 let Some(i) = prep.also.iter().position(|k| *k == key) {
prep.also.remove(i);
if let Some(old) = prep.selected.replace(key) {
prep.also.push(old);
}
} else if prep.selected.as_ref() != Some(&key) {
prep.selected = Some(key);
prep.also.clear();
}
}
/// Align, Center, Distribute and Set Fields to Same Size (Prepare a form, several fields
/// selected).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+40
View File
@@ -1089,3 +1089,43 @@ fn a_right_click_on_a_note_over_a_link_opens_the_notes_menu() {
assert_eq!((v.links.context, v.comments.selected), (None, Some((0, 1))), "the note is selected, not the link");
assert!(h.query_by_label("Remove all links").is_none(), "the menu is the note's");
}
/// [`link_pdf`], with a highlight drawn over the middle of its link.
fn highlight_over_link_pdf() -> Vec<u8> {
pdf(&[
CATALOG,
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 /Highlight /Rect [100 310 200 340] /QuadPoints [100 340 200 340 100 310 200 310] /C [1 1 0] /F 4 /T (Ana) >>",
])
}
/// A click on a highlight over a link follows the link, but a right-click there is the
/// highlight's: it is selected and its menu opens. The link's menu used to open, with the
/// highlight selected underneath.
#[test]
fn a_right_click_on_a_highlight_over_a_link_opens_the_highlights_menu() {
let mut h = open(highlight_over_link_pdf(), "150", false);
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.context, v.comments.selected), (None, Some((0, 1))), "the highlight is selected, not the link");
assert!(h.query_by_label("Remove all links").is_none(), "the menu is the highlight's");
h.key_press(egui::Key::Escape);
h.run_steps(3);
mouse_click(&mut h, on_both);
assert!(h.state().pending_link.is_some(), "a click there still follows the link");
}
/// A comment's menu opens on a right-click whatever the tool, though a press there is the tool's:
/// with the Rectangle tool, a right-click on a comment selects it.
#[test]
fn a_right_click_on_a_comment_with_a_drawing_tool_opens_its_menu() {
let mut h = open(comment_on_text_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle);
h.run_steps(4);
let on_comment = at(&h, 100.0, 305.0);
right_click(&mut h, on_comment);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu");
}