refactor(ui): move page input out of canvas.rs into canvas/page_input.rs

What the tools do with the pointer on one page now lives in its own file: `PageInput`,
`PageToolContext` and `page_tool_input`; `PageHits`, `edit_inventory`, `link_hit` and
`cancel_on_escape`; the text layer, `text_layer` to `finish_text_pointer_gesture`; and
`page_overlays`, which follows links and picks images. `cached_edit_images` stays in canvas.rs,
where painting and the menus use it too. No change in behaviour. canvas.rs goes from 4,073 to
3,265 lines.

The guard's page modules gain `canvas/page_input.rs`, and its allowlist entries move with the
functions.

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:28 +01:00
1 parent 0cbd61d611
commit 71d222cacf
3 files changed
+826 -811

No files matched your search

+4 -806
View File
@@ -12,7 +12,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHand
use pdfcraft_engine::{DocId, Edit};
use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PRINT_TAG, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels};
use crate::page_cursor::{Layer, Subject};
use crate::page_cursor::Layer;
use crate::page_pointer::PagePointer;
use crate::theme::{self, Tokens};
use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets};
@@ -28,6 +28,7 @@ pub(super) mod image_pick;
mod navigation;
mod organize;
pub(super) mod page_cursor;
mod page_input;
pub(super) mod page_pointer;
pub(super) mod page_router;
pub mod scroll_speed;
@@ -63,6 +64,8 @@ pub(crate) use organize::grid_page_rect;
use organize::organize_grid;
#[cfg(test)]
use organize::sharp_enough;
pub(crate) use page_input::PageToolContext;
use page_input::{PageInput, cancel_on_escape, page_overlays, page_tool_input, text_layer};
use selection::{Selection, Span, click_unit, selection_actions};
pub use xform::PageXform;
use xform::snap_to_pixels;
@@ -1444,308 +1447,6 @@ fn paint_page_tiles(
}
}
/// What the page's tools did with the pointer this frame.
struct PageInput {
/// A tool, a field or a comment took the pointer: text selection must leave it alone.
consumed: bool,
/// Redact or Highlight draws boxes off text.
area_tool: bool,
}
/// Common geometry and pointer state for a tool acting on one page.
#[derive(Clone, Copy)]
pub(crate) struct PageToolContext<'a> {
pub ui: &'a egui::Ui,
pub resp: &'a egui::Response,
pub xf: &'a PageXform,
pub ptr: &'a PagePointer,
pub page: usize,
pub doc: &'a pdfcraft_engine::Document,
pub info: &'a DocInfo,
}
/// Comments: tools, selection, moving and resizing come before text selection. Form fields take
/// clicks first with the Select tool (as Acrobat fills fields in every viewing mode); then
/// comments; then text selection.
fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput {
let PageEnv {
added,
allowed,
author,
by_line,
can_crop,
can_fill,
can_modify,
comments_hidden,
custom_stamp,
date_text,
doc,
doc_links,
editing_content,
form,
info,
initials,
prefs,
preparing,
redact_mark,
signature,
table_preview,
text_style,
tool,
units,
fill_text_size,
fill_text_color,
..
} = frame.env;
let PageSlot { i, xf, .. } = *slot;
let PageFrame { resp, ptr, pointer, .. } = *frame;
let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info };
let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
let (lines, images) =
if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() };
let field_tool = match tool {
QuickTool::Field(f) => Some(f),
_ => None,
};
// While a table is being set up, nothing else is drawn or moved.
let prepare_allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none();
let hits = PageHits {
page: i,
xf: &xf,
info,
tool,
pcx: &pcx,
form,
can_fill,
can_modify,
editing_content,
preparing,
field_tool,
prepare_allowed,
added,
lines: &lines,
images: &images,
clip: ui.clip_rect(),
};
if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) {
let at = hits.at(view, o);
view.router.press(i, at, tool);
}
if let QuickTool::Measure(measure_tool) = tool {
crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units);
}
// Form fields take clicks first with the Select tool (as Acrobat fills fields in
// every viewing mode); then comments; then text selection.
if let QuickTool::Stamp(kind) = tool
&& allowed
&& let Some(p) = ptr.within(xf.rect)
{
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
// The tool's own click: nothing drawn over the page here holds the press.
if resp.clicked() && view.router.owner(i).is_none() {
// Centred on the click, upright as the page is shown.
let (vx, vy) = xf.screen_to_view(p);
let (w, h) = kind.size();
let corners = [(vx as f64 - w / 2.0, vy as f64 - h / 2.0), (vx as f64 + w / 2.0, vy as f64 + h / 2.0)];
let u: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.pages[i].view_to_user(*x as f32, *y as f32)).collect();
let rect = [u[0][0].min(u[1][0]) as f64, u[0][1].min(u[1][1]) as f64, u[0][0].max(u[1][0]) as f64, u[0][1].max(u[1][1]) as f64];
let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.clone());
let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by };
view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
page: i,
style: pdfcraft_engine::Style::default_for(&shape),
shape,
contents: String::new(),
author: author.clone(),
}));
frame.out.stamp_placed = true;
}
}
if let Some(cs) = custom_stamp.as_ref()
&& allowed
&& let Some(p) = ptr.within(xf.rect)
{
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
// The tool's own click: nothing drawn over the page here holds the press.
if resp.clicked() && view.router.owner(i).is_none() {
// Centred on the click at its natural size (the engine sizes it).
let (vx, vy) = xf.screen_to_view(p);
let u = info.pages[i].view_to_user(vx, vy);
let (x, y) = (u[0] as f64, u[1] as f64);
view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp {
page: i,
rect: [x, y, x, y],
name: cs.name.clone(),
file: pdfcraft_engine::MarkFile { name: cs.file.clone(), bytes: cs.data.clone(), page: cs.page },
author: author.clone(),
});
frame.out.stamp_placed = true;
}
}
// Over a mark already placed, the Fill & Sign tool picks it up (comments, below).
if let QuickTool::Fill(ft) = tool
&& allowed
&& !comments::fill_grabs(&pcx, view)
{
match crate::fill_sign::page_input(
cx,
crate::fill_sign::PageInput {
tool: ft,
text_size: fill_text_size,
text_color: fill_text_color,
signature: signature.as_ref(),
initials: initials.as_ref(),
preview: frame.signature_preview,
author,
date_text,
},
view,
) {
Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e),
Some(crate::fill_sign::FillAction::Signature(e)) => {
view.pending_edit = Some(*e);
view.fill_signature_page = Some(i);
}
Some(crate::fill_sign::FillAction::CreateSignature) => frame.out.open_signature = true,
Some(crate::fill_sign::FillAction::CreateInitials) => frame.out.open_initials = true,
Some(crate::fill_sign::FillAction::Refused(why)) => frame.out.refused = Some(why),
None => {}
}
}
if matches!(tool, QuickTool::SignArea { .. }) {
crate::sign_ui::page_input(ui, resp, &xf, ptr, i, info, view);
}
if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) {
crate::zoom_snap::page_input(ui, resp, &xf, ptr, i, view);
}
if tool == QuickTool::EnhanceCamera {
crate::camera_ui::page_input(resp, &xf, ptr, i, view, can_modify);
}
if tool == QuickTool::Crop
&& crate::crop::page_input(ui, crate::crop::CropPageInput { resp, xf: &xf, ptr, page: i, info, view, allowed: can_crop })
{
view.current = i;
frame.out.open_boxes = true;
}
let on_field = if preparing {
let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, view);
frame.out.field_props |= o.properties;
frame.out.field_placed |= o.placed;
frame.out.open_table = frame.out.open_table.or(o.table);
o.consumed || field_tool.is_some()
} else {
tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, view)
};
// Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat).
let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed;
let boxing = area_tool && {
let text = view.page_text(i);
let over_text = |p: Pos2| {
let (vx, vy) = xf.screen_to_view(p);
text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3]))
};
crate::redact_ui::page_input(cx, over_text, view)
};
let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view);
// Add text acts on the page's own paragraphs over them, and on the one its press was on until
// the button is up: a drag that leaves the box still ends, and no new box takes it over.
let over_source = tool == QuickTool::AddText
&& (matches!(view.router.owner(i), Some(crate::page_router::Owner::Paragraph { .. }))
|| pointer.is_some_and(|p| {
!crate::content_ui::hits_added(&xf, info, i, added, p) && crate::edit_text_ui::over_paragraph(&xf, info, i, &lines, p)
}));
let source_blocked = over_source && view.content.hold_blocked();
if over_source && resp.clicked() && !source_blocked {
if let Some(edit) = view.content.finish_draft(added) {
view.pending_edit = Some(edit);
}
view.content.selected = None;
}
let on_content = !on_objects && editing_content && can_modify && {
source_blocked
|| crate::content_ui::page_input(
cx,
added,
tool == QuickTool::AddText && !over_source,
matches!(tool, QuickTool::AddText | QuickTool::EditText),
text_style,
view,
)
|| tool == QuickTool::AddText && !over_source
};
// Added content owns its gesture; its source text must not queue a second edit.
// Edit text & images boxes every paragraph and image whenever the tool is on. Only the
// input waits while selected objects or added content own the pointer, or while page
// input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn
// instead of vanishing until the pointer comes back.
let others_own_pointer = on_objects || on_content;
let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && {
// Editable text takes precedence over background/overlapping artwork. Both
// inventories are painted every frame, even when text owns the pointer.
let text_used = crate::edit_text_ui::page_input(cx, &lines, view, !others_own_pointer);
let image_cx = crate::edit_text_ui::ImageCx {
ui,
resp,
xf: &xf,
ptr,
page: i,
info,
allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used),
};
let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view);
!others_own_pointer && (text_used || image_used)
};
if tool == QuickTool::EditText {
if others_own_pointer {
if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() {
view.image_selection = None;
}
} else {
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);
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))
{
view.links.context = Some((i, l.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
// last claim on a layer stands).
if frame.env.unobstructed
&& let Some(p) = pointer
{
for h in hits.at(view, p) {
view.cursor.claim_with_tip(h.layer, h.icon, h.tip);
}
}
PageInput { consumed, area_tool }
}
/// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged.
fn edit_inventory(
view: &mut DocView,
doc: &pdfcraft_engine::Document,
i: usize,
) -> (Vec<pdfcraft_engine::TextBlock>, Vec<pdfcraft_engine::PageImage>) {
let generation = doc.edit_generation();
let lines = match view.edit_lines.get(&i) {
Some((g, l)) if *g == generation => l.clone(),
_ => {
let l = doc.text_blocks(i);
view.edit_lines.insert(i, (generation, l.clone()));
l
}
};
let images = cached_edit_images(view, doc, i).to_vec();
(lines, images)
}
/// Images parsed once per page and document edit generation, shared by painting and menus.
fn cached_edit_images<'a>(view: &'a mut DocView, doc: &pdfcraft_engine::Document, page: usize) -> &'a [pdfcraft_engine::PageImage] {
let generation = doc.edit_generation();
@@ -1756,472 +1457,6 @@ fn cached_edit_images<'a>(view: &'a mut DocView, doc: &pdfcraft_engine::Document
&entry.1
}
/// What the page modules have under a point on one page, for the page router.
struct PageHits<'a> {
page: usize,
xf: &'a PageXform,
info: &'a DocInfo,
tool: QuickTool,
pcx: &'a comments::PageCx<'a>,
form: &'a [pdfcraft_engine::FormField],
can_fill: bool,
can_modify: bool,
/// Edit a PDF is open: added text and pictures can be picked up.
editing_content: bool,
preparing: bool,
field_tool: Option<crate::prepare::FieldTool>,
/// Prepare may draw and move fields (not while a table is being set up).
prepare_allowed: bool,
added: &'a [pdfcraft_engine::Added],
lines: &'a [pdfcraft_engine::TextBlock],
images: &'a [pdfcraft_engine::PageImage],
/// The pages' visible area (the picture's rotate knob stays inside it).
clip: Rect,
}
impl PageHits<'_> {
/// 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> {
let PageHits {
page,
xf,
info,
tool,
pcx,
form,
can_fill,
can_modify,
editing_content,
preparing,
field_tool,
prepare_allowed,
added,
lines,
images,
clip,
} = *self;
// Crop mode owns every press on its page (`edit_text_ui::crop_hit`).
if tool == QuickTool::EditText
&& can_modify
&& let Some(crop) = crate::edit_text_ui::crop_hit(view, page)
{
return vec![crop];
}
let fields = if preparing {
crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p)
} else if tool == QuickTool::Select {
crate::forms_ui::hit(xf, info, page, form, can_fill, p)
} else {
None
};
// With Add text, only the paragraphs and pictures not under added content.
let editing =
can_modify && (tool == QuickTool::EditText || tool == QuickTool::AddText && !crate::content_ui::hits_added(xf, info, page, added, p));
let (pictures, paragraphs, objects, grips) = if editing {
let open = view.line_editor.as_ref().filter(|e| e.page == page);
let frame = open
.filter(|e| !e.has_changes())
.and_then(|e| lines.get(e.block))
.map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0));
// Add text acts on the page's paragraphs only in their boxes, edges included (it
// finishes the box being typed there, `over_source`): not on pictures, nor beside a
// paragraph, where a click starts a new box.
let edit_text = tool == QuickTool::EditText;
let on_paragraphs = edit_text || crate::edit_text_ui::over_paragraph(xf, info, page, lines, p);
(
edit_text.then(|| crate::edit_text_ui::picture_hit(xf, page, info, images, view, p)).flatten(),
on_paragraphs.then(|| crate::edit_text_ui::hit(xf, info, page, lines, open.map(|e| e.block), p)).flatten(),
edit_text.then(|| crate::object_ui::hit(view, xf, info, page, frame, p)).flatten(),
edit_text.then(|| crate::edit_text_ui::picture_handle_hit(clip, xf, page, info, images, view, p)).flatten(),
)
} else {
(None, None, None, None)
};
// Added text and pictures, over the page's own paragraphs and under the objects selected
// 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()
}
}
/// Text layer: find matches, selection, I-beam and drag-to-select.
fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) {
let PageEnv { info, selects_text, tool, .. } = frame.env;
let PageSlot { i, r, xf } = *slot;
let PageFrame { resp, ptr, pointer, .. } = *frame;
if let Some(text) = view.texts.get(&i).cloned() {
paint_text_marks(ui, view, &text, i, xf, resp.dragged());
if selects_text
&& !input.consumed
&& let Some(p) = pointer.filter(|p| r.contains(*p))
{
text_pointer_input(ui, view, &text, TextPointer { page: i, xf, rect: r, info, tool, resp, ptr: ptr.clone(), pointer: p });
}
}
}
fn paint_text_marks(ui: &egui::Ui, view: &DocView, text: &PageText, page: usize, xf: PageXform, dragging: bool) {
let painter = ui.painter();
let to_screen = |g: [f32; 4]| xf.view_rect(g);
if let Some(find) = &view.find {
for (index, (match_page, range)) in find.matches.iter().enumerate() {
if *match_page != page {
continue;
}
let fill = if find.current == Some(index) {
Color32::from_rgba_unmultiplied(255, 140, 0, 110)
} else {
Color32::from_rgba_unmultiplied(255, 214, 0, 90)
};
for line_rect in text.line_rects(range.clone()) {
painter.rect_filled(to_screen(line_rect).expand(1.0), CornerRadius::same(2), fill);
}
}
}
if view.all_text {
for line_rect in text.line_rects(0..text.glyphs.len()) {
painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70));
}
}
if let Some(selection) = view.selection.filter(|selection| selection.page == page) {
for line_rect in selection.rects(text) {
painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70));
}
if let Span::Column { rect } = selection.span
&& dragging
{
let outline = Stroke::new(1.0, Color32::from_rgb(0x3A, 0x7B, 0xF0));
painter.rect_stroke(to_screen(rect), CornerRadius::ZERO, outline, egui::StrokeKind::Middle);
}
}
}
struct TextPointer<'a> {
page: usize,
xf: PageXform,
rect: Rect,
info: &'a DocInfo,
tool: QuickTool,
resp: &'a egui::Response,
ptr: PagePointer,
pointer: Pos2,
}
struct TextGesture<'a> {
page: usize,
xf: PageXform,
rect: Rect,
tool: QuickTool,
ptr: PagePointer,
resp: &'a egui::Response,
pointer: Pos2,
vx: f32,
vy: f32,
over_text: bool,
over_link: bool,
column: bool,
}
fn text_pointer_input(ui: &mut egui::Ui, view: &mut DocView, text: &PageText, pointer: TextPointer<'_>) {
let TextPointer { page, xf, rect, info, tool, resp, ptr, pointer: screen_pointer } = pointer;
let (vx, vy) = xf.screen_to_view(screen_pointer);
let over_text = text.glyphs.iter().any(|glyph| vx >= glyph.rect[0] && vx <= glyph.rect[2] && vy >= glyph.rect[1] && vy <= glyph.rect[3]);
let over_link = info.links.iter().any(|link| link.page == page && xf.user_rect(info, page, link.rect).contains(screen_pointer));
let column = tool == QuickTool::ColumnSelect || ui.input(|input| input.modifiers.alt);
let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, column };
update_text_cursor(view, &gesture);
update_selection_on_press(ui, view, text, &gesture);
update_dragged_selection(view, text, &gesture);
finish_text_pointer_gesture(ui, view, text, &gesture);
}
fn update_text_cursor(view: &mut DocView, gesture: &TextGesture<'_>) {
// A press on a link selects nothing (it is the link's), so no cursor here says it would.
if gesture.column && !gesture.over_link {
view.cursor.claim(Layer::Text, egui::CursorIcon::Crosshair);
} else if gesture.over_text && !gesture.over_link {
view.cursor.claim(Layer::Text, egui::CursorIcon::Text);
}
}
fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) {
// A press held by what keeps its drag (a link, a comment that moves) selects no text; one on
// text markup lets its drag select the text underneath.
let press_here = gesture.ptr.origin.is_some_and(|origin| gesture.rect.contains(origin));
if gesture.ptr.pressed().is_some() && press_here && !view.router.keeps_drag() {
let now = ui.input(|input| input.time);
let (max_delay, max_dist) = ui.ctx().options(|options| (options.input_options.max_double_click_delay, options.input_options.max_click_dist));
let consecutive = now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(gesture.pointer) <= max_dist);
view.clicks = if consecutive { view.clicks.saturating_add(1) } else { 1 };
view.last_press_time = now;
view.last_press_pos = Some(gesture.pointer);
let clicks = selection_click_count(view.clicks, gesture.tool);
if clicks >= 2
&& gesture.over_text
&& let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor))
{
view.selection = Some(Selection::run(gesture.page, first, last));
}
}
if gesture.resp.drag_started() && press_here && !view.router.keeps_drag() {
view.actions_dismissed = None;
view.selection_drag = true;
let origin = gesture.ptr.origin.unwrap_or(gesture.pointer);
let (ox, oy) = gesture.xf.screen_to_view(origin);
if view.clicks < 2 {
view.selection = if gesture.column {
Some(Selection { page: gesture.page, span: Span::Column { rect: [ox, oy, gesture.vx, gesture.vy] } })
} else {
text.nearest(ox, oy).map(|anchor| Selection::run(gesture.page, anchor, anchor))
};
}
}
}
fn selection_click_count(clicks: u32, tool: QuickTool) -> u32 {
if clicks > 2 && tool != QuickTool::Select { 1 } else { clicks }
}
fn update_dragged_selection(view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) {
let clicks = selection_click_count(view.clicks, gesture.tool);
if gesture.resp.dragged()
&& let Some(selection) = view.selection.as_mut().filter(|selection| selection.page == gesture.page)
{
match &mut selection.span {
Span::Run { anchor, head } if clicks >= 2 => {
if let Some(hit) = text.nearest(gesture.vx, gesture.vy)
&& let Some((first, last)) = click_unit(text, clicks, *anchor)
{
let (to_first, to_last) = click_unit(text, clicks, hit).unwrap_or((hit, hit));
(*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) };
}
}
Span::Run { head, .. } => {
if let Some(hit) = text.nearest(gesture.vx, gesture.vy) {
*head = hit;
}
}
Span::Column { rect } => {
rect[2] = gesture.vx;
rect[3] = gesture.vy;
}
}
}
if gesture.resp.drag_stopped()
&& view.selection.is_some_and(|selection| {
selection.page == gesture.page && matches!(selection.span, Span::Column { .. }) && selection.text(text).is_empty()
})
{
view.selection = None;
}
}
fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) {
let release_clicks = if gesture.resp.triple_clicked() {
view.clicks.max(2).saturating_add(1)
} else if gesture.resp.double_clicked() {
view.clicks.max(2)
} else {
0
};
if release_clicks > 0 {
view.clicks = release_clicks;
}
let clicks = selection_click_count(view.clicks, gesture.tool);
if release_clicks > 0
&& clicks >= 2
&& gesture.over_text
&& view.selection.is_none()
&& let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor))
{
view.selection = Some(Selection::run(gesture.page, first, last));
} else if gesture.resp.clicked() && view.clicks < 2 && !view.router.keeps_drag() {
let shift = ui.input(|input| input.modifiers.shift);
match (
view.selection.as_mut().filter(|selection| shift && selection.page == gesture.page).map(|selection| &mut selection.span),
text.nearest(gesture.vx, gesture.vy),
) {
(Some(Span::Run { head, .. }), Some(hit)) => *head = hit,
_ => view.selection = None,
}
}
}
/// Everything drawn over the page after its text: pictures, comments, content, links, fields, marks
/// and badges.
fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) {
let PageEnv {
added,
allowed,
calc_order,
can_modify,
comments_hidden,
doc,
doc_links,
editing_content,
form,
generation,
info,
prefs,
preparing,
redact_mark,
table_preview,
tool,
unobstructed,
..
} = frame.env;
let PageSlot { i, xf, .. } = *slot;
let PageFrame { resp, ptr, pointer, .. } = *frame;
let painter = ui.painter();
let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info };
let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
if tool == QuickTool::Select && !input.consumed {
crate::image_pick::page_input(resp, &xf, i, info, doc, pointer, view);
}
crate::image_pick::drop_stale(doc, view);
crate::image_pick::paint(painter, &xf, i, info, view);
// A cancelled press ends no selection: no markup or redaction is made from it.
if !view.press_cancelled {
comments::page_after_text(resp, &pcx, view);
if tool == QuickTool::Redact && can_modify {
crate::redact_ui::after_text(resp, i, info, view);
}
}
if input.area_tool {
crate::redact_ui::paint(painter, i, view, pointer);
}
comments::paint_page(ui, painter, &pcx, view);
if editing_content {
crate::content_ui::paint_page(painter, &xf, i, info, added, view, pointer);
}
if tool == QuickTool::EditText {
crate::object_ui::paint(painter, &xf, i, info, &view.objects);
}
if tool == QuickTool::EnhanceCamera {
crate::camera_ui::paint(painter, &xf, i, view);
}
if tool == QuickTool::Link {
crate::link_ui::paint(painter, &xf, i, info, doc_links, view, pointer);
}
if preparing {
crate::prepare::paint_page(tool_cx, form, view);
if let Some(preview) = &table_preview {
crate::field_table_ui::paint_preview(painter, &xf, i, preview);
}
if calc_order {
let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str());
if let Some(field) = crate::calc_order_ui::paint_page(tool_cx, form, &view.calc_order, generation, selected) {
frame.calc_hover = Some(field);
}
}
} else {
crate::forms_ui::paint_page(painter, &xf, i, info, form, view, pointer);
}
// Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a
// red border. Radio buttons and round XFA check boxes are round, and so is theirs (as
// in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a
// radio button's own) is left as it is drawn.
paint_field_highlights(painter, &xf, info, form.as_slice(), i, view.highlight_fields);
// Links: the hand and the link's target, which count only for the tools that follow or
// edit links (`Layer::reached_by`). Tips draw above the palette, so none shows while it is
// open, even beside it. A click follows the link its press was on, released over it: one that
// slips off the link first follows nothing, as a button's does.
if unobstructed
&& 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)
{
frame.out.clicked_link = Some(l.target.clone());
}
// Comments say what they are with any tool, beside its cursor, as Acrobat's do.
comments::describe(&pcx, view);
for (mp, mr, mc) in &view.compare_marks {
if *mp == i {
let sr = xf.user_rect(info, i, *mr).expand(1.5);
painter.rect_filled(sr, CornerRadius::same(2), mc.gamma_multiply(0.28));
}
}
if let Some((fp, fr, t0)) = view.flash
&& fp == i
{
let now = ui.input(|inp| inp.time);
let t0 = if t0 == 0.0 { now } else { t0 };
view.flash = Some((fp, fr, t0));
let age = (now - t0) as f32;
if age < 1.6 {
let a = ((1.6 - age) / 1.6 * 255.0) as u8;
let sr = xf.user_rect(info, i, fr).expand(4.0);
painter.rect_stroke(
sr,
CornerRadius::same(3),
Stroke::new(2.5, Color32::from_rgba_unmultiplied(0x1B, 0x63, 0xE0, a)),
egui::StrokeKind::Outside,
);
ui.ctx().request_repaint();
} else {
view.flash = None;
}
}
// Validation is viewer chrome over the signer's original artwork, which
// may still say "Signature Not Verified". These badges never enter document
// streams, image/text exports, vector printing or Print as Image.
for badge in signature_badges(&doc.signatures, i) {
let [x0, y0, x1, y1] = badge.rect;
let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]);
let (icon, color) = crate::sign_ui::signature_status_style(badge.status);
painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside);
// A widget too small for the mark keeps its outline: the verdict still shows.
if sr.width() < 4.0 || sr.height() < 4.0 {
continue;
}
// Big enough to read at a glance, small enough to leave the signer's own
// text visible around it.
let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0);
let mark = Rect::from_center_size(sr.center(), vec2(side, side));
icons::paint(ui, mark, icon, side * 0.64, color);
// Overlapping widgets paint in order, so the topmost badge's verdict is the tip, with
// any tool; a link's or a comment's tip wins over any badge.
if pointer.is_some_and(|p| mark.expand(2.0).contains(p)) {
view.cursor.describe(Subject::SignatureBadge, badge.summary.to_string());
}
}
}
fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInfo, form: &[pdfcraft_engine::FormField], page: usize, enabled: bool) {
if !enabled {
return;
}
for field in form.iter() {
let required = field.has(pdfcraft_engine::field_flags::REQUIRED);
for widget in field.widgets.iter().filter(|widget| widget.page == Some(page) && !widget.hidden && !widget.read_only) {
let rect = widget.rect;
let screen_rect = xf.user_rect(info, page, [rect[0] as f32, rect[1] as f32, rect[2] as f32, rect[3] as f32]);
let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48);
let (width, color) = if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) };
if widget.round {
let radius = screen_rect.width().min(screen_rect.height()) / 2.0;
painter.circle_filled(screen_rect.center(), radius, tint);
painter.circle_stroke(screen_rect.center(), radius - width / 2.0, Stroke::new(width, color));
} else {
painter.rect_filled(screen_rect, CornerRadius::same(1), tint);
painter.rect_stroke(screen_rect, CornerRadius::same(1), Stroke::new(width, color), egui::StrokeKind::Inside);
}
}
}
}
fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i: usize) {
let PageEnv { allowed, comments_hidden, doc, editing_content, info, ppp, prefs, rects, redact_mark, scale, t, tool, .. } = frame.env;
let PageFrame { ptr, origin, visible, pressed, pointer, .. } = *frame;
@@ -2283,43 +1518,6 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i
page_overlays(ui, view, frame, &slot, &input);
}
/// 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.
fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer) {
// Over once the button is up and its release seen, or when a new press comes (its release and
// the next press can share a frame).
let new_press = matches!(ptr.edges.last(), Some(crate::page_pointer::Edge::Press(_)));
if view.press_cancelled && (new_press || !ptr.any_down && ptr.edges.is_empty()) {
view.press_cancelled = false;
}
if !view.press_cancelled && ptr.origin.is_some() && ptr.primary_down && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape))
{
// A text selection being dragged goes with its drag: the markup, redaction or Replace
// Text that its end would make is not made.
if view.selection_drag {
view.clear_selection();
}
view.cancel_pointer_gestures();
view.press_cancelled = true;
}
}
#[allow(clippy::cognitive_complexity)]
fn draw_pages(
ui: &mut egui::Ui,
+815
View File
@@ -0,0 +1,815 @@
//! Page input: what the tools do with the pointer on one page, what lies under it for the page
//! 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 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::{QuickTool, comments, icons};
/// What the page's tools did with the pointer this frame.
pub(super) struct PageInput {
/// A tool, a field or a comment took the pointer: text selection must leave it alone.
pub(super) consumed: bool,
/// Redact or Highlight draws boxes off text.
pub(super) area_tool: bool,
}
/// Common geometry and pointer state for a tool acting on one page.
#[derive(Clone, Copy)]
pub(crate) struct PageToolContext<'a> {
pub ui: &'a egui::Ui,
pub resp: &'a egui::Response,
pub xf: &'a PageXform,
pub ptr: &'a PagePointer,
pub page: usize,
pub doc: &'a pdfcraft_engine::Document,
pub info: &'a DocInfo,
}
/// Comments: tools, selection, moving and resizing come before text selection. Form fields take
/// clicks first with the Select tool (as Acrobat fills fields in every viewing mode); then
/// comments; then text selection.
pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput {
let PageEnv {
added,
allowed,
author,
by_line,
can_crop,
can_fill,
can_modify,
comments_hidden,
custom_stamp,
date_text,
doc,
doc_links,
editing_content,
form,
info,
initials,
prefs,
preparing,
redact_mark,
signature,
table_preview,
text_style,
tool,
units,
fill_text_size,
fill_text_color,
..
} = frame.env;
let PageSlot { i, xf, .. } = *slot;
let PageFrame { resp, ptr, pointer, .. } = *frame;
let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info };
let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
let (lines, images) =
if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() };
let field_tool = match tool {
QuickTool::Field(f) => Some(f),
_ => None,
};
// While a table is being set up, nothing else is drawn or moved.
let prepare_allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none();
let hits = PageHits {
page: i,
xf: &xf,
info,
tool,
pcx: &pcx,
form,
can_fill,
can_modify,
editing_content,
preparing,
field_tool,
prepare_allowed,
added,
lines: &lines,
images: &images,
clip: ui.clip_rect(),
};
if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) {
let at = hits.at(view, o);
view.router.press(i, at, tool);
}
if let QuickTool::Measure(measure_tool) = tool {
crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units);
}
// Form fields take clicks first with the Select tool (as Acrobat fills fields in
// every viewing mode); then comments; then text selection.
if let QuickTool::Stamp(kind) = tool
&& allowed
&& let Some(p) = ptr.within(xf.rect)
{
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
// The tool's own click: nothing drawn over the page here holds the press.
if resp.clicked() && view.router.owner(i).is_none() {
// Centred on the click, upright as the page is shown.
let (vx, vy) = xf.screen_to_view(p);
let (w, h) = kind.size();
let corners = [(vx as f64 - w / 2.0, vy as f64 - h / 2.0), (vx as f64 + w / 2.0, vy as f64 + h / 2.0)];
let u: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.pages[i].view_to_user(*x as f32, *y as f32)).collect();
let rect = [u[0][0].min(u[1][0]) as f64, u[0][1].min(u[1][1]) as f64, u[0][0].max(u[1][0]) as f64, u[0][1].max(u[1][1]) as f64];
let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.clone());
let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by };
view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
page: i,
style: pdfcraft_engine::Style::default_for(&shape),
shape,
contents: String::new(),
author: author.clone(),
}));
frame.out.stamp_placed = true;
}
}
if let Some(cs) = custom_stamp.as_ref()
&& allowed
&& let Some(p) = ptr.within(xf.rect)
{
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
// The tool's own click: nothing drawn over the page here holds the press.
if resp.clicked() && view.router.owner(i).is_none() {
// Centred on the click at its natural size (the engine sizes it).
let (vx, vy) = xf.screen_to_view(p);
let u = info.pages[i].view_to_user(vx, vy);
let (x, y) = (u[0] as f64, u[1] as f64);
view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp {
page: i,
rect: [x, y, x, y],
name: cs.name.clone(),
file: pdfcraft_engine::MarkFile { name: cs.file.clone(), bytes: cs.data.clone(), page: cs.page },
author: author.clone(),
});
frame.out.stamp_placed = true;
}
}
// Over a mark already placed, the Fill & Sign tool picks it up (comments, below).
if let QuickTool::Fill(ft) = tool
&& allowed
&& !comments::fill_grabs(&pcx, view)
{
match crate::fill_sign::page_input(
cx,
crate::fill_sign::PageInput {
tool: ft,
text_size: fill_text_size,
text_color: fill_text_color,
signature: signature.as_ref(),
initials: initials.as_ref(),
preview: frame.signature_preview,
author,
date_text,
},
view,
) {
Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e),
Some(crate::fill_sign::FillAction::Signature(e)) => {
view.pending_edit = Some(*e);
view.fill_signature_page = Some(i);
}
Some(crate::fill_sign::FillAction::CreateSignature) => frame.out.open_signature = true,
Some(crate::fill_sign::FillAction::CreateInitials) => frame.out.open_initials = true,
Some(crate::fill_sign::FillAction::Refused(why)) => frame.out.refused = Some(why),
None => {}
}
}
if matches!(tool, QuickTool::SignArea { .. }) {
crate::sign_ui::page_input(ui, resp, &xf, ptr, i, info, view);
}
if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) {
crate::zoom_snap::page_input(ui, resp, &xf, ptr, i, view);
}
if tool == QuickTool::EnhanceCamera {
crate::camera_ui::page_input(resp, &xf, ptr, i, view, can_modify);
}
if tool == QuickTool::Crop
&& crate::crop::page_input(ui, crate::crop::CropPageInput { resp, xf: &xf, ptr, page: i, info, view, allowed: can_crop })
{
view.current = i;
frame.out.open_boxes = true;
}
let on_field = if preparing {
let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, view);
frame.out.field_props |= o.properties;
frame.out.field_placed |= o.placed;
frame.out.open_table = frame.out.open_table.or(o.table);
o.consumed || field_tool.is_some()
} else {
tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, view)
};
// Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat).
let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed;
let boxing = area_tool && {
let text = view.page_text(i);
let over_text = |p: Pos2| {
let (vx, vy) = xf.screen_to_view(p);
text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3]))
};
crate::redact_ui::page_input(cx, over_text, view)
};
let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view);
// Add text acts on the page's own paragraphs over them, and on the one its press was on until
// the button is up: a drag that leaves the box still ends, and no new box takes it over.
let over_source = tool == QuickTool::AddText
&& (matches!(view.router.owner(i), Some(crate::page_router::Owner::Paragraph { .. }))
|| pointer.is_some_and(|p| {
!crate::content_ui::hits_added(&xf, info, i, added, p) && crate::edit_text_ui::over_paragraph(&xf, info, i, &lines, p)
}));
let source_blocked = over_source && view.content.hold_blocked();
if over_source && resp.clicked() && !source_blocked {
if let Some(edit) = view.content.finish_draft(added) {
view.pending_edit = Some(edit);
}
view.content.selected = None;
}
let on_content = !on_objects && editing_content && can_modify && {
source_blocked
|| crate::content_ui::page_input(
cx,
added,
tool == QuickTool::AddText && !over_source,
matches!(tool, QuickTool::AddText | QuickTool::EditText),
text_style,
view,
)
|| tool == QuickTool::AddText && !over_source
};
// Added content owns its gesture; its source text must not queue a second edit.
// Edit text & images boxes every paragraph and image whenever the tool is on. Only the
// input waits while selected objects or added content own the pointer, or while page
// input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn
// instead of vanishing until the pointer comes back.
let others_own_pointer = on_objects || on_content;
let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && {
// Editable text takes precedence over background/overlapping artwork. Both
// inventories are painted every frame, even when text owns the pointer.
let text_used = crate::edit_text_ui::page_input(cx, &lines, view, !others_own_pointer);
let image_cx = crate::edit_text_ui::ImageCx {
ui,
resp,
xf: &xf,
ptr,
page: i,
info,
allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used),
};
let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view);
!others_own_pointer && (text_used || image_used)
};
if tool == QuickTool::EditText {
if others_own_pointer {
if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() {
view.image_selection = None;
}
} else {
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);
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))
{
view.links.context = Some((i, l.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
// last claim on a layer stands).
if frame.env.unobstructed
&& let Some(p) = pointer
{
for h in hits.at(view, p) {
view.cursor.claim_with_tip(h.layer, h.icon, h.tip);
}
}
PageInput { consumed, area_tool }
}
/// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged.
fn edit_inventory(
view: &mut DocView,
doc: &pdfcraft_engine::Document,
i: usize,
) -> (Vec<pdfcraft_engine::TextBlock>, Vec<pdfcraft_engine::PageImage>) {
let generation = doc.edit_generation();
let lines = match view.edit_lines.get(&i) {
Some((g, l)) if *g == generation => l.clone(),
_ => {
let l = doc.text_blocks(i);
view.edit_lines.insert(i, (generation, l.clone()));
l
}
};
let images = cached_edit_images(view, doc, i).to_vec();
(lines, images)
}
/// What the page modules have under a point on one page, for the page router.
struct PageHits<'a> {
page: usize,
xf: &'a PageXform,
info: &'a DocInfo,
tool: QuickTool,
pcx: &'a comments::PageCx<'a>,
form: &'a [pdfcraft_engine::FormField],
can_fill: bool,
can_modify: bool,
/// Edit a PDF is open: added text and pictures can be picked up.
editing_content: bool,
preparing: bool,
field_tool: Option<crate::prepare::FieldTool>,
/// Prepare may draw and move fields (not while a table is being set up).
prepare_allowed: bool,
added: &'a [pdfcraft_engine::Added],
lines: &'a [pdfcraft_engine::TextBlock],
images: &'a [pdfcraft_engine::PageImage],
/// The pages' visible area (the picture's rotate knob stays inside it).
clip: Rect,
}
impl PageHits<'_> {
/// 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> {
let PageHits {
page,
xf,
info,
tool,
pcx,
form,
can_fill,
can_modify,
editing_content,
preparing,
field_tool,
prepare_allowed,
added,
lines,
images,
clip,
} = *self;
// Crop mode owns every press on its page (`edit_text_ui::crop_hit`).
if tool == QuickTool::EditText
&& can_modify
&& let Some(crop) = crate::edit_text_ui::crop_hit(view, page)
{
return vec![crop];
}
let fields = if preparing {
crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p)
} else if tool == QuickTool::Select {
crate::forms_ui::hit(xf, info, page, form, can_fill, p)
} else {
None
};
// With Add text, only the paragraphs and pictures not under added content.
let editing =
can_modify && (tool == QuickTool::EditText || tool == QuickTool::AddText && !crate::content_ui::hits_added(xf, info, page, added, p));
let (pictures, paragraphs, objects, grips) = if editing {
let open = view.line_editor.as_ref().filter(|e| e.page == page);
let frame = open
.filter(|e| !e.has_changes())
.and_then(|e| lines.get(e.block))
.map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0));
// Add text acts on the page's paragraphs only in their boxes, edges included (it
// finishes the box being typed there, `over_source`): not on pictures, nor beside a
// paragraph, where a click starts a new box.
let edit_text = tool == QuickTool::EditText;
let on_paragraphs = edit_text || crate::edit_text_ui::over_paragraph(xf, info, page, lines, p);
(
edit_text.then(|| crate::edit_text_ui::picture_hit(xf, page, info, images, view, p)).flatten(),
on_paragraphs.then(|| crate::edit_text_ui::hit(xf, info, page, lines, open.map(|e| e.block), p)).flatten(),
edit_text.then(|| crate::object_ui::hit(view, xf, info, page, frame, p)).flatten(),
edit_text.then(|| crate::edit_text_ui::picture_handle_hit(clip, xf, page, info, images, view, p)).flatten(),
)
} else {
(None, None, None, None)
};
// Added text and pictures, over the page's own paragraphs and under the objects selected
// 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()
}
}
/// Text layer: find matches, selection, I-beam and drag-to-select.
pub(super) fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) {
let PageEnv { info, selects_text, tool, .. } = frame.env;
let PageSlot { i, r, xf } = *slot;
let PageFrame { resp, ptr, pointer, .. } = *frame;
if let Some(text) = view.texts.get(&i).cloned() {
paint_text_marks(ui, view, &text, i, xf, resp.dragged());
if selects_text
&& !input.consumed
&& let Some(p) = pointer.filter(|p| r.contains(*p))
{
text_pointer_input(ui, view, &text, TextPointer { page: i, xf, rect: r, info, tool, resp, ptr: ptr.clone(), pointer: p });
}
}
}
fn paint_text_marks(ui: &egui::Ui, view: &DocView, text: &PageText, page: usize, xf: PageXform, dragging: bool) {
let painter = ui.painter();
let to_screen = |g: [f32; 4]| xf.view_rect(g);
if let Some(find) = &view.find {
for (index, (match_page, range)) in find.matches.iter().enumerate() {
if *match_page != page {
continue;
}
let fill = if find.current == Some(index) {
Color32::from_rgba_unmultiplied(255, 140, 0, 110)
} else {
Color32::from_rgba_unmultiplied(255, 214, 0, 90)
};
for line_rect in text.line_rects(range.clone()) {
painter.rect_filled(to_screen(line_rect).expand(1.0), CornerRadius::same(2), fill);
}
}
}
if view.all_text {
for line_rect in text.line_rects(0..text.glyphs.len()) {
painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70));
}
}
if let Some(selection) = view.selection.filter(|selection| selection.page == page) {
for line_rect in selection.rects(text) {
painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70));
}
if let Span::Column { rect } = selection.span
&& dragging
{
let outline = Stroke::new(1.0, Color32::from_rgb(0x3A, 0x7B, 0xF0));
painter.rect_stroke(to_screen(rect), CornerRadius::ZERO, outline, egui::StrokeKind::Middle);
}
}
}
struct TextPointer<'a> {
page: usize,
xf: PageXform,
rect: Rect,
info: &'a DocInfo,
tool: QuickTool,
resp: &'a egui::Response,
ptr: PagePointer,
pointer: Pos2,
}
struct TextGesture<'a> {
page: usize,
xf: PageXform,
rect: Rect,
tool: QuickTool,
ptr: PagePointer,
resp: &'a egui::Response,
pointer: Pos2,
vx: f32,
vy: f32,
over_text: bool,
over_link: bool,
column: bool,
}
fn text_pointer_input(ui: &mut egui::Ui, view: &mut DocView, text: &PageText, pointer: TextPointer<'_>) {
let TextPointer { page, xf, rect, info, tool, resp, ptr, pointer: screen_pointer } = pointer;
let (vx, vy) = xf.screen_to_view(screen_pointer);
let over_text = text.glyphs.iter().any(|glyph| vx >= glyph.rect[0] && vx <= glyph.rect[2] && vy >= glyph.rect[1] && vy <= glyph.rect[3]);
let over_link = info.links.iter().any(|link| link.page == page && xf.user_rect(info, page, link.rect).contains(screen_pointer));
let column = tool == QuickTool::ColumnSelect || ui.input(|input| input.modifiers.alt);
let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, column };
update_text_cursor(view, &gesture);
update_selection_on_press(ui, view, text, &gesture);
update_dragged_selection(view, text, &gesture);
finish_text_pointer_gesture(ui, view, text, &gesture);
}
fn update_text_cursor(view: &mut DocView, gesture: &TextGesture<'_>) {
// A press on a link selects nothing (it is the link's), so no cursor here says it would.
if gesture.column && !gesture.over_link {
view.cursor.claim(Layer::Text, egui::CursorIcon::Crosshair);
} else if gesture.over_text && !gesture.over_link {
view.cursor.claim(Layer::Text, egui::CursorIcon::Text);
}
}
fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) {
// A press held by what keeps its drag (a link, a comment that moves) selects no text; one on
// text markup lets its drag select the text underneath.
let press_here = gesture.ptr.origin.is_some_and(|origin| gesture.rect.contains(origin));
if gesture.ptr.pressed().is_some() && press_here && !view.router.keeps_drag() {
let now = ui.input(|input| input.time);
let (max_delay, max_dist) = ui.ctx().options(|options| (options.input_options.max_double_click_delay, options.input_options.max_click_dist));
let consecutive = now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(gesture.pointer) <= max_dist);
view.clicks = if consecutive { view.clicks.saturating_add(1) } else { 1 };
view.last_press_time = now;
view.last_press_pos = Some(gesture.pointer);
let clicks = selection_click_count(view.clicks, gesture.tool);
if clicks >= 2
&& gesture.over_text
&& let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor))
{
view.selection = Some(Selection::run(gesture.page, first, last));
}
}
if gesture.resp.drag_started() && press_here && !view.router.keeps_drag() {
view.actions_dismissed = None;
view.selection_drag = true;
let origin = gesture.ptr.origin.unwrap_or(gesture.pointer);
let (ox, oy) = gesture.xf.screen_to_view(origin);
if view.clicks < 2 {
view.selection = if gesture.column {
Some(Selection { page: gesture.page, span: Span::Column { rect: [ox, oy, gesture.vx, gesture.vy] } })
} else {
text.nearest(ox, oy).map(|anchor| Selection::run(gesture.page, anchor, anchor))
};
}
}
}
fn selection_click_count(clicks: u32, tool: QuickTool) -> u32 {
if clicks > 2 && tool != QuickTool::Select { 1 } else { clicks }
}
fn update_dragged_selection(view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) {
let clicks = selection_click_count(view.clicks, gesture.tool);
if gesture.resp.dragged()
&& let Some(selection) = view.selection.as_mut().filter(|selection| selection.page == gesture.page)
{
match &mut selection.span {
Span::Run { anchor, head } if clicks >= 2 => {
if let Some(hit) = text.nearest(gesture.vx, gesture.vy)
&& let Some((first, last)) = click_unit(text, clicks, *anchor)
{
let (to_first, to_last) = click_unit(text, clicks, hit).unwrap_or((hit, hit));
(*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) };
}
}
Span::Run { head, .. } => {
if let Some(hit) = text.nearest(gesture.vx, gesture.vy) {
*head = hit;
}
}
Span::Column { rect } => {
rect[2] = gesture.vx;
rect[3] = gesture.vy;
}
}
}
if gesture.resp.drag_stopped()
&& view.selection.is_some_and(|selection| {
selection.page == gesture.page && matches!(selection.span, Span::Column { .. }) && selection.text(text).is_empty()
})
{
view.selection = None;
}
}
fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) {
let release_clicks = if gesture.resp.triple_clicked() {
view.clicks.max(2).saturating_add(1)
} else if gesture.resp.double_clicked() {
view.clicks.max(2)
} else {
0
};
if release_clicks > 0 {
view.clicks = release_clicks;
}
let clicks = selection_click_count(view.clicks, gesture.tool);
if release_clicks > 0
&& clicks >= 2
&& gesture.over_text
&& view.selection.is_none()
&& let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor))
{
view.selection = Some(Selection::run(gesture.page, first, last));
} else if gesture.resp.clicked() && view.clicks < 2 && !view.router.keeps_drag() {
let shift = ui.input(|input| input.modifiers.shift);
match (
view.selection.as_mut().filter(|selection| shift && selection.page == gesture.page).map(|selection| &mut selection.span),
text.nearest(gesture.vx, gesture.vy),
) {
(Some(Span::Run { head, .. }), Some(hit)) => *head = hit,
_ => view.selection = None,
}
}
}
/// Everything drawn over the page after its text: pictures, comments, content, links, fields, marks
/// and badges.
pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) {
let PageEnv {
added,
allowed,
calc_order,
can_modify,
comments_hidden,
doc,
doc_links,
editing_content,
form,
generation,
info,
prefs,
preparing,
redact_mark,
table_preview,
tool,
unobstructed,
..
} = frame.env;
let PageSlot { i, xf, .. } = *slot;
let PageFrame { resp, ptr, pointer, .. } = *frame;
let painter = ui.painter();
let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info };
let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
if tool == QuickTool::Select && !input.consumed {
crate::image_pick::page_input(resp, &xf, i, info, doc, pointer, view);
}
crate::image_pick::drop_stale(doc, view);
crate::image_pick::paint(painter, &xf, i, info, view);
// A cancelled press ends no selection: no markup or redaction is made from it.
if !view.press_cancelled {
comments::page_after_text(resp, &pcx, view);
if tool == QuickTool::Redact && can_modify {
crate::redact_ui::after_text(resp, i, info, view);
}
}
if input.area_tool {
crate::redact_ui::paint(painter, i, view, pointer);
}
comments::paint_page(ui, painter, &pcx, view);
if editing_content {
crate::content_ui::paint_page(painter, &xf, i, info, added, view, pointer);
}
if tool == QuickTool::EditText {
crate::object_ui::paint(painter, &xf, i, info, &view.objects);
}
if tool == QuickTool::EnhanceCamera {
crate::camera_ui::paint(painter, &xf, i, view);
}
if tool == QuickTool::Link {
crate::link_ui::paint(painter, &xf, i, info, doc_links, view, pointer);
}
if preparing {
crate::prepare::paint_page(tool_cx, form, view);
if let Some(preview) = &table_preview {
crate::field_table_ui::paint_preview(painter, &xf, i, preview);
}
if calc_order {
let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str());
if let Some(field) = crate::calc_order_ui::paint_page(tool_cx, form, &view.calc_order, generation, selected) {
frame.calc_hover = Some(field);
}
}
} else {
crate::forms_ui::paint_page(painter, &xf, i, info, form, view, pointer);
}
// Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a
// red border. Radio buttons and round XFA check boxes are round, and so is theirs (as
// in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a
// radio button's own) is left as it is drawn.
paint_field_highlights(painter, &xf, info, form.as_slice(), i, view.highlight_fields);
// Links: the hand and the link's target, which count only for the tools that follow or
// edit links (`Layer::reached_by`). Tips draw above the palette, so none shows while it is
// open, even beside it. A click follows the link its press was on, released over it: one that
// slips off the link first follows nothing, as a button's does.
if unobstructed
&& 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)
{
frame.out.clicked_link = Some(l.target.clone());
}
// Comments say what they are with any tool, beside its cursor, as Acrobat's do.
comments::describe(&pcx, view);
for (mp, mr, mc) in &view.compare_marks {
if *mp == i {
let sr = xf.user_rect(info, i, *mr).expand(1.5);
painter.rect_filled(sr, CornerRadius::same(2), mc.gamma_multiply(0.28));
}
}
if let Some((fp, fr, t0)) = view.flash
&& fp == i
{
let now = ui.input(|inp| inp.time);
let t0 = if t0 == 0.0 { now } else { t0 };
view.flash = Some((fp, fr, t0));
let age = (now - t0) as f32;
if age < 1.6 {
let a = ((1.6 - age) / 1.6 * 255.0) as u8;
let sr = xf.user_rect(info, i, fr).expand(4.0);
painter.rect_stroke(
sr,
CornerRadius::same(3),
Stroke::new(2.5, Color32::from_rgba_unmultiplied(0x1B, 0x63, 0xE0, a)),
egui::StrokeKind::Outside,
);
ui.ctx().request_repaint();
} else {
view.flash = None;
}
}
// Validation is viewer chrome over the signer's original artwork, which
// may still say "Signature Not Verified". These badges never enter document
// streams, image/text exports, vector printing or Print as Image.
for badge in signature_badges(&doc.signatures, i) {
let [x0, y0, x1, y1] = badge.rect;
let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]);
let (icon, color) = crate::sign_ui::signature_status_style(badge.status);
painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside);
// A widget too small for the mark keeps its outline: the verdict still shows.
if sr.width() < 4.0 || sr.height() < 4.0 {
continue;
}
// Big enough to read at a glance, small enough to leave the signer's own
// text visible around it.
let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0);
let mark = Rect::from_center_size(sr.center(), vec2(side, side));
icons::paint(ui, mark, icon, side * 0.64, color);
// Overlapping widgets paint in order, so the topmost badge's verdict is the tip, with
// any tool; a link's or a comment's tip wins over any badge.
if pointer.is_some_and(|p| mark.expand(2.0).contains(p)) {
view.cursor.describe(Subject::SignatureBadge, badge.summary.to_string());
}
}
}
fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInfo, form: &[pdfcraft_engine::FormField], page: usize, enabled: bool) {
if !enabled {
return;
}
for field in form.iter() {
let required = field.has(pdfcraft_engine::field_flags::REQUIRED);
for widget in field.widgets.iter().filter(|widget| widget.page == Some(page) && !widget.hidden && !widget.read_only) {
let rect = widget.rect;
let screen_rect = xf.user_rect(info, page, [rect[0] as f32, rect[1] as f32, rect[2] as f32, rect[3] as f32]);
let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48);
let (width, color) = if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) };
if widget.round {
let radius = screen_rect.width().min(screen_rect.height()) / 2.0;
painter.circle_filled(screen_rect.center(), radius, tint);
painter.circle_stroke(screen_rect.center(), radius - width / 2.0, Stroke::new(width, color));
} else {
painter.rect_filled(screen_rect, CornerRadius::same(1), tint);
painter.rect_stroke(screen_rect, CornerRadius::same(1), Stroke::new(width, color), egui::StrokeKind::Inside);
}
}
}
}
/// 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.
pub(super) fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer) {
// Over once the button is up and its release seen, or when a new press comes (its release and
// the next press can share a frame).
let new_press = matches!(ptr.edges.last(), Some(crate::page_pointer::Edge::Press(_)));
if view.press_cancelled && (new_press || !ptr.any_down && ptr.edges.is_empty()) {
view.press_cancelled = false;
}
if !view.press_cancelled && ptr.origin.is_some() && ptr.primary_down && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape))
{
// A text selection being dragged goes with its drag: the markup, redaction or Replace
// Text that its end would make is not made.
if view.selection_drag {
view.clear_selection();
}
view.cancel_pointer_gestures();
view.press_cancelled = true;
}
}
@@ -150,8 +150,9 @@ fn edges_are_primary_finite_in_order_and_capped() {
}
/// The page modules: the per-page chain in `canvas::document_area` and what it calls.
const PAGE_MODULES: [&str; 35] = [
const PAGE_MODULES: [&str; 36] = [
"canvas.rs",
"canvas/page_input.rs",
"canvas/area.rs",
"canvas/bars.rs",
"canvas/cache.rs",
@@ -387,7 +388,7 @@ const ROUTER_WRITES: [&str; 3] = ["router.press(", "router.settle(", "router.can
/// The functions that decide, keep or drop the press the pages hold: (file, function, why).
const ROUTER_WRITERS: [(&str, &str, &str); 3] = [
("canvas.rs", "page_tool_input", "the hits at the press point decide its owner, once per press"),
("canvas/page_input.rs", "page_tool_input", "the hits at the press point decide its owner, once per press"),
("canvas.rs", "draw_pages", "the press is over once the button is up and its release seen, and a new one drops the last owner"),
("canvas.rs", "cancel_pointer_gestures", "every pointer gesture's cancel: Escape, a dialog, automation's cancel"),
];
@@ -416,8 +417,8 @@ 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.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.rs", "page_overlays", "a link's click counts only released over the link its press was on"),
("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", "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"),
];
@@ -426,7 +427,8 @@ const HIT_TESTERS: [(&str, &str, &str); 4] = [
const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"];
/// The one function that ignores a press until the button is up: (file, function, why).
const PRESS_IGNORERS: [(&str, &str, &str); 1] = [("canvas.rs", "cancel_on_escape", "Escape abandons the gesture the press drives, whichever tool's")];
const PRESS_IGNORERS: [(&str, &str, &str); 1] =
[("canvas/page_input.rs", "cancel_on_escape", "Escape abandons the gesture the press drives, whichever tool's")];
/// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who
/// may).