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 <noreply@anthropic.com>
This commit is contained in:
alehelandClaude Opus 5.5 committed 2026-11-02 20:38:37 +01:00
1 parent 71d222cacf
commit 2b256ab0c7
6 files changed
+215 -59

No files matched your search

+26 -28
View File
@@ -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<Owner>) -> Option<usize> {
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<crate::page_router::Hit> {
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.
@@ -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(",
+1 -1
View File
@@ -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 },
+6 -9
View File
@@ -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<Hit> {
/// 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<Hit> {
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<Hit> {
}
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
+47 -19
View File
@@ -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<Hit> {
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<usize>, context: Option<usize>, 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;
+133 -1
View File
@@ -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<u8> {
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<u8> {
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<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 /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<String> {
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");
}