mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 18:52:36 +00:00
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:
1 parent
71d222cacf
commit
2b256ab0c7
6 files changed
+215
-59
No files matched your search
@@ -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(",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user