From 2b256ab0c79eff8c97b54efdf143685faab4b65c Mon Sep 17 00:00:00 2001 From: alehel Date: Sat, 31 Oct 2026 08:31:01 +0100 Subject: [PATCH] refactor(ui): one link test for the Select tool, the Link tool and right-clicks Three link tests disagreed. The Select tool's hand and click (`link_hit`) took the topmost link the renderer lists, with no margin, by its place in that list. The Link tool's selection and Select's right-click took the bottom one of every link the engine lists, 2 px around each, by `/Annots` index. Comments had a fourth (`on_link`). Now `link_ui::hit` is the one test, and `Owner::Link` carries the `/Annots` index: - the Link tool finds every link the engine lists, 2 px around each, and takes the topmost; a drag from a link still draws a new one (the hit declines the drag); - every other tool finds the links the renderer can follow, with no margin (so Select shows no hand over a link with no action, as before), and takes the topmost; - following a link looks its target up by that index; - `comments::hit` is told whether a link is under the point. The Link tool is told the link its press is on, and the one a right-click is on, instead of testing the pointer itself. A right-click goes to the top hit under the pointer, so with the Select tool a right-click on a note over a link selects the note and opens its menu, and one just beside a link no longer opens the link's. Behaviour changes (the user's rulings, 2026-10-31): the Link tool takes the topmost of stacked links; Select's right-click has no margin; a right-click on a note over a link is the note's. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 54 ++++--- .../ui-egui/src/canvas/page_pointer/tests.rs | 3 +- crates/ui-egui/src/canvas/page_router.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 15 +- crates/ui-egui/src/tools/link_ui.rs | 66 ++++++--- crates/ui-egui/tests/it/pointer.rs | 134 +++++++++++++++++- 6 files changed, 215 insertions(+), 59 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 32500d7154..5ca60f8684 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -2,11 +2,12 @@ //! router, the text layer, and what is drawn over the page after its text. use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2}; -use pdfcraft_render::{DocInfo, LinkTarget, PageText}; +use pdfcraft_render::{DocInfo, PageText}; use super::{DocView, PageEnv, PageFrame, PageSlot, PageXform, Selection, Span, cached_edit_images, click_unit, signature_badges}; use crate::page_cursor::{Layer, Subject}; use crate::page_pointer::PagePointer; +use crate::page_router::{Owner, top}; use crate::{QuickTool, comments, icons}; /// What the page's tools did with the pointer this frame. @@ -88,6 +89,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut field_tool, prepare_allowed, added, + doc_links, lines: &lines, images: &images, clip: ui.clip_rect(), @@ -96,6 +98,8 @@ 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); if let QuickTool::Measure(measure_tool) = tool { crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); } @@ -270,14 +274,13 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut crate::content_ui::sync_image_selection(view, i, &images, added); } } - let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, doc_links, view); + let (link_owner, link_context) = (link_of(view.router.owner(i)), link_of(context)); + let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, link_owner, link_context, view); if tool == QuickTool::Select - && resp.secondary_clicked() && can_modify - && let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) - && let Some(l) = doc.links().iter().find(|l| l.page == i && crate::link_ui::screen_rect(&xf, info, i, l.rect).expand(2.0).contains(p)) + && let Some(index) = link_context { - view.links.context = Some((i, l.index)); + view.links.context = Some((i, index)); } let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, resp, &pcx, view); // The cursor and the tip from what lies under the pointer, after the tools claimed theirs (the @@ -292,6 +295,14 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageInput { consumed, area_tool } } +/// The link `owner` is, by its `/Annots` index. +fn link_of(owner: Option) -> Option { + match owner { + Some(Owner::Link { index }) => Some(index), + _ => None, + } +} + /// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged. fn edit_inventory( view: &mut DocView, @@ -328,6 +339,8 @@ struct PageHits<'a> { /// Prepare may draw and move fields (not while a table is being set up). prepare_allowed: bool, added: &'a [pdfcraft_engine::Added], + /// The document's links, as the engine lists them (the Link tool edits every one). + doc_links: &'a [pdfcraft_engine::LinkItem], lines: &'a [pdfcraft_engine::TextBlock], images: &'a [pdfcraft_engine::PageImage], /// The pages' visible area (the picture's rotate knob stays inside it). @@ -355,6 +368,7 @@ impl PageHits<'_> { field_tool, prepare_allowed, added, + doc_links, lines, images, clip, @@ -400,10 +414,9 @@ impl PageHits<'_> { // together. 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(); - [link_hit(info, xf, page, p), comments::hit(pcx, view, p), pictures, paragraphs, added, objects, grips, fields] - .into_iter() - .flatten() - .collect() + let link = crate::link_ui::hit(xf, info, page, doc_links, tool, p); + let comment = comments::hit(pcx, view, p, link.is_some()); + [link, comment, pictures, paragraphs, added, objects, grips, fields].into_iter().flatten().collect() } } @@ -694,9 +707,9 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P && resp.clicked() && tool == QuickTool::Select && !input.consumed - && let Some(owner @ crate::page_router::Owner::Link { index }) = view.router.owner(i) - && pointer.and_then(|p| link_hit(info, &xf, i, p)).is_some_and(|h| h.owner == owner) - && let Some(l) = info.links.get(index) + && let Some(owner @ Owner::Link { index }) = view.router.owner(i) + && pointer.and_then(|p| crate::link_ui::hit(&xf, info, i, doc_links, tool, p)).is_some_and(|h| h.owner == owner) + && let Some(l) = info.links.iter().find(|l| l.page == i && l.index == index) { frame.out.clicked_link = Some(l.target.clone()); } @@ -777,21 +790,6 @@ fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInf } } -/// The link under `p` on `page`: the hand, and where a click goes. Only the Select tool follows -/// links and the Link tool edits them (`Layer::reached_by`). -fn link_hit(info: &DocInfo, xf: &PageXform, page: usize, p: Pos2) -> Option { - let (index, l) = info.links.iter().enumerate().rfind(|(_, l)| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?; - let label = match &l.target { - LinkTarget::Page(n, _) => crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]), - LinkTarget::Uri(u) => u.clone(), - LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), - LinkTarget::RemoteGoTo { file, .. } => file.clone(), - LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), - }; - let owner = crate::page_router::Owner::Link { index }; - Some(crate::page_router::Hit { tip: Some(label), ..crate::page_router::Hit::new(owner, Layer::Link, egui::CursorIcon::PointingHand) }) -} - /// Escape while the pages hold a press abandons the gesture it drives, whichever tool's: nothing is /// placed, drawn, moved, resized or opened, and the press is ignored until the button is up. The /// frame of the release still belongs to the cancelled press. diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 248b627298..4f9398be85 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -432,7 +432,8 @@ const PRESS_IGNORERS: [(&str, &str, &str); 1] = /// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who /// may). -const PAGE_HIT_CALLS: [&str; 9] = [ +const PAGE_HIT_CALLS: [&str; 10] = [ + "link_ui::hit(", "prepare::hit(", "forms_ui::hit(", "comments::hit(", diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 8b91cc865c..a9f7500189 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -18,7 +18,7 @@ use crate::page_cursor::Layer; pub(crate) enum Owner { /// A comment (or a Fill & Sign mark), by its `/Annots` index, and the part of it. Comment { index: usize, part: CommentPart }, - /// A link, by its index in the document's links. + /// A link, by its index in its page's `/Annots`. Link { index: usize }, /// A form field's widget, by the field's index in the form and the widget's in the field. Field { field: usize, widget: usize }, diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index a7ef6c92e1..527df65d01 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -54,8 +54,9 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, /// only, as Acrobat picks them up: a press anywhere else places the tool's mark. With a shape /// tool (rectangle, oval, line, polygon…), only the selected comment's handles, so the shape just /// drawn can be resized straight away (#2338); a press anywhere else, the shape itself included, -/// draws. `None` with other tools, off the page, or with comments hidden. -pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2) -> Option { +/// draws. `None` with other tools, off the page, or with comments hidden. `on_link`: a link lies +/// under `p` ([`crate::link_ui::hit`]). +pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Option { if cx.hidden || !cx.xf.rect.contains(p) { return None; } @@ -93,7 +94,7 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2) -> Option { } let a = cx.hit(p).filter(|a| bodies && pick(a))?; // A click on a highlight over a link follows the link, so the link's hand and tip show there. - if cx.tool == QuickTool::Select && a.subtype == "Highlight" && on_link(cx, p) { + if cx.tool == QuickTool::Select && a.subtype == "Highlight" && on_link { return None; } let markup = is_markup(&a.subtype); @@ -116,7 +117,8 @@ fn grabs(cx: &PageCx<'_>, view: &DocView) -> bool { if cx.ptr.any_down { return matches!(view.router.owner(cx.page), Some(Owner::Comment { .. })) && view.router.keeps_drag(); } - cx.ptr.pos.and_then(|p| hit(cx, view, p)).is_some_and(|h| !h.declines_drag) + // Only the Select tool lets a link through a highlight, and it doesn't come here. + cx.ptr.pos.and_then(|p| hit(cx, view, p, false)).is_some_and(|h| !h.declines_drag) } /// With a Fill & Sign tool, a Fill & Sign mark already on the page is picked up as with the Select @@ -130,11 +132,6 @@ fn draw_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { matches!(cx.tool, QuickTool::Comment(_)) && grabs(cx, view) } -/// Whether `p` is on one of this page's links. -fn on_link(cx: &PageCx<'_>, p: Pos2) -> bool { - cx.info.links.iter().any(|link| link.page == cx.page && cx.xf.user_rect(cx.info, cx.page, link.rect).contains(p)) -} - /// The tip for the comment under the pointer, with any tool, beside that tool's own cursor: Acrobat /// shows a comment's tooltip whatever tool is active. Not with Fill & Sign, whose placement stays /// clear; not while drawing freehand, where it would cover the next stroke (#429); and not while a diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index a0840e1b89..699383cbfe 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -4,14 +4,17 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkItem, LinkStyle}; -use pdfcraft_render::DocInfo; +use pdfcraft_render::{DocInfo, LinkTarget}; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::page_cursor::Layer; +use crate::page_router::{Hit, Owner}; use crate::theme::{self, Tokens}; -use crate::{PdfCraftApp, widgets}; +use crate::{PdfCraftApp, QuickTool, widgets}; const LINK_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); +/// How far around a link (screen points) the Link tool finds it, so a thin one can be picked. +const LINK_TOOL_MARGIN: f32 = 2.0; #[derive(Clone, Debug, Default, PartialEq)] pub struct LinkView { @@ -56,8 +59,38 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { [u[0] as f64, u[1] as f64] } -/// Link tool input on one page. Returns `true` when the gesture belongs to the tool. -pub(crate) fn page_input(cx: PageToolContext<'_>, links: &[LinkItem], view: &mut DocView) -> bool { +/// The link under `p` on `page`: the one hit test of links, for the hand, the tip and the press. +/// Of stacked links, the topmost (the last in `/Annots`, drawn on top). Only the Select tool +/// follows links and the Link tool edits them (`Layer::reached_by`). The Link tool finds every link +/// the document has (`links`, the engine's), with a margin around each so a thin one can be +/// picked, and a drag from one draws a new link. The other tools find the links the renderer can +/// follow, which have a target (`info.links`). The tip is the link's target. +pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, links: &[LinkItem], tool: QuickTool, p: Pos2) -> Option { + let index = if tool == QuickTool::Link { + links.iter().rfind(|l| l.page == page && screen_rect(xf, info, page, l.rect).expand(LINK_TOOL_MARGIN).contains(p))?.index + } else { + info.links.iter().rfind(|l| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?.index + }; + let tip = info.links.iter().find(|l| l.page == page && l.index == index).map(|l| target_label(info, &l.target)); + let declines_drag = tool == QuickTool::Link; + Some(Hit { tip, declines_drag, ..Hit::new(Owner::Link { index }, Layer::Link, egui::CursorIcon::PointingHand) }) +} + +/// What a link's tip says it goes to. +fn target_label(info: &DocInfo, target: &LinkTarget) -> String { + match target { + LinkTarget::Page(n, _) => crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]), + LinkTarget::Uri(u) => u.clone(), + LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), + LinkTarget::RemoteGoTo { file, .. } => file.clone(), + LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), + } +} + +/// Link tool input on one page: `owner` is the link that holds the press (by its `/Annots` +/// index), and `context` the one a right-click is on. Returns `true` when the gesture belongs to +/// the tool. +pub(crate) fn page_input(cx: PageToolContext<'_>, owner: Option, context: Option, view: &mut DocView) -> bool { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let pointer = ptr.pos; let lv = &mut view.links; @@ -76,26 +109,21 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, links: &[LinkItem], view: &mut return true; } let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false }; - let hit = links.iter().filter(|l| l.page == page).find(|l| screen_rect(xf, info, page, l.rect).expand(2.0).contains(p)); - match hit { - Some(_) => view.cursor.claim(Layer::Link, egui::CursorIcon::PointingHand), - None => view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair), - } - if resp.secondary_clicked() - && let Some(l) = hit - { - lv.selected = Some((page, l.index)); - lv.context = Some((page, l.index)); + // Over a link, its hit claims the hand. + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + if let Some(index) = context { + lv.selected = Some((page, index)); + lv.context = Some((page, index)); return true; } if resp.clicked() || resp.double_clicked() { let now = ui.input(|i| i.time); // A second click on the same link soon after the first is a double-click. - let double = resp.double_clicked() || hit.is_some_and(|l| lv.last_click.is_some_and(|(t, p, k)| p == page && k == l.index && now - t < 0.5)); - lv.last_click = hit.map(|l| (now, page, l.index)); - lv.selected = hit.map(|l| (page, l.index)); - if double && let Some(l) = hit { - lv.open_existing = Some((page, l.index)); + let double = resp.double_clicked() || owner.is_some_and(|k| lv.last_click.is_some_and(|(t, p, l)| p == page && l == k && now - t < 0.5)); + lv.last_click = owner.map(|k| (now, page, k)); + lv.selected = owner.map(|k| (page, k)); + if double && let Some(k) = owner { + lv.open_existing = Some((page, k)); lv.last_click = None; } return true; diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 889caf4c1c..1ee9676ad2 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -11,7 +11,7 @@ use egui_kittest::kittest::Queryable; use pdfcraft_engine::StampKind; use pdfcraft_ui_egui::comments::CommentTool; use pdfcraft_ui_egui::fill_sign::FillTool; -use pdfcraft_ui_egui::{PdfCraftApp, QuickTool, UnlockPrompt}; +use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool, UnlockPrompt}; use crate::support::input::{coalesced_click, double_click, one_frame, touch_drag, touch_tap}; @@ -957,3 +957,135 @@ fn escape_cancels_a_crop_rectangle() { let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None, "no page box was set"); } + +// --------------------------------------------------------------------------------------------- +// One link test: what the Select tool follows, the Link tool edits and a right-click opens is +// the same link. + +/// A 300×400 page with two web links overlapping at x 150..200, y 300..350: `first` first in +/// `/Annots`, then `second` drawn over it. +fn stacked_links_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 200 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/first) >> >>", + "<< /Type /Annot /Subtype /Link /Rect [150 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/second) >> >>", + ]) +} + +/// A 300×400 page with a link that does nothing (no action) at x 10..40, y 300..350, then a web +/// link at x 50..250 beside it: second in `/Annots`, and the first the renderer can follow. +fn dead_link_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [10 300 40 350] /Border [0 0 0] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + ]) +} + +/// [`link_pdf`], with a rectangle comment drawn over the middle of its link. +fn note_over_link_pdf() -> Vec { + 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 /Square /Rect [100 310 200 340] /C [1 0 0] /F 4 /T (Ana) /Contents (A note) >>", + ]) +} + +fn link_tool(h: &mut Harness<'_, PdfCraftApp>) { + h.state_mut().quick_tool = QuickTool::Link; + h.run_steps(4); +} + +/// A right-click at `p`: press and release in frames of their own. +fn right_click(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) { + h.hover_at(p); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(3); +} + +fn pending_url(h: &Harness<'_, PdfCraftApp>) -> Option { + h.state().pending_link.as_ref().map(|l| l.url.clone()) +} + +/// Of two stacked links the Link tool selects the one drawn on top, the one the Select tool +/// follows. It used to select the one underneath. +#[test] +fn the_link_tool_selects_the_topmost_of_stacked_links() { + let mut h = open(stacked_links_pdf(), "150", false); + let both = at(&h, 175.0, 325.0); + mouse_click(&mut h, both); + assert_eq!(pending_url(&h).as_deref(), Some("https://example.org/second"), "the Select tool follows the link on top"); + h.state_mut().pending_link = None; + link_tool(&mut h); + mouse_click(&mut h, both); + assert_eq!(h.state().views[0].links.selected, Some((0, 1)), "the Link tool selects it too"); +} + +/// The Link tool edits every link, one that does nothing included. The Select tool, which +/// follows links, shows no hand over it, and follows the link beside it by its own `/Annots` +/// place, not by its place among the links the renderer can follow. +#[test] +fn the_link_tool_selects_a_link_with_no_action() { + let mut h = open(dead_link_pdf(), "150", false); + let dead = at(&h, 25.0, 325.0); + assert_ne!(cursor_at(&mut h, dead), egui::CursorIcon::PointingHand, "the Select tool has nothing to follow there"); + let web = at(&h, 150.0, 325.0); + mouse_click(&mut h, web); + assert_eq!(pending_url(&h).as_deref(), Some("https://example.org/"), "the web link is followed"); + h.state_mut().pending_link = None; + link_tool(&mut h); + assert_eq!(cursor_at(&mut h, dead), egui::CursorIcon::PointingHand); + mouse_click(&mut h, dead); + assert_eq!(h.state().views[0].links.selected, Some((0, 0)), "the Link tool selects the link that does nothing"); +} + +/// With the Link tool a drag that starts on a link draws a new link: the press is the link's, its +/// drag is not. +#[test] +fn a_drag_with_the_link_tool_from_a_link_draws_a_new_link() { + let mut h = open(link_pdf(), "150", false); + link_tool(&mut h); + let on_link = at(&h, 150.0, 325.0); + mouse_drag(&mut h, on_link, egui::vec2(80.0, 120.0)); + let s = h.state(); + assert_eq!(s.dialog, Some(Dialog::LinkProps)); + assert_eq!(s.link_draft.as_ref().map(|d| d.index), Some(None), "Link Properties opens for a new link"); +} + +/// With the Select tool a right-click finds a link only where a click follows it: just beside the +/// link it opens the page's menu. The Link tool's margin around links used to reach there. +#[test] +fn a_right_click_beside_a_link_with_the_select_tool_opens_no_link_menu() { + let mut h = open(link_pdf(), "150", false); + // The link ends at x 250. + let beside = at(&h, 250.0, 325.0) + egui::vec2(1.5, 0.0); + right_click(&mut h, beside); + assert_eq!(h.state().views[0].links.context, None, "no link under the right-click"); + assert!(h.query_by_label("Remove all links").is_none(), "the page's menu has no link items"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + let on_link = at(&h, 245.0, 325.0); + right_click(&mut h, on_link); + assert_eq!(h.state().views[0].links.context, Some((0, 0)), "on the link, its menu"); +} + +/// A right-click goes to what a press there would go to: on a note over a link, the note. It used +/// to open the link's menu. +#[test] +fn a_right_click_on_a_note_over_a_link_opens_the_notes_menu() { + let mut h = open(note_over_link_pdf(), "150", false); + let on_note = at(&h, 150.0, 325.0); + right_click(&mut h, on_note); + let v = &h.state().views[0]; + 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"); +}