Merge remote-tracking branch 'upstream/main' into pr-fix/print-area

# Conflicts:
#	crates/ui-egui/src/canvas/zoom_snap.rs
This commit is contained in:
Osvaldo Duran Melendez committed 2026-11-04 06:10:54 -05:00
commit a9a4e0e732
29 files changed
+2931 -1146

No files matched your search

+1 -1
View File
@@ -23,7 +23,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
- **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone.
- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it.
- **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it.
- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own (`view.router.owner(page)`), never by testing the press point again or by asking another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_modules_take_presses_from_the_router_without_repeating_hit_tests` guards the centralized hit calls and captured-owner handlers; runtime routing tests cover gesture behavior.
- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the press is its own or, for the active tool, nobody's: the page view tells it which (`PagePress`: its own, nobody's, or another page's), and it never tests the press point again or asks another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it.
- **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it.
- **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code:
- No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why.
+57 -819
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;
@@ -62,6 +63,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;
@@ -887,6 +890,9 @@ impl DocView {
self.sign.drag = None;
self.redact_drag = None;
self.crop_drag = None;
if let Some(q) = &mut self.camera {
q.grab = None;
}
self.block_drag = None;
self.org_drag = None;
self.panel_drag = None;
@@ -1235,6 +1241,19 @@ struct PageFrame<'a> {
current_height: f32,
pressed_page: Option<usize>,
calc_hover: Option<crate::calc_order_ui::Focus>,
/// A page took this frame's right-click.
right_clicked: bool,
}
impl PageFrame<'_> {
/// Where this frame's right-click is, on the page at `rect`, for the first page it is on: pages
/// that touch (the gaps hidden) can share a row of pixels, and a right-click there is one
/// page's, as a press is.
fn right_click_on(&mut self, rect: Rect) -> Option<Pos2> {
let p = self.pointer.filter(|p| !self.right_clicked && self.resp.secondary_clicked() && rect.contains(*p))?;
self.right_clicked = true;
Some(p)
}
}
/// Visible heights (points) closer than this are equal: one page size can meet the viewport a
@@ -1478,308 +1497,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();
@@ -1790,478 +1507,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 presses on its page, including the controls beside its
// frame. Underlying paragraphs and the empty-page marquee must not take
// a crop button's press before the image module receives it.
if tool == QuickTool::EditText
&& can_modify
&& let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_some())
{
return vec![crate::page_router::Hit::new(
crate::page_router::Owner::Picture { index: selection.index, part: crate::page_router::PicturePart::Body },
crate::page_cursor::Layer::Handle,
egui::CursorIcon::Default,
)];
}
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;
@@ -2300,10 +1545,18 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i
}
let slot = PageSlot { i, r, xf };
// A cancelled press takes nothing until the button is up.
let input = if view.press_cancelled { PageInput { consumed: true, area_tool: false } } else { page_tool_input(ui, view, frame, &slot) };
let painter = ui.painter();
let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
// A cancelled press takes nothing until the button is up, though the page's menu still opens
// where a right-click is.
let input = if view.press_cancelled {
if let Some(p) = frame.right_click_on(xf.rect) {
comments::anchor(&pcx, p, view);
}
PageInput { consumed: true, area_tool: false, hover: None, released_over: false }
} else {
page_tool_input(ui, view, frame, &slot)
};
let painter = ui.painter();
let preview_target = (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten();
view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale);
view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref());
@@ -2323,43 +1576,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,
@@ -2445,9 +1661,9 @@ fn draw_pages(
cancel_on_escape(ui.ctx(), view, &ptr);
view.router.settle(ptr.any_down, !ptr.edges.is_empty());
// A new press is no longer the last one's, wherever it lands (the page it is on gives it
// its owner; in the gap between pages it has none).
// its owner; in the gap between pages it is no page's).
if ptr.pressed().is_some() {
view.router.cancel();
view.router.begin();
// The page widget is not focusable. Explicitly return keyboard ownership from Pages
// when the user presses the document, without stealing focus through a modal.
if let Some(id) = view.pages_focus_id {
@@ -2462,6 +1678,11 @@ fn draw_pages(
if resp.secondary_clicked() {
// Set again below when the right-click lands on a link.
view.links.context = None;
// Set again below by the page right-clicked: a right-click off the pages (between them,
// beside one) anchors no page's menu, which then acts on the current page. It deselects the
// comment too (below), as one on a page's empty part does. A selected Prepare field keeps
// its menu, off the pages as on them.
view.comments.context_at = None;
let mut over_image = false;
if tool == QuickTool::EditText {
let pointer_pos = ptr.pos;
@@ -2526,13 +1747,28 @@ fn draw_pages(
current_height: f32::INFINITY,
pressed_page: None,
calc_hover: None,
right_clicked: false,
};
for &i in visible_pages {
draw_page(ui, view, &mut frame, i);
}
let PageFrame {
wanted, visible_now, mut current, best_overlap, current_overlap, current_height, pressed_page, calc_hover, out: page_out, ..
wanted,
visible_now,
mut current,
best_overlap,
current_overlap,
current_height,
pressed_page,
calc_hover,
out: page_out,
right_clicked,
..
} = frame;
// A right-click no page took deselects the comment, so the page's menu opens, not the comment's.
if resp.secondary_clicked() && !right_clicked {
view.comments.selected = None;
}
let PageOut {
mut canvas_action,
clicked_link,
@@ -2701,7 +1937,9 @@ fn draw_pages(
}));
if is_image_menu && let Some(sel) = view.image_selection.clone() {
let images = cached_edit_images(view, doc, sel.page).to_vec();
let at = view.comments.context_at.map(|(_, at)| at);
// Paste lands where the menu opened only on the picture's page: where pages touch, the
// first page takes the right-click, and its point means nothing on the second.
let at = view.comments.context_at.filter(|(page, _)| *page == sel.page).map(|(_, at)| at);
let rotation = (view.rotation % 360 + info.pages.get(sel.page).map_or(0, |p| p.rotation % 360)) % 360;
crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action, at, rotation);
return;
+5 -3
View File
@@ -27,10 +27,12 @@ pub(crate) struct CropPageInput<'a> {
pub(crate) info: &'a DocInfo,
pub(crate) view: &'a mut DocView,
pub(crate) allowed: bool,
/// Nobody holds the press, and it began on this page: the tool's.
pub(crate) free: bool,
}
pub(crate) fn page_input(ui: &egui::Ui, input: CropPageInput<'_>) -> bool {
let CropPageInput { resp, xf, ptr, page, info, view, allowed } = input;
let CropPageInput { resp, xf, ptr, page, info, view, allowed, free } = input;
let (pointer, origin) = (ptr.pos, ptr.origin);
if pointer.is_some_and(|p| xf.rect.contains(p)) {
view.cursor.claim(Layer::ToolArea, if allowed { egui::CursorIcon::Crosshair } else { egui::CursorIcon::NotAllowed });
@@ -38,9 +40,9 @@ pub(crate) fn page_input(ui: &egui::Ui, input: CropPageInput<'_>) -> bool {
if !allowed {
return false;
}
// The tool's own press, of the primary button: nothing drawn over the page here holds it.
// The tool's own press, of the primary button: one nobody holds.
if resp.drag_started_by(egui::PointerButton::Primary)
&& view.router.owner(page).is_none()
&& free
&& let Some(o) = origin.filter(|o| xf.rect.contains(*o))
{
view.crop_drag = Some((page, o));
+900
View File
@@ -0,0 +1,900 @@
//! 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, 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, PagePress, top};
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,
/// What a press under the pointer would take (the top hit there), for hover painting.
pub(super) hover: Option<crate::page_router::Hit>,
/// The press's click came up over what the press began on: a link's or a field's click counts
/// only then, as a button's does.
pub(super) released_over: 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,
}
/// The tools' input on one page. The hits at the press point decide who holds the press, and the
/// modules are told what the press is to this page (`PagePress`): their own, nobody's (the active
/// tool's: `tool_input`, and the tools still called here), or another page's. The Edit text group
/// still asks the router, until it is told too. Text selection runs after, on what nobody took.
pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput {
let PageEnv {
added,
allowed,
can_fill,
can_modify,
comments_hidden,
doc,
doc_links,
editing_content,
form,
info,
prefs,
preparing,
redact_mark,
table_preview,
text_style,
tool,
..
} = 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,
doc_links,
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);
}
// What this page has to do with the press: its owner's, the active tool's, or another page's.
let press = view.router.press_for(i);
// What lies under the pointer before the tools act, on the frames that need it: for a click's
// release, a right-click's tool checks, and Fill & Sign's preview.
let wants_under = resp.clicked() || resp.secondary_clicked() || matches!(tool, QuickTool::Fill(_)) && !ptr.any_down;
let under = pointer.filter(|p| wants_under && xf.rect.contains(*p)).map(|p| hits.at(view, p)).unwrap_or_default();
let released_over = match press {
PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner),
_ => false,
};
// A right-click while the primary button is held (a comment moved, a box drawn) leaves the
// selections that press acts on alone. egui ends the press's own interaction when any button is
// released, the right-click's own frame included, so the button is what tells. A touch's long
// press, which egui reports as a right-click with the finger still down, opens its menu.
let press_held = ptr.primary_down && !resp.long_touched();
let context = frame.right_click_on(xf.rect).and_then(|p| right_click(&hits, &under, view, p, press_held));
// A mark already placed takes its own press (comments, below), as does one being moved on this
// page until its move ends; with no button down the Fill & Sign tool's preview hides where a
// press would be taken (a mark, its handles, a Prepare field).
let over_mark = matches!(tool, QuickTool::Fill(_))
&& (matches!(press, PagePress::Mine(_)) || comments::moving_on(view, i) || !ptr.any_down && top(under.iter().cloned(), tool).is_some());
tool_input(ui, view, frame, slot, &pcx, press, over_mark);
let on_field = if preparing {
let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, press, 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, press, released_over, 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, press == PagePress::Free, 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 (link_owner, link_context) = (link_of(press.owner()), link_of(context));
let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, press == PagePress::Free, link_owner, link_context, view);
if tool == QuickTool::Select
&& can_modify
&& let Some(index) = link_context
{
view.links.context = Some((i, index));
}
// Comments, every frame: their own presses, a commenting tool's, and a click elsewhere that
// leaves them unselected. A press nobody holds that another tool took this frame (Prepare's
// selection rectangle, added content, which still decide for themselves) is not also a
// commenting tool's, as before.
let taken = on_objects || on_edit_text || on_link || on_content || boxing || on_field;
let on_comment = comments::page_input(ui, resp, &pcx, press, taken, view);
let consumed = taken || on_comment;
let hover = pointer.and_then(|p| claim_under(&hits, view, p, frame.env.unobstructed));
PageInput { consumed, area_tool, hover, released_over }
}
/// What lies under the pointer at `p`, after the tools act: its top, what a press there would take,
/// for hover painting. With `claim` (nothing drawn over the pages, such as the palette), its cursor
/// and tip too, after the tools claimed theirs (the last claim on a layer stands).
fn claim_under(hits: &PageHits<'_>, view: &mut DocView, p: Pos2, claim: bool) -> Option<crate::page_router::Hit> {
let under = hits.at(view, p);
if claim {
for h in &under {
view.cursor.claim_with_tip(h.layer, h.icon, h.tip.clone());
}
}
top(under, hits.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,
}
}
/// The active tool's own input on page `i`: the one gate for presses nobody holds. Measure, the
/// stamps, Fill & Sign, Sign area, zoom and snapshot, the camera's quadrilateral and crop act only
/// on a press that began on this page and that no hit won (`press` is Free), and keep their cursor
/// and gestures. The camera also drags a corner whose press it holds, and crop's double-click
/// opens Set Page Boxes wherever it is. `over_mark`: with Fill & Sign, a hit holds the press (a
/// mark's, or a Prepare field's), a mark is being moved on this page, or with no button down a press
/// under the pointer would be taken (a mark, its handles, a Prepare field).
fn tool_input(
ui: &egui::Ui,
view: &mut DocView,
frame: &mut PageFrame<'_>,
slot: &PageSlot,
pcx: &comments::PageCx<'_>,
press: PagePress,
over_mark: bool,
) {
let PageEnv {
allowed,
author,
by_line,
can_crop,
can_modify,
custom_stamp,
date_text,
doc,
info,
initials,
signature,
tool,
units,
fill_text_size,
fill_text_color,
..
} = frame.env;
let PageSlot { i, xf, .. } = *slot;
let PageFrame { resp, ptr, .. } = *frame;
let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info };
let free = press == PagePress::Free;
if let QuickTool::Measure(measure_tool) = tool {
crate::measure_ui::page_input(ui, crate::measure_ui::Clicks::of(resp, free), doc, view, pcx, measure_tool, units);
}
if let QuickTool::Stamp(kind) = tool
&& allowed
&& crate::stamps_ui::place(cx, kind, by_line, author, free, view)
{
frame.out.stamp_placed = true;
}
if let Some(cs) = custom_stamp.as_ref()
&& allowed
&& crate::stamps_ui::place_custom(cx, cs, author, free, view)
{
frame.out.stamp_placed = true;
}
if let QuickTool::Fill(ft) = tool
&& allowed
&& !over_mark
{
let input = 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,
};
match crate::fill_sign::page_input(cx, input, free, 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(cx, free, view);
}
if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) {
crate::zoom_snap::page_input(ui, resp, &xf, ptr, i, free, view);
}
if tool == QuickTool::EnhanceCamera {
crate::camera_ui::page_input(resp, &xf, ptr, i, view, can_modify, press);
}
if tool == QuickTool::Crop
&& crate::crop::page_input(ui, crate::crop::CropPageInput { resp, xf: &xf, ptr, page: i, info, view, allowed: can_crop, free })
{
view.current = i;
frame.out.open_boxes = true;
}
}
/// A right-click at `p`: the page's menu opens there, and it goes to what lies on top there
/// ([`PageHits::context_at`]), which the menu then acts on. A comment there is selected, and none
/// elsewhere; a Prepare field is selected; links take theirs from what this returns.
/// `under`: the hits at `p` before the tools act. `press_held`: the primary button is held, and the
/// comment and Prepare selections its press acts on are left alone until its release; links still
/// take theirs, as before.
fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2, press_held: bool) -> Option<Owner> {
let context = hits.context_at(view, p, under);
// Delete page, Paste and Copy Image act where the menu opened, whoever takes the right-click.
comments::anchor(hits.pcx, p, view);
if press_held {
return context;
}
// Add text takes every right-click on a page it can change, Edit text those on its own
// paragraphs and pictures (the objects selected together, and crop mode's picture, among them),
// the Link tool those on a link, and a Prepare field tool the whole page, as before: comments
// see none of them.
let tool_takes_it = match hits.tool {
QuickTool::AddText => hits.can_modify,
QuickTool::EditText => under.iter().any(|h| matches!(h.owner, Owner::Paragraph { .. } | Owner::Picture { .. } | Owner::Objects { .. })),
QuickTool::Link => hits.can_modify && matches!(context, Some(Owner::Link { .. })),
_ => hits.field_tool.is_some(),
};
if !tool_takes_it {
let comment = match context {
Some(Owner::Comment { index, .. }) => Some(index),
_ => None,
};
comments::context(hits.pcx, comment, view);
}
if let Some(Owner::PrepareField { field, widget }) = context {
crate::prepare::context(hits.form, field, widget, view);
}
context
}
/// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged.
fn edit_inventory(
view: &mut DocView,
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],
/// 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).
clip: Rect,
}
impl PageHits<'_> {
/// The hits at `p`, bottom up ([`Self::hits`]).
fn at(&self, view: &DocView, p: Pos2) -> Vec<crate::page_router::Hit> {
self.hits(view, p, false)
}
/// What a right-click at `p` is on: what lies on top there, as for a press, but with every
/// tool reaching comments and links, as a comment's menu shows whatever the tool. A highlight
/// over a link is the highlight's ([`comments::context_hit`]).
/// The Link tool edits links and reaches no comment: on a document it can change, its
/// right-click is what its press there would take (`under`, the hits at `p`), when that is
/// anything.
fn context_at(&self, view: &DocView, p: Pos2, under: &[crate::page_router::Hit]) -> Option<Owner> {
if self.tool == QuickTool::Link
&& self.can_modify
&& let Some(press) = top(under.iter().cloned(), QuickTool::Link)
{
return Some(press.owner);
}
top(self.hits(view, p, true), QuickTool::Select).map(|h| h.owner)
}
/// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text
/// on top of artwork takes the press), added content, the objects selected together and the
/// open paragraph's frame, the selected picture's grips over them all; the camera tool's corners;
/// then form fields (Prepare's, being moved, over the comments; filled ones under added content
/// and the comments that move). In crop mode, only the picture being cropped.
/// For a right-click (`context`), comments by [`comments::context_hit`], and no camera corner (it
/// has no menu).
fn hits(&self, view: &DocView, p: Pos2, context: bool) -> 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,
doc_links,
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();
let corner = (!context && tool == QuickTool::EnhanceCamera && can_modify).then(|| crate::camera_ui::hit(xf, page, view, p)).flatten();
let link = crate::link_ui::hit(xf, info, page, doc_links, tool, p);
let comment = if context { comments::context_hit(pcx, p) } else { comments::hit(pcx, view, p, link.is_some()) };
[link, comment, pictures, paragraphs, added, objects, grips, corner, 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, input.hover.as_ref());
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, input.hover.as_ref());
crate::forms_ui::tooltip(resp, form, input.hover.as_ref());
}
// 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::Link { index }) = view.router.owner(i)
&& input.released_over
&& let Some(l) = info.links.iter().find(|l| l.page == i && l.index == 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);
}
}
}
}
/// 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;
}
}
+23 -4
View File
@@ -39,6 +39,9 @@ pub(crate) struct PagePointer {
pub origin: Option<Pos2>,
/// This frame's primary presses and releases of a press the pages hold, in order.
pub edges: Vec<Edge>,
/// Where this frame's last primary release of a press the pages hold was: where a click came
/// up, which the pointer may have left by the frame's end. Kept past the cap on `edges`.
pub released: Option<Pos2>,
/// The primary button is down, wherever it was pressed: a gesture ends once it is up.
pub primary_down: bool,
/// Some button is down, wherever it was pressed.
@@ -49,8 +52,16 @@ impl PagePointer {
/// The pointer for `resp`, the content widget that holds every page, allocated in `ui`, while
/// a dialog is open over the window or not (`modal`).
pub(crate) fn read(ui: &egui::Ui, resp: &egui::Response, modal: bool) -> Self {
let (at, origin, edges, primary_down, any_down) =
ui.input(|i| (i.pointer.interact_pos(), i.pointer.press_origin(), edges(&i.events), i.pointer.primary_down(), i.pointer.any_down()));
let (at, origin, edges, released, primary_down, any_down) = ui.input(|i| {
(
i.pointer.interact_pos(),
i.pointer.press_origin(),
edges(&i.events),
last_release(&i.events),
i.pointer.primary_down(),
i.pointer.any_down(),
)
});
// egui's own answer to "do the pages hold a press?": from the press on until the frame
// of its click, release or drag stop. A drag can outlive egui's press while the
// pointer is outside the window. With a dialog open only a drag counts, up to the frame
@@ -64,9 +75,9 @@ impl PagePointer {
resp.dragged() || resp.drag_stopped() || (!modal && (resp.interact_pointer_pos().is_some() || press_origin.is_some_and(|p| on_top(&p))));
let pos = at.filter(|p| held || on_top(p));
if !held {
return Self { pos, origin: None, edges: Vec::new(), primary_down, any_down };
return Self { pos, origin: None, edges: Vec::new(), released: None, primary_down, any_down };
}
Self { pos, origin: press_origin, edges, primary_down, any_down }
Self { pos, origin: press_origin, edges, released, primary_down, any_down }
}
/// Where the pointer is, when it is inside `r`.
@@ -97,6 +108,14 @@ fn first_press(edges: &[Edge]) -> Option<Pos2> {
})
}
/// Where the last primary release among `events` was, however many came before it.
fn last_release(events: &[Event]) -> Option<Pos2> {
events.iter().rev().find_map(|e| match e {
Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, .. } if pos.is_finite() => Some(*pos),
_ => None,
})
}
/// The first [`MAX_EDGES`] primary presses and releases among `events`, in order.
fn edges(events: &[Event]) -> Vec<Edge> {
events
+406 -56
View File
@@ -53,6 +53,20 @@ fn a_touch_tap_keeps_its_position_on_the_frame_it_lifts() {
assert_eq!(frame(&ctx, vec![], false), PagePointer::default(), "and forget it the frame after");
}
/// A click is located where it came up even after a stall's many presses and releases, past the
/// cap on a frame's edges. Read from the edges kept, it was an earlier release's.
#[test]
fn a_frames_last_release_is_kept_past_the_cap_on_its_edges() {
let ctx = Context::default();
let (p, q) = (pos2(50.0, 60.0), pos2(80.0, 90.0));
frame(&ctx, vec![Event::PointerMoved(p)], false);
let mut events: Vec<Event> = (0..2 * MAX_EDGES).map(|i| button(p, PointerButton::Primary, i % 2 == 0)).collect();
events.extend([button(q, PointerButton::Primary, true), button(q, PointerButton::Primary, false)]);
let stall = frame(&ctx, events, false);
assert_eq!(stall.edges.len(), MAX_EDGES);
assert_eq!(stall.released, Some(q));
}
#[test]
fn a_press_and_release_in_one_frame_keep_the_press() {
let ctx = Context::default();
@@ -150,8 +164,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; 37] = [
"canvas.rs",
"canvas/page_input.rs",
"canvas/area.rs",
"canvas/bars.rs",
"canvas/cache.rs",
@@ -186,8 +201,18 @@ const PAGE_MODULES: [&str; 35] = [
"canvas/image_pick.rs",
"canvas/signature_drag.rs",
"tools/calc_order_ui.rs",
"tools/stamps_ui.rs",
];
/// What a file that acts on the pages uses: the pointer, a tool's or the comments' page context,
/// what a press is to a page, the router's hits and owners, or the router itself. Such a file is a
/// page module. One that acts on the pages through the page view's response and a bare point alone
/// uses none of them (`canvas/image_pick.rs`): PAGE_MODULES lists it by hand.
const PAGE_MODULE_MARKS: [&str; 6] = ["PagePointer", "PageToolContext", "PageCx", "PagePress", "page_router::", ".router."];
/// The router's own files, which define what page modules use.
const ROUTER_FILES: [&str; 3] = ["canvas/page_pointer.rs", "canvas/page_router.rs", "canvas/page_cursor.rs"];
/// egui's raw pointer state: where the pointer is, and which buttons are down or went down.
const RAW_READS: [&str; 21] = [
"hover_pos()",
@@ -216,14 +241,13 @@ const RAW_READS: [&str; 21] = [
/// Functions in the page modules that are not page input and read egui's pointer state
/// themselves: (file, function, why).
const NOT_PAGE_INPUT: [(&str, &str, &str); 11] = [
const NOT_PAGE_INPUT: [(&str, &str, &str); 10] = [
("canvas/area.rs", "zoom_anchor", "a pinch zooms the whole view, and is read before the pages' widget exists"),
("canvas/area.rs", "drawing_edge_pointer", "scrolls the view at its edges, read before the pages' widget exists"),
("canvas/area.rs", "glide_interrupted", "a press anywhere stops the touchpad's glide"),
("canvas/area.rs", "presentation_click", "full screen's click to turn the page, read before the pages' widget exists"),
("canvas/bars.rs", "page_size_hint", "the page size hint sits in the view's corner, not on a page"),
("canvas/organize.rs", "organize_grid", "Organize Pages shows thumbnails, not pages"),
("tools/comments/input.rs", "keys", "keyboard handling after the pages: a drag cancelled with Escape ends once no button is down"),
("tools/forms_ui.rs", "overlay", "the field editor is an Area of its own over the page"),
("tools/content_ui.rs", "editor", "the added-text editor is an Area of its own over the page"),
("tools/edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page"),
@@ -263,36 +287,131 @@ fn uses(line: &str, name: &str) -> bool {
line.match_indices(name).any(|(at, _)| name.starts_with('.') || !line[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_'))
}
/// The lines of `source` outside its test modules, numbered from 1. Tests drive egui's input and
/// read its output themselves. A test module (`#[cfg(test)]` then `mod name {`) ends at the first
/// line that is a lone `}`, as rustfmt lays a top-level item out, so the code after a test module
/// in the middle of a file is still read; `#[cfg(test)]` then `mod name;` is one line.
fn outside_test_modules(source: &str) -> Vec<(usize, &str)> {
let mut kept = Vec::new();
let mut lines = source.lines().enumerate().map(|(n, line)| (n + 1, line)).peekable();
while let Some((n, line)) = lines.next() {
if line == "#[cfg(test)]" && lines.peek().is_some_and(|(_, next)| next.starts_with("mod ")) {
if lines.next().is_some_and(|(_, module)| module.ends_with('{')) {
lines.by_ref().find(|(_, line)| *line == "}");
}
continue;
}
kept.push((n, line));
}
kept
}
/// The lines of code in `source` outside its test modules, numbered from 1, each with the
/// top-level function or `impl` method it is in; comment lines are left out. A line rustfmt
/// continued (a method chain split as `view` / `.router` / `.owner(i)`) is read with the one it
/// continues, under that one's number.
fn code_lines(source: &str) -> Vec<(usize, Option<&str>, String)> {
let (mut function, mut in_impl) = (None, false);
let mut lines: Vec<(usize, Option<&str>, String)> = Vec::new();
for (n, line) in outside_test_modules(source) {
if line.starts_with("impl") {
(function, in_impl) = (None, true);
} else if line == "}" {
(function, in_impl) = (None, false);
} else if let Some(name) = declares(line).or_else(|| line.strip_prefix(" ").filter(|_| in_impl).and_then(declares)) {
function = Some(name);
}
if line.trim_start().starts_with("//") {
continue;
}
match lines.last_mut() {
Some((_, _, text)) if line.trim_start().starts_with('.') => text.push_str(line.trim_start()),
_ => lines.push((n, function, line.to_owned())),
}
}
lines
}
/// Each name `find` finds in a line of the page modules' code (their tests left out), as (file,
/// the top-level function or `impl` method it is in, line, name) ([`code_lines`]).
fn page_module_matches(find: impl Fn(&str) -> Vec<String>) -> Vec<(&'static str, Option<String>, usize, String)> {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut found = Vec::new();
for file in PAGE_MODULES {
let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n");
for (n, function, text) in code_lines(&source) {
for name in find(&text) {
found.push((file, function.map(str::to_owned), n, name));
}
}
}
found
}
/// Each hit test `source` imports under a name that is not a hit test's (`use …::hit as under;`),
/// as (name, alias), from each `use` statement, from its first line to its `;`, wherever it is.
fn aliased_hit_tests(source: &str) -> Vec<(String, String)> {
let mut found = Vec::new();
let mut statement: Option<String> = None;
for line in source.lines().map(str::trim) {
if statement.is_none() && ["use ", "pub use ", "pub(crate) use ", "pub(super) use "].iter().any(|start| line.starts_with(start)) {
statement = Some(String::new());
}
let Some(text) = statement.as_mut() else { continue };
text.push_str(line);
text.push(' ');
if !line.ends_with(';') {
continue;
}
for part in text.split([',', '{', '}', ';']) {
let Some((path, alias)) = part.split_once(" as ") else { continue };
let (name, alias) = (path.trim().rsplit("::").next().unwrap_or_default(), alias.trim());
if is_hit_test(name) && !is_hit_test(alias) {
found.push((name.to_owned(), alias.to_owned()));
}
}
statement = None;
}
found
}
/// Each hit test a page module imports under a name that is not a hit test's, which the checks by
/// name would not see ([`aliased_hit_tests`]).
fn hit_test_aliases() -> Vec<String> {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut found = Vec::new();
for file in PAGE_MODULES {
let source = std::fs::read_to_string(src.join(file)).expect("page module");
for (name, alias) in aliased_hit_tests(&source) {
found.push(format!(
"src/{file}: `{name} as {alias}` imports a hit test under another name, which the checks by name miss: name \
it `hit` or `…_hit`."
));
}
}
found
}
/// Each use of one of `names` in the page modules' code (their tests left out).
fn page_module_uses(names: &[&str]) -> Vec<(&'static str, Option<String>, usize, String)> {
page_module_matches(|line| names.iter().filter(|name| uses(line, name)).map(|name| (*name).to_owned()).collect())
}
/// Each use of one of `names` in the page modules' code (their tests left out) outside the
/// functions `allowed` to, as "src/file:line: `name` why"; then each allowed function that uses
/// none of them any more, as "src/file: fn function unused".
fn misuses(names: &[&str], allowed: &[(&str, &str, &str)], why: &str, unused: &str) -> Vec<String> {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
misuses_in(page_module_uses(names), allowed, why, unused)
}
/// [`misuses`], of what was `found`.
fn misuses_in(found: Vec<(&str, Option<String>, usize, String)>, allowed: &[(&str, &str, &str)], why: &str, unused: &str) -> Vec<String> {
let mut problems = Vec::new();
let mut needed = vec![false; allowed.len()];
for file in PAGE_MODULES {
let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n");
// Tests drive egui's input and read its output themselves.
let code = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default();
// The top-level function, or method of an `impl`, that each line is in.
let (mut function, mut in_impl) = (None, false);
for (n, line) in code.lines().enumerate() {
if line.starts_with("impl") {
(function, in_impl) = (None, true);
} else if line == "}" {
(function, in_impl) = (None, false);
} else if let Some(name) = declares(line).or_else(|| line.strip_prefix(" ").filter(|_| in_impl).and_then(declares)) {
function = Some(name);
}
if line.trim_start().starts_with("//") {
continue;
}
for name in names.iter().filter(|name| uses(line, name)) {
match allowed.iter().position(|(f, function_name, _)| *f == file && function == Some(*function_name)) {
Some(k) => needed[k] = true,
None => problems.push(format!("src/{file}:{}: `{name}` {why}", n + 1)),
}
}
for (file, function, n, name) in found {
match allowed.iter().position(|(f, function_name, _)| *f == file && function.as_deref() == Some(*function_name)) {
Some(k) => needed[k] = true,
None => problems.push(format!("src/{file}:{n}: `{name}` {why}")),
}
}
for ((file, name, _), needed) in allowed.iter().zip(needed) {
@@ -317,7 +436,7 @@ fn page_modules_read_the_pointer_only_through_page_pointer() {
NOT_PAGE_INPUT there, with why.",
"reads no pointer state any more: remove it from NOT_PAGE_INPUT.",
);
// A module that takes the page pointer is a page module, so it is checked too.
// A module that acts on the pages is a page module, so it is checked too.
let mut dirs = vec![src.clone()];
while let Some(dir) = dirs.pop() {
for entry in std::fs::read_dir(dir).expect("source directory") {
@@ -337,8 +456,9 @@ fn page_modules_read_the_pointer_only_through_page_pointer() {
let file =
path.strip_prefix(&src).expect("under src").components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/");
let source = std::fs::read_to_string(&path).expect("source file");
if file != "canvas/page_pointer.rs" && source.contains("PagePointer") && !PAGE_MODULES.contains(&file.as_str()) {
problems.push(format!("src/{file} takes the page pointer: add it to PAGE_MODULES."));
let marks = PAGE_MODULE_MARKS.iter().filter(|mark| source.contains(**mark)).collect::<Vec<_>>();
if !ROUTER_FILES.contains(&file.as_str()) && !marks.is_empty() && !PAGE_MODULES.contains(&file.as_str()) {
problems.push(format!("src/{file} uses {marks:?}, so it acts on the pages: add it to PAGE_MODULES."));
}
}
}
@@ -362,18 +482,138 @@ fn page_modules_claim_the_cursor_and_never_set_it() {
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
/// Hit producers are called only by the central routing table, not again by an input handler.
const PAGE_HIT_CALLS: [&str; 8] = [
/// What decides, keeps or drops the press the pages hold.
const ROUTER_WRITES: [&str; 8] = [
"router.begin(",
"router.press(",
"router.settle(",
"router.cancel(",
"PageRouter::begin(",
"PageRouter::press(",
"PageRouter::settle(",
"PageRouter::cancel(",
];
/// The functions that decide, keep or drop the press the pages hold: (file, function, why).
const ROUTER_WRITERS: [(&str, &str, &str); 3] = [
("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, automation's cancel"),
];
/// Ways to make a `Hit`.
const HIT_BUILDS: [&str; 2] = ["Hit::new(", "Hit {"];
/// Whether `name` is a hit test's: `hit`, or `…_hit`.
fn is_hit_test(name: &str) -> bool {
name == "hit" || name.ends_with("_hit")
}
/// The hit tests `line` calls: by path, bare or as a method.
fn hit_test_calls(line: &str) -> Vec<String> {
line.match_indices("hit(")
.filter_map(|(at, _)| {
let end = at + "hit".len();
let start = line[..end].char_indices().rev().find(|(_, c)| !(c.is_alphanumeric() || *c == '_')).map_or(0, |(i, c)| i + c.len_utf8());
let (before, name) = (&line[..start], &line[start..end]);
(is_hit_test(name) && !before.ends_with("fn ")).then(|| name.to_owned())
})
.collect()
}
/// The functions that call a hit test: (file, function, why). Hit tests are found by name (a
/// function named `hit` or `…_hit`, called by path, bare or as a method). The comments' point test
/// has a list of its own (`COMMENT_POINT_TESTERS`); the others by other names (added content's,
/// Edit text's paragraphs', Redact's text test, the text layer's links) are not listed yet, and
/// most are PR 4b's. `PageHits::hits` is the one: the hits
/// at the press point decide its owner, and those under the pointer give its cursor, its tip, hover
/// painting and a right-click.
const HIT_TESTERS: [(&str, &str, &str); 1] = [(
"canvas/page_input.rs",
"hits",
"`PageHits::hits`: every module's hits at a point, for the pointer's cursor and tip, a right-click and, once, the press's owner",
)];
/// The page view's asks for every module's hits at a point (`PageHits::hits`, and `at` and
/// `context_at`, which wrap it).
const PAGE_HITS_ASKS: [&str; 6] = [".hits(", ".at(", ".context_at(", "PageHits::hits(", "PageHits::at(", "PageHits::context_at("];
/// The functions that ask the page view's hits at a point: (file, function, why). `PageHits` is
/// private to `page_input.rs`, so this keeps a new function there from asking; a second ask inside
/// a listed function is for review to catch. The method names are matched in every page module,
/// so an `at` of another type there would need them narrowed.
const PAGE_HITS_ASKERS: [(&str, &str, &str); 5] = [
("canvas/page_input.rs", "at", "`PageHits::at`: the hits at a point, as a press there would take them"),
("canvas/page_input.rs", "context_at", "`PageHits::context_at`: what a right-click at a point is on"),
(
"canvas/page_input.rs",
"page_tool_input",
"the hits at the press point decide its owner, once per press; those under the pointer before the tools act, once a \
frame, serve a click's release, a right-click and Fill & Sign's preview",
),
("canvas/page_input.rs", "claim_under", "what lies under the pointer after the tools act: its cursor, its tip and hover painting"),
("canvas/page_input.rs", "right_click", "what a right-click is on"),
];
/// The comments' point test, which their hit tests are built on: which comment lies under a point.
const COMMENT_POINT_TESTS: [&str; 2] = [".comment_at(", "PageCx::comment_at("];
/// The functions that ask which comment lies under a point: (file, function, why).
const COMMENT_POINT_TESTERS: [(&str, &str, &str); 3] = [
("tools/comments/input.rs", "hit", "the comments' hit test"),
("tools/comments/input.rs", "context_hit", "the comments' hit test for a right-click"),
("tools/comments/paint.rs", "paint_page", "a redaction mark shows its fill under the pointer with every tool, as comments' tips show"),
];
/// What reads who holds the press the pages hold.
const ROUTER_READS: [&str; 6] =
["router.press_for(", "router.owner(", "router.keeps_drag(", "PageRouter::press_for(", "PageRouter::owner(", "PageRouter::keeps_drag("];
/// The functions that read who holds the press: (file, function, why). The page view asks once per
/// page and tells each module what the press is to its page (`PagePress`); the Edit text group, the
/// text layer and link following still ask, until they are told too (PR 4b), and an unused entry
/// fails the test.
const ROUTER_READERS: [(&str, &str, &str); 8] = [
(
"canvas/page_input.rs",
"page_tool_input",
"the page view: what the press is to each page (`press_for`); Add text's paragraph and the pictures until 4b",
),
("canvas/page_input.rs", "update_selection_on_press", "the text layer selects no text under a press held by what keeps its drag (4b)"),
("canvas/page_input.rs", "finish_text_pointer_gesture", "a click held by what keeps its drag leaves the text selection alone (4b)"),
("canvas/page_input.rs", "page_overlays", "a click follows the link that holds its press (4b)"),
("tools/content_ui.rs", "page_input", "added text and pictures, until they are told (4b)"),
("tools/edit_text_ui.rs", "page_input", "Edit text's paragraphs, until they are told (4b)"),
("tools/edit_text_ui/image.rs", "image_input", "Edit text's pictures, until they are told (4b)"),
("tools/object_ui.rs", "input", "the objects selected together, until they are told (4b)"),
];
/// What ignores the press the pages hold until the button is up.
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/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).
const PAGE_HIT_CALLS: [&str; 12] = [
"link_ui::hit(",
"prepare::hit(",
"forms_ui::hit(",
"comments::hit(",
"comments::context_hit(",
"camera_ui::hit(",
"edit_text_ui::hit(",
"edit_text_ui::picture_hit(",
"edit_text_ui::picture_handle_hit(",
"edit_text_ui::crop_hit(",
"object_ui::hit(",
"content_ui::hit(",
];
/// Whether a press handler's `code` acts on the owner the router captured, and calls no hit test
/// of its own to choose one.
fn routed_press_handler(code: &str) -> bool {
let code = code.lines().filter(|line| !line.trim_start().starts_with("//")).collect::<Vec<_>>().join("\n");
code.contains("router.owner(")
@@ -383,51 +623,161 @@ fn routed_press_handler(code: &str) -> bool {
&& !uses(&code, "handle_hit(")
}
/// Source-level guard for the Page presses rule, alongside the raw-pointer and cursor guards.
/// Runtime routing, capture lifetime and Escape behavior have separate behavioral tests.
/// Page modules act on a press only when the router says it is theirs. The hits at the press
/// point decide its owner once, in one place, and a `Hit` comes only from a module's hit test,
/// so what the cursor promised is what the press does. Where each module tested the press
/// point again, or asked another whether it wanted the press, the answers disagreed: a press on
/// one check box released over the next ticked the second, and a drag could be taken over
/// halfway. Only `PageHits::hits` asks the hit tests. Escape is the one way to
/// drop a press and ignore it until the button is up.
#[test]
fn page_modules_take_presses_from_the_router_without_repeating_hit_tests() {
fn page_modules_act_on_a_press_only_through_the_router() {
let mut problems = misuses(
&PAGE_HIT_CALLS,
&[("canvas.rs", "at", "the central PageHits table")],
"repeats a module hit test outside PageHits::at; use the captured router owner",
"no longer collects module hits",
&ROUTER_WRITES,
&ROUTER_WRITERS,
"decides, keeps or drops the press the pages hold. Only the hits at the press point decide its owner, once \
(src/canvas/page_router.rs), and the page view tells each module what the press is to its page (`PagePress`). Drop a press with \
`DocView::cancel_pointer_gestures`. A new place that must? Add the function to ROUTER_WRITERS in \
src/canvas/page_pointer/tests.rs, with why.",
"no longer decides, keeps or drops the press: remove it from ROUTER_WRITERS.",
);
problems.extend(misuses(
&["router.press("],
&[("canvas.rs", "page_tool_input", "the central press capture")],
"captures a page press outside the central routing path",
"no longer captures page presses",
&ROUTER_READS,
&ROUTER_READERS,
"reads who holds the press the pages hold. The page view asks the router once per page and tells each module \
what the press is to its page: take a `PagePress` (src/canvas/page_router.rs) instead. A new place that must? \
Add the function to ROUTER_READERS in src/canvas/page_pointer/tests.rs, with why.",
"no longer reads who holds the press: remove it from ROUTER_READERS.",
));
// A router method the lists above don't name yet is the router's all the same: only their
// functions use it.
let known: Vec<&str> = ROUTER_WRITES.iter().chain(ROUTER_READS.iter()).copied().collect();
let unlisted = page_module_matches(|line| {
let router = uses(line, ".router.") || uses(line, "PageRouter::");
if router && !known.iter().any(|name| uses(line, name)) { vec!["router".to_owned()] } else { Vec::new() }
});
problems.extend(
unlisted
.into_iter()
.filter(|(file, function, _, _)| {
!ROUTER_WRITERS.iter().chain(ROUTER_READERS.iter()).any(|(f, name, _)| f == file && function.as_deref() == Some(*name))
})
.map(|(file, _, n, _)| {
format!(
"src/{file}:{n}: uses the router by a method ROUTER_WRITES and ROUTER_READS don't name. The page view \
decides the press and tells each module what it is to its page (`PagePress`): add the method to \
ROUTER_WRITES or ROUTER_READS in src/canvas/page_pointer/tests.rs, and the function to their lists \
only if it must."
)
}),
);
problems.extend(misuses(
&COMMENT_POINT_TESTS,
&COMMENT_POINT_TESTERS,
"asks which comment lies under a point. The comments' hit tests answer that for a press and a right-click: \
act on what the page view tells you, rather than testing the point again. A new place that must? Add the \
function to COMMENT_POINT_TESTERS in src/canvas/page_pointer/tests.rs, with why.",
"no longer asks which comment lies under a point: remove it from COMMENT_POINT_TESTERS.",
));
problems.extend(misuses(
&PAGE_HITS_ASKS,
&PAGE_HITS_ASKERS,
"asks the page view's hits at a point. The hits at the press point decide its owner once, and those under the \
pointer are taken once before the tools act and once after: use those (`under`, `PageInput.hover`). A new \
place that must ask? Add the function to PAGE_HITS_ASKERS in src/canvas/page_pointer/tests.rs, with why.",
"no longer asks the page view's hits: remove it from PAGE_HITS_ASKERS.",
));
problems.extend(misuses(
&PRESS_IGNORES,
&PRESS_IGNORERS,
"ignores the press the pages hold. Escape cancels a press, in `cancel_on_escape`: drop a gesture with \
`DocView::cancel_pointer_gestures` instead.",
"no longer ignores a press: remove it from PRESS_IGNORERS.",
));
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
for (file, function) in [
("tools/forms_ui.rs", "page_input"),
("tools/prepare.rs", "page_input"),
("tools/content_ui.rs", "page_input"),
("tools/edit_text_ui.rs", "page_input"),
("tools/edit_text_ui/image.rs", "image_input"),
("tools/object_ui.rs", "input"),
("tools/comments/input.rs", "select_input"),
] {
let source = std::fs::read_to_string(src.join(file)).expect("page input module");
let body = source.lines().skip_while(|line| declares(line) != Some(function)).take_while(|line| *line != "}").collect::<Vec<_>>().join("\n");
// Forms reuse their hit test for hover text and to require release over the captured
// widget. Neither chooses the press owner. Pin both calls to the current pointer `p`,
// not the press origin, and reject an additional call or a changed argument.
let body = if file == "tools/forms_ui.rs" {
let call = "hit(xf, info, page, form, allowed, p)";
assert_eq!(body.matches(call).count(), 2, "only the existing hover and release checks are exempt");
body.replace(call, "hover_or_release_check")
} else {
body
};
if !routed_press_handler(&body) {
problems.push(format!("src/{file}: {function} must consume the captured router owner without repeating a hit test"));
}
}
// A hit test is a function named `hit` or `…_hit`: one answer to "what of mine is here?",
// which gives the module's cursor, its tip and its press.
problems.extend(misuses_in(
page_module_matches(hit_test_calls),
&HIT_TESTERS,
"calls a hit test. The hits at the press point decide its owner once, in `PageHits::hits`: act on the press when \
the page view tells you it is yours (`PagePress::Mine`), rather than testing its point again. What lies under \
the pointer comes from the page view's own hits: hover painting and tips (`PageInput.hover`), a click released \
over what it was pressed on (`released_over`). A new place that must ask? Add the function to HIT_TESTERS in \
src/canvas/page_pointer/tests.rs, with why.",
"calls no hit test any more: remove it from HIT_TESTERS.",
));
problems.extend(hit_test_aliases());
// An entry no page module calls any more no longer guards anything.
let called = page_module_uses(&PAGE_HIT_CALLS);
problems.extend(
PAGE_HIT_CALLS
.iter()
.filter(|call| !called.iter().any(|(_, _, _, name)| name == *call))
.map(|call| format!("no page module calls `{call}` any more: remove it from PAGE_HIT_CALLS.")),
);
let builds = page_module_uses(&HIT_BUILDS);
assert!(!builds.is_empty(), "no page module makes a `Hit` any more: update HIT_BUILDS");
problems.extend(builds.into_iter().filter(|(_, function, _, _)| !function.as_deref().is_some_and(is_hit_test)).map(|(file, _, n, name)| {
format!(
"src/{file}:{n}: `{name}` makes a hit outside a hit test. A page module answers \"what of mine is \
here?\" in one function, named `hit` or `…_hit`, so its cursor, its tip and its press agree."
)
}));
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
/// A test module in the middle of a file hides only itself from the page guards: the code after
/// it is read too. The guards used to stop at the first test module.
#[test]
fn the_page_guards_read_past_a_test_module_in_the_middle_of_a_file() {
let source = "fn before() {}\n#[cfg(test)]\nmod tests {\n fn inside() {}\n}\n\nfn after() {}\n#[cfg(test)]\nmod more;\nfn last() {}\n";
let kept: Vec<(usize, &str)> = outside_test_modules(source);
assert_eq!(kept, [(1, "fn before() {}"), (6, ""), (7, "fn after() {}"), (10, "fn last() {}")]);
}
/// The guard's readers: hit tests by name, the function a line declares, a call rustfmt split,
/// and a hit test imported as another name.
#[test]
fn the_page_guards_read_hit_tests_split_calls_and_aliases() {
assert_eq!(hit_test_calls("let h = crate::forms_ui::hit(xf, p);"), ["hit"]);
assert_eq!(hit_test_calls("cx.comment_hit(p) && picture_hit(p)"), ["comment_hit", "picture_hit"]);
assert!(hit_test_calls("pub(crate) fn hit(xf: &PageXform) -> Option<Hit> {").is_empty());
assert!(hit_test_calls("let all = self.hits(view, p, false);").is_empty());
assert_eq!(declares("pub(crate) const fn crop_hit<'a>(view: &DocView) {"), Some("crop_hit"));
assert_eq!(declares("let hit = 1;"), None);
let source = "impl PageHits<'_> {\n fn at(&self) {\n self\n .hits(view, p, false)\n }\n}\nfn after() {\n // view.router.owner(0)\n let _ = view\n .router\n .owner(0);\n}\n";
assert_eq!(
code_lines(source),
[
(1, None, "impl PageHits<'_> {".to_owned()),
(2, Some("at"), " fn at(&self) {".to_owned()),
(3, Some("at"), " self.hits(view, p, false)".to_owned()),
(5, Some("at"), " }".to_owned()),
(6, None, "}".to_owned()),
(7, Some("after"), "fn after() {".to_owned()),
(9, Some("after"), " let _ = view.router.owner(0);".to_owned()),
(12, None, "}".to_owned()),
]
);
let source =
"fn a() {}\n\n#[cfg(any())]\nuse crate::camera_ui::hit as corner_under;\nuse crate::x::{\n hit as x_hit,\n picture_hit as under,\n};\n";
let aliases: Vec<(String, String)> = [("hit", "corner_under"), ("picture_hit", "under")].map(|(a, b)| (a.to_owned(), b.to_owned())).to_vec();
assert_eq!(aliased_hit_tests(source), aliases);
}
#[test]
fn the_page_press_guard_rejects_missing_owners_and_second_hit_tests() {
assert!(routed_press_handler("let owner = view.router.owner(page);"));
+99 -7
View File
@@ -6,7 +6,8 @@
//! press point decide who captures it ([`PageRouter::press`]), by the same table: the highest layer
//! the active tool reaches. Until the button is up only the owner acts on the press, its drag and
//! its release, so what the cursor promised is what the press does, and nothing takes a gesture
//! back halfway.
//! back halfway. Each page is told what it has to do with the press ([`PagePress`]): its owner's,
//! the active tool's, or none of its business.
use egui::CursorIcon;
@@ -18,7 +19,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 },
@@ -35,6 +36,8 @@ pub(crate) enum Owner {
/// Added text or a picture placed on the page, by its index among the page's, and a corner of
/// the selected one (left, top) or the item itself.
Added { index: usize, corner: Option<(bool, bool)> },
/// Enhance ▸ Camera image: a corner of the quadrilateral (0 left top, then clockwise).
CameraCorner { corner: usize },
}
/// The part of a picture a hit is on.
@@ -87,7 +90,7 @@ pub(crate) struct Hit {
/// Beside the pointer where this hit wins (a link's target).
pub tip: Option<String>,
/// A drag from here is not this owner's: a click on a highlight selects it, a drag across it
/// selects the text underneath.
/// selects the text underneath; with the Link tool, a drag from a link draws a new one.
pub declines_drag: bool,
}
@@ -106,10 +109,35 @@ pub(crate) fn top(hits: impl IntoIterator<Item = Hit>, tool: QuickTool) -> Optio
})
}
/// What one page has to do with the press the pages hold.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PagePress {
/// The press began on this page, and the owner's hit won it: its module acts on it. Others
/// only let go of their own state (a click on anything but the selected comment unselects it).
Mine(Owner),
/// No button is down, or the press began on this page and nobody's hit won it: the active
/// tool's. Also every page's while a press the page view does not hold is down (one that began
/// over a panel), and after the press was dropped ([`PageRouter::cancel`]) until the button is
/// up (`Elsewhere` instead when Escape came in the press's own frame; page input skips a
/// cancelled press either way).
Free,
/// The press began on another page, or off the pages (between them, or beside one).
Elsewhere,
}
impl PagePress {
/// Who holds the press on this page, if anyone.
pub(crate) fn owner(self) -> Option<Owner> {
match self {
PagePress::Mine(owner) => Some(owner),
PagePress::Free | PagePress::Elsewhere => None,
}
}
}
/// A press the pages hold, and who it belongs to.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Capture {
pub page: usize,
pub owner: Owner,
pub declines_drag: bool,
}
@@ -118,18 +146,43 @@ pub(crate) struct Capture {
#[derive(Debug, Default)]
pub(crate) struct PageRouter {
capture: Option<Capture>,
/// A press is held, and the page it began on (`None`: off the pages, between them or beside one).
began: Option<Option<usize>>,
}
impl PageRouter {
/// A new press on the pages: the last one's owner goes, and until a page claims it the press is
/// between the pages.
pub(crate) fn begin(&mut self) {
self.capture = None;
self.began = Some(None);
}
/// A press on `page`: `hits` at the press point decide who it belongs to. A press nobody's
/// hit claims is held by nobody (the page's text, or a tool that acts anywhere on the page).
/// Only a press just begun ([`Self::begin`]) is claimed, by the first page that does: pages
/// that touch (the gaps hidden) can share a row of pixels, and a press there is one page's.
pub(crate) fn press(&mut self, page: usize, hits: impl IntoIterator<Item = Hit>, tool: QuickTool) {
self.capture = top(hits, tool).map(|h| Capture { page, owner: h.owner, declines_drag: h.declines_drag });
if self.began != Some(None) {
return;
}
self.capture = top(hits, tool).map(|h| Capture { owner: h.owner, declines_drag: h.declines_drag });
self.began = Some(Some(page));
}
/// What `page` has to do with the press the pages hold.
pub(crate) fn press_for(&self, page: usize) -> PagePress {
match (self.began, &self.capture) {
(None, _) => PagePress::Free,
(Some(Some(p)), Some(capture)) if p == page => PagePress::Mine(capture.owner),
(Some(Some(p)), None) if p == page => PagePress::Free,
_ => PagePress::Elsewhere,
}
}
/// Who holds the press on `page`.
pub(crate) fn owner(&self, page: usize) -> Option<Owner> {
self.capture.as_ref().filter(|c| c.page == page).map(|c| c.owner)
self.press_for(page).owner()
}
/// Whether the press is held by someone who keeps its drag: then the page's text is not
@@ -142,12 +195,15 @@ impl PageRouter {
pub(crate) fn settle(&mut self, any_down: bool, edges: bool) {
if !any_down && !edges {
self.capture = None;
self.began = None;
}
}
/// Drops the press (Escape, a dialog, automation's cancel).
/// Drops the press (Escape, automation's cancel): every page is the active tool's again
/// ([`PagePress::Free`]). Page input skips a press Escape cancelled until the button is up.
pub(crate) fn cancel(&mut self) {
self.capture = None;
self.began = None;
}
}
@@ -172,6 +228,7 @@ mod tests {
#[test]
fn a_press_is_held_until_the_button_is_up_and_its_release_seen() {
let mut r = PageRouter::default();
r.begin();
r.press(2, [hit(Layer::Comment, 7)], QuickTool::Select);
assert_eq!(r.owner(2), Some(hit(Layer::Comment, 7).owner));
assert_eq!(r.owner(1), None, "only on its page");
@@ -180,14 +237,49 @@ mod tests {
assert!(r.owner(2).is_some(), "the release frame still belongs to the press");
r.settle(false, false);
assert_eq!(r.owner(2), None);
r.begin();
r.press(0, [], QuickTool::Select);
assert_eq!(r.owner(0), None, "a press nobody claims is held by nobody");
assert!(!r.keeps_drag());
}
/// A page is told whether the press is its owner's, the active tool's, or another page's.
#[test]
fn each_page_is_told_whether_the_press_is_its_own() {
let mut r = PageRouter::default();
assert_eq!(r.press_for(3), PagePress::Free, "no button down: every page is the tool's");
r.begin();
assert_eq!(r.press_for(0), PagePress::Elsewhere, "a press between the pages is no page's");
r.press(2, [], QuickTool::Select);
assert_eq!((r.press_for(2), r.press_for(1)), (PagePress::Free, PagePress::Elsewhere), "nobody's, on page 2");
r.begin();
r.press(1, [hit(Layer::Comment, 4)], QuickTool::Select);
assert_eq!((r.press_for(1), r.press_for(2)), (PagePress::Mine(hit(Layer::Comment, 4).owner), PagePress::Elsewhere));
r.settle(false, true);
assert_eq!(r.press_for(2), PagePress::Elsewhere, "until its release has been seen");
r.settle(false, false);
assert_eq!((r.press_for(1), r.press_for(2)), (PagePress::Free, PagePress::Free));
r.begin();
r.cancel();
// Page input skips a press Escape cancelled until the button is up (`press_cancelled`).
assert_eq!(r.press_for(0), PagePress::Free, "a cancelled press leaves every page to the tool");
}
/// Pages that touch (the gaps hidden) can share a row of pixels: a press there is the first
/// page's to claim it, and the next page's claim is ignored.
#[test]
fn a_press_where_two_pages_touch_is_the_first_pages() {
let mut r = PageRouter::default();
r.begin();
r.press(0, [hit(Layer::Comment, 1)], QuickTool::Select);
r.press(1, [hit(Layer::Comment, 2)], QuickTool::Select);
assert_eq!((r.press_for(0), r.press_for(1)), (PagePress::Mine(hit(Layer::Comment, 1).owner), PagePress::Elsewhere));
}
#[test]
fn a_highlight_lets_its_drag_go_to_the_text() {
let mut r = PageRouter::default();
r.begin();
r.press(0, [Hit { declines_drag: true, ..hit(Layer::Comment, 0) }], QuickTool::Select);
assert!(r.owner(0).is_some() && !r.keeps_drag());
r.cancel();
+1 -2
View File
@@ -402,8 +402,7 @@ mod tests {
let doc = session.get(id).expect("open");
let index = doc.info.annotations.iter().find(|a| a.subtype == "Sound").expect("listed").index;
let ctx = egui::Context::default();
let mut cv = CommentView::default();
cv.gesture = Some(Gesture::Move { page: 0, index, from: pos2(0.0, 0.0) });
let mut cv = CommentView { gesture: Some(Gesture::Move { page: 0, index, from: pos2(0.0, 0.0) }), ..CommentView::default() };
let mut drag = SignatureDrag::default();
let key = Some((doc.edit_generation(), 0, index));
drag.prepare_move(&ctx, doc, &cv, None, false, 1.0);
+21
View File
@@ -2359,3 +2359,24 @@ fn image_handles_account_for_document_and_view_rotation() {
}
}
}
/// Cancelling the pages' pointer gestures (Escape, automation's cancel) lets go of a
/// camera corner being dragged: the quadrilateral stays, and no corner follows the pointer.
#[test]
fn cancelling_pointer_gestures_lets_go_of_a_camera_corner() {
let mut v = view(1, PageLayout::Single, true);
v.camera = Some(crate::camera_ui::CameraQuad { grab: Some(2), ..crate::camera_ui::CameraQuad::inset(0, 300.0, 400.0) });
v.cancel_pointer_gestures();
assert_eq!(v.camera.map(|q| q.grab), Some(None));
}
/// A camera corner at the page's edge is hit on the page only: a press off the page is no page's,
/// so its grab cursor does not show there.
#[test]
fn a_camera_corner_at_the_page_edge_is_hit_on_the_page_only() {
let mut v = view(1, PageLayout::Single, true);
v.camera = Some(crate::camera_ui::CameraQuad { page: 0, corners: [[0.0, 0.0], [300.0, 0.0], [300.0, 400.0], [0.0, 400.0]], grab: None });
let xf = PageXform { rect: Rect::from_min_size(pos2(10.0, 20.0), egui::vec2(300.0, 400.0)), rot: 0, pw: 300.0, ph: 400.0 };
assert!(crate::camera_ui::hit(&xf, 0, &v, pos2(14.0, 24.0)).is_some(), "on the page");
assert!(crate::camera_ui::hit(&xf, 0, &v, pos2(6.0, 16.0)).is_none(), "just off it");
}
+6 -5
View File
@@ -22,15 +22,15 @@ pub enum AreaChoice {
Save,
}
/// Drag a rectangle on page `page` (Marquee Zoom, Snapshot).
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView) {
/// Drag a rectangle on page `page` (Marquee Zoom, Snapshot), with a press nobody holds (`free`).
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, free: bool, view: &mut DocView) {
let (pointer, origin) = (ptr.pos, ptr.origin);
if pointer.is_some_and(|p| xf.rect.contains(p)) {
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
}
// The tool's own press, of the primary button: nothing drawn over the page here holds it.
// The tool's own press, of the primary button: one nobody holds.
if resp.drag_started_by(egui::PointerButton::Primary)
&& view.router.owner(page).is_none()
&& free
&& let Some(o) = origin.filter(|o| xf.rect.contains(*o))
{
view.marquee = Some((page, o));
@@ -73,7 +73,8 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p
view.marquee_done = Some((page, r, [a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)]));
}
}
if resp.clicked()
if free
&& resp.clicked()
&& let Some(p) = pointer.filter(|p| xf.rect.contains(*p))
{
let v = xf.screen_to_view(p);
+42 -20
View File
@@ -7,6 +7,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Shape, Stroke, vec2};
use crate::canvas::{DocView, PageXform, ViewAction};
use crate::page_cursor::Layer;
use crate::page_pointer::PagePointer;
use crate::page_router::{Hit, Owner, PagePress};
use crate::theme::Tokens;
pub use pdfcraft_engine::camera_clean::Look;
@@ -59,8 +60,29 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 {
xf.norm_to_screen(c[0] / xf.pw.max(1e-3), c[1] / xf.ph.max(1e-3))
}
/// Corner dragging on one page. A press on a page without the quadrilateral moves it there.
pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool) {
/// The corner of the quadrilateral under `p` on `page`, the nearest within reach: the one hit test
/// of the camera tool, for the cursor and the press. `None` once the page is enhanced, on a page
/// without the quadrilateral, or off the page (a press there is no page's), even where a corner at
/// its edge would reach past it.
pub(crate) fn hit(xf: &PageXform, page: usize, view: &DocView, p: Pos2) -> Option<Hit> {
if view.camera_done.is_some() || !xf.rect.contains(p) {
return None;
}
let q = view.camera.filter(|q| q.page == page)?;
let corner = q
.corners
.iter()
.enumerate()
.map(|(i, &c)| (i, screen(xf, c).distance(p)))
.filter(|(_, d)| *d <= GRAB)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(i, _)| i)?;
Some(Hit::new(Owner::CameraCorner { corner }, Layer::Handle, egui::CursorIcon::Grab))
}
/// Corner dragging on one page: a corner's own press ([`hit`]) drags it, and a press nobody holds
/// on a page without the quadrilateral moves it there.
pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool, press: PagePress) {
let (pointer, origin) = (ptr.pos, ptr.origin);
if !allowed {
if pointer.is_some_and(|p| xf.rect.contains(p)) {
@@ -76,23 +98,25 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe
if view.camera.is_none() && page == view.current {
view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph));
}
if resp.drag_started() || resp.clicked() {
let pressed = origin.filter(|o| xf.rect.contains(*o));
if pressed.is_some() && view.camera.is_none_or(|q| q.page != page) {
view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph));
}
if resp.drag_started()
&& let (Some(o), Some(q)) = (pressed, view.camera.as_mut())
{
q.grab = (0..4)
.map(|i| (i, screen(xf, q.corners[i]).distance(o)))
.filter(|(_, d)| *d <= GRAB)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(i, _)| i);
}
// A press nobody holds on a page without the quadrilateral moves it there: a drag from where it
// began, a click where it came up (egui has cleared the press's origin by its release, and the
// pointer may have moved on in the same frame).
let at = if resp.clicked() { ptr.released } else { origin };
if (resp.drag_started() || resp.clicked())
&& press == PagePress::Free
&& at.is_some_and(|p| xf.rect.contains(p))
&& view.camera.is_none_or(|q| q.page != page)
{
view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph));
}
if resp.drag_started()
&& let PagePress::Mine(Owner::CameraCorner { corner }) = press
&& let Some(q) = view.camera.as_mut().filter(|q| q.page == page)
{
q.grab = Some(corner);
}
let Some(q) = view.camera.as_mut().filter(|q| q.page == page) else { return };
let near = pointer.is_some_and(|p| q.corners.iter().any(|&c| screen(xf, c).distance(p) <= GRAB));
// Over a corner, its hit claims the grab cursor.
if let Some(i) = q.grab {
view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing);
if let Some(p) = pointer {
@@ -101,11 +125,9 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe
*c = [x.clamp(0.0, xf.pw), y.clamp(0.0, xf.ph)];
}
}
if resp.drag_stopped() {
if resp.drag_stopped() || !ptr.primary_down {
q.grab = None;
}
} else if near {
view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab);
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ pub(crate) use geometry::{corner_ratio, page_rotation, resized};
pub use geometry::{drawn_text_box_rect, text_box_rect};
#[cfg(test)]
use input::hover_tip;
pub(crate) use input::{describe, fill_grabs, hit, keys, nudge_keys, page_after_text, page_input};
pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving_on, nudge_keys, page_after_text, page_input};
pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid};
#[cfg(test)]
use menu::{RECENT_CUSTOM, same_colour};
@@ -20,10 +20,10 @@ pub(super) fn reshapable(a: &Annotation) -> bool {
&& !a.intent.as_deref().is_some_and(|i| i.ends_with("Dimension"))
}
/// Whether `tool` picks up a line's end and corner handles: the Select and Fill & Sign tools.
/// Whether `tool` reaches a line's end and corner handles: the Select and Fill & Sign tools.
/// The drawing tools draw from where they are pressed, so a new line starts at the end of the
/// one just drawn rather than dragging it.
pub(super) fn grabs_points(tool: QuickTool) -> bool {
pub(super) fn reaches_points(tool: QuickTool) -> bool {
!matches!(tool, QuickTool::Comment(t) if t.draws() || t.clicks_points())
}
+94 -73
View File
@@ -1,61 +1,69 @@
use super::geometry::{
CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, grabs_points, handle_pos, is_markup, point_handle, reshapable, reshaped, resizable,
CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, handle_pos, is_markup, point_handle, reaches_points, reshapable, reshaped, resizable,
resized, snapped_caret,
};
use super::paint::{drawn_shape, edit_anchor, erase, new_comment};
use super::state::{Composer, ComposerKind, Gesture, PageCx};
use super::{Annotation, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2};
use crate::page_cursor::{Layer, Subject};
use crate::page_router::{CommentPart, Hit, Owner};
use crate::page_router::{CommentPart, Hit, Owner, PagePress};
/// Comment input on one page, before text selection runs. Returns `true` when the pointer
/// gesture belongs to commenting (text selection must ignore it).
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) -> bool {
/// Comment input on one page, before text selection runs. `press` is what this page has to do with
/// the press the pages hold: a press on a comment where the tool reaches it ([`hit`]: the Select
/// tool, Fill & Sign over its marks, a shape tool over the selected shape's handles) is that
/// comment's, and one nobody holds is a commenting tool's. When another tool took this frame
/// (`taken`: Prepare, added content, which still decide for themselves), the commenting tools leave
/// it to that tool, as before; the Select tool's click there still unselects. Returns `true` when
/// the pointer gesture belongs to commenting (text selection must ignore it).
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, press: PagePress, taken: bool, view: &mut DocView) -> bool {
let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin);
let page_rect = cx.xf.rect;
let pressed_here = origin.is_some_and(|o| page_rect.contains(o));
let free = press == PagePress::Free;
let pressed_here = free && origin.is_some_and(|o| page_rect.contains(o));
let over_page = pointer.is_some_and(|p| page_rect.contains(p));
let fill_grab = fill_grabs(cx, view);
let draw_grab = draw_grabs(cx, view);
let owned = match press {
PagePress::Mine(Owner::Comment { index, part }) => Some((index, part)),
_ => None,
};
let caret_text = if cx.tool == QuickTool::Comment(CommentTool::Caret) { view.page_text(cx.page) } else { None };
let cv = &mut view.comments;
if cv.gesture.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
cv.gesture = None;
cv.cancelled_drag = true;
return true;
}
if cv.cancelled_drag {
return true;
}
if resp.secondary_clicked()
&& let Some(p) = pointer.filter(|p| page_rect.contains(*p))
{
cv.context_at = Some((cx.page, cx.to_user(p)));
cv.selected = cx.hit(p).map(|a| (cx.page, a.index));
}
let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page };
let moving = moving_on(view, cx.page);
let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, free };
match cx.tool {
QuickTool::Comment(_) if draw_grab => select_input(ui, resp, cx, view, pointer, origin),
// The selected shape's handles, and Fill & Sign's marks, take their own presses.
QuickTool::Comment(_) | QuickTool::Fill(_) if owned.is_some() || moving => select_input(ui, resp, cx, view, press, pointer, origin),
// Neither a press nor a double-click or a key another tool took this frame.
QuickTool::Comment(_) if taken => false,
QuickTool::Comment(tool) if tool.draws() => gestures.draw_shape(view, tool),
QuickTool::Comment(tool) if tool.clicks_points() => gestures.click_points(view, tool),
QuickTool::Comment(CommentTool::Callout) => gestures.callout(view),
QuickTool::Comment(CommentTool::Attach) => gestures.attach(view),
QuickTool::Comment(CommentTool::Caret) => gestures.caret(view, caret_text.as_deref()),
QuickTool::Comment(tool @ (CommentTool::Note | CommentTool::TextBox)) => gestures.note_or_box(view, tool),
QuickTool::Select => select_input(ui, resp, cx, view, pointer, origin),
QuickTool::Fill(_) if fill_grab => select_input(ui, resp, cx, view, pointer, origin),
QuickTool::Select => select_input(ui, resp, cx, view, press, pointer, origin),
_ => false,
}
}
/// A comment (a Fill & Sign mark included) is being moved, resized or reshaped on `page`: it keeps
/// the page's frames until its gesture ends.
pub(crate) fn moving_on(view: &DocView, page: usize) -> bool {
matches!(view.comments.gesture, Some(Gesture::Move { page: p, .. } | Gesture::Resize { page: p, .. } | Gesture::Reshape { page: p, .. }) if p == page)
}
/// What of the comments lies under `p`, for the cursor, the tip and the press: the one hit test
/// of this module. With the Select tool, the selected comment's point handles, then its resize
/// handles, then the topmost comment. With a Fill & Sign tool, the same over Fill & Sign's marks
/// 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;
}
@@ -73,7 +81,7 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2) -> Option<Hit> {
};
let bodies = matches!(cx.tool, QuickTool::Select | QuickTool::Fill(_));
if let Some(a) = view.comments.selected.filter(|(page, _)| *page == cx.page).and_then(|(_, i)| cx.get(i)).filter(|a| pick(a) && cx.allowed) {
if grabs_points(cx.tool)
if reaches_points(cx.tool)
&& reshapable(a)
&& let Some(point) = point_handle(cx, a, p)
{
@@ -91,48 +99,45 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2) -> Option<Hit> {
return Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Handle(x, y) }, Layer::Handle, icon));
}
}
let a = cx.hit(p).filter(|a| bodies && pick(a))?;
let a = cx.comment_at(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);
let icon = if cx.allowed && !markup { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand };
// What a drag moves whole (a note, a shape, a signature, a Fill & Sign mark) is placed on the
// page, over a form field; text markup lies on the text, under one.
let layer = if a.fill_sign || !markup { Layer::Placed } else { Layer::Comment };
// Text markup does not move: a click selects it, a drag across it selects the text.
Some(Hit { declines_drag: markup, ..Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, icon) })
Some(Hit { declines_drag: markup, ..Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer(a), icon) })
}
/// Whether the comments take the press on this page (or, with no button down, would take one
/// under the pointer), so the tools beneath leave it: a live move, resize or reshape, or a press
/// held by a comment that keeps its drag.
fn grabs(cx: &PageCx<'_>, view: &DocView) -> bool {
if matches!(view.comments.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page)
{
return true;
/// The layer a comment's body lies on: what a drag moves whole (a note, a shape, a signature, a
/// Fill & Sign mark) is placed on the page, over a form field; text markup lies on the text, under
/// one.
fn layer(a: &Annotation) -> Layer {
if a.fill_sign || !is_markup(&a.subtype) { Layer::Placed } else { Layer::Comment }
}
/// The comment a right-click at `p` is on: the topmost, with every tool, as a comment's menu shows
/// whatever tool is active. A highlight over a link is the highlight's here (a left click on it
/// follows the link, [`hit`]). `None` off the page or with comments hidden.
pub(crate) fn context_hit(cx: &PageCx<'_>, p: Pos2) -> Option<Hit> {
if cx.hidden || !cx.xf.rect.contains(p) {
return None;
}
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)
let a = cx.comment_at(p)?;
Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer(a), egui::CursorIcon::Default))
}
/// With a Fill & Sign tool, a Fill & Sign mark already on the page is picked up as with the Select
/// tool ([`hit`]), so the tool does not place a mark there.
pub(crate) fn fill_grabs(cx: &PageCx<'_>, view: &DocView) -> bool {
matches!(cx.tool, QuickTool::Fill(_)) && grabs(cx, view)
/// A right-click at `p` on this page: the page's menu is anchored there (Delete page, Paste, Copy
/// Image, a note added here).
pub(crate) fn anchor(cx: &PageCx<'_>, p: Pos2, view: &mut DocView) {
view.comments.context_at = Some((cx.page, cx.to_user(p)));
}
/// With a shape tool, the selected comment's handles are picked up ([`hit`]).
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))
/// A right-click on comment `index` of this page ([`context_hit`], when it wins there) selects it,
/// and one on anything else selects none.
pub(crate) fn context(cx: &PageCx<'_>, index: Option<usize>, view: &mut DocView) {
view.comments.selected = index.map(|i| (cx.page, i));
}
/// The tip for the comment under the pointer, with any tool, beside that tool's own cursor: Acrobat
@@ -170,11 +175,21 @@ pub(super) fn hover_tip(a: &Annotation, prefs: &CommentPrefs) -> Option<String>
Some(if body.is_empty() { who } else { format!("{who}\n{body}") })
}
/// The Select tool: select, move, resize, edit, delete, on the comment the press was on (its
/// owner, from [`hit`]). The cursor comes from [`hit`] too.
fn select_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView, pointer: Option<Pos2>, origin: Option<Pos2>) -> bool {
let owner = match view.router.owner(cx.page) {
Some(Owner::Comment { index, part }) => cx.get(index).map(|a| (a, part)),
/// The Select tool: select, move, resize, edit, delete, on the comment the press is on and the
/// part of it (`press`, from [`hit`]). The cursor comes from [`hit`] too. A click on this page on
/// anything else (a link, a field, added text, the page) leaves comments unselected; one that
/// began off the pages is no page's.
fn select_input(
ui: &egui::Ui,
resp: &egui::Response,
cx: &PageCx<'_>,
view: &mut DocView,
press: PagePress,
pointer: Option<Pos2>,
origin: Option<Pos2>,
) -> bool {
let owner = match press {
PagePress::Mine(Owner::Comment { index, part }) => cx.get(index).map(|a| (a, part)),
_ => None,
};
let cv = &mut view.comments;
@@ -252,15 +267,16 @@ fn select_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mu
// (egui clears the press origin on release, so clicks are located by the pointer.)
let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p));
if resp.clicked() && on_page {
// A click elsewhere (a link beneath text highlighting included: it follows the link)
// leaves comments unselected.
// A click elsewhere (a link beneath text highlighting included: it follows the link; a
// form field; added text) leaves comments unselected.
match owner {
Some((a, _)) => {
cv.selected = Some((cx.page, a.index));
cv.reveal = true;
consumed = true;
}
None => cv.selected = None,
None if press != PagePress::Elsewhere => cv.selected = None,
None => {}
}
}
if resp.double_clicked()
@@ -316,13 +332,21 @@ struct Gestures<'a> {
pointer: Option<Pos2>,
origin: Option<Pos2>,
page_rect: Rect,
/// The press began on this page, and nobody holds it: the tool's.
pressed_here: bool,
over_page: bool,
/// Nobody holds the press, and it began on this page, or no button is down.
free: bool,
}
impl Gestures<'_> {
/// The tool's own click: one nobody holds.
fn clicked(&self) -> bool {
self.free && self.resp.clicked()
}
fn draw_shape(&self, view: &mut DocView, tool: CommentTool) -> bool {
let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self;
let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, .. } = *self;
let cv = &mut view.comments;
if !cx.allowed {
return false;
@@ -370,7 +394,7 @@ impl Gestures<'_> {
// it as a one-point ink stroke (a dot). The appearance uses round line caps, so a
// single PDF point is visible and remains editable like any other drawing.
if matches!(tool, CommentTool::Ink | CommentTool::FreehandHighlight)
&& resp.clicked()
&& self.clicked()
&& over_page
&& let Some(p) = pointer
&& let Some(shape) = drawn_shape(tool, &[cx.to_user(p)])
@@ -402,7 +426,7 @@ impl Gestures<'_> {
cv.undo_point();
return true;
}
if resp.clicked()
if self.clicked()
&& over_page
&& let Some(p) = pointer
{
@@ -482,7 +506,7 @@ impl Gestures<'_> {
}
fn attach(&self, view: &mut DocView) -> bool {
let Self { resp, cx, pointer, over_page, .. } = *self;
let Self { cx, pointer, over_page, .. } = *self;
let cv = &mut view.comments;
if !cx.allowed {
return false;
@@ -490,7 +514,7 @@ impl Gestures<'_> {
if over_page {
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
}
if resp.clicked()
if self.clicked()
&& over_page
&& let Some(p) = pointer
{
@@ -501,7 +525,7 @@ impl Gestures<'_> {
}
fn caret(&self, view: &mut DocView, caret_text: Option<&pdfcraft_render::PageText>) -> bool {
let Self { resp, cx, pointer, over_page, .. } = *self;
let Self { cx, pointer, over_page, .. } = *self;
let cv = &mut view.comments;
if !cx.allowed {
return false;
@@ -509,7 +533,7 @@ impl Gestures<'_> {
if over_page {
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Text);
}
if resp.clicked()
if self.clicked()
&& over_page
&& let Some(p) = pointer
{
@@ -563,7 +587,7 @@ impl Gestures<'_> {
}
}
}
if resp.clicked()
if self.clicked()
&& over_page
&& let Some(p) = pointer
{
@@ -577,9 +601,6 @@ impl Gestures<'_> {
/// Delete / Escape handling for comments (only while no text field has focus).
pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool, allowed: bool) {
if !ctx.input(|i| i.pointer.any_down()) {
view.comments.cancelled_drag = false;
}
if ctx.egui_wants_keyboard_input() {
return;
}
+19 -11
View File
@@ -1,18 +1,30 @@
use super::geometry::{HANDLES, grabs_points, handle_pos, is_markup, reshapable, reshaped, resizable};
use super::state::{Gesture, PageCx, SETTLE_SECS};
use super::geometry::{HANDLES, handle_pos, is_markup, reaches_points, reshapable, reshaped, resizable};
use super::state::{CommentView, Gesture, PageCx, SETTLE_SECS};
use super::{
Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape,
Stroke, color32, vec2,
};
use crate::page_router::{CommentPart, Hit, Owner};
/// Paint comment selection, hover and gesture previews on a page.
pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) {
/// The comment outlined under the pointer: the one a press there would take (`hover`, the top hit),
/// with the Select tool or, over its own marks, Fill & Sign. Not the selected comment, nor while a
/// comment is moved, resized or drawn, nor one under a form field or added text that would take
/// the press.
pub(super) fn hover_outline<'c>(cx: &'c PageCx<'_>, cv: &CommentView, hover: Option<&Hit>) -> Option<&'c Annotation> {
let Some(Owner::Comment { index, part: CommentPart::Body }) = hover.map(|h| h.owner) else { return None };
let tool_outlines = matches!(cx.tool, QuickTool::Select | QuickTool::Fill(_));
(cv.gesture.is_none() && tool_outlines && cv.selected != Some((cx.page, index))).then(|| cx.get(index)).flatten()
}
/// Paint comment selection, hover and gesture previews on a page. `hover` is the top hit under the
/// pointer.
pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView, hover: Option<&Hit>) {
let cv = &view.comments;
let pointer = cx.ptr.pos;
// A redaction mark shows its fill while the pointer is over it, as Acrobat's does, so the
// marked area reads before anything is applied.
if cv.gesture.is_none()
&& let Some(a) = pointer.and_then(|p| cx.hit(p))
&& let Some(a) = pointer.and_then(|p| cx.comment_at(p))
&& a.subtype == "Redact"
{
let ([r, g, b], opacity) = cx.redact_mark;
@@ -21,11 +33,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>
painter.rect_filled(rect, CornerRadius::ZERO, fill);
}
}
if cv.gesture.is_none()
&& let Some(a) = pointer.and_then(|p| cx.hit(p))
&& (cx.tool == QuickTool::Select || matches!(cx.tool, QuickTool::Fill(_)) && a.fill_sign)
&& cv.selected != Some((cx.page, a.index))
{
if let Some(a) = hover_outline(cx, cv, hover) {
for r in cx.screen_rects(a) {
painter.rect_stroke(r.expand(2.0), CornerRadius::same(2), Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7)), egui::StrokeKind::Outside);
}
@@ -45,7 +53,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>
return paint_gesture(painter, cx, view);
}
let shift = ui.input(|i| i.modifiers.shift);
if cx.allowed && grabs_points(cx.tool) && reshapable(a) {
if cx.allowed && reaches_points(cx.tool) && reshapable(a) {
return paint_points(painter, cx, view, a, pointer, shift);
}
let r = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, view.pending_edit.as_ref()).expand(2.0);
+1 -3
View File
@@ -133,8 +133,6 @@ pub struct CommentView {
/// Drawings just released: shown from their points until the page raster that includes the
/// committed annotation arrives, so the stroke never vanishes in between (#1858).
pub(crate) settling: Vec<Settling>,
/// Escape ends egui's drag too; ignore its synthetic release until the mouse is up.
pub(super) cancelled_drag: bool,
pub composer: Option<Composer>,
/// Reply being typed under the selected card.
pub reply: String,
@@ -386,7 +384,7 @@ impl PageCx<'_> {
/// The topmost comment under `p`: the one drawn last, in `/Annots` order (inspection lists
/// comments by position, not in paint order).
pub(super) fn hit(&self, p: Pos2) -> Option<&Annotation> {
pub(super) fn comment_at(&self, p: Pos2) -> Option<&Annotation> {
self.comments().filter(|a| self.screen_rects(a).any(|r| r.expand(3.0).contains(p))).max_by_key(|a| a.index)
}
@@ -499,3 +499,39 @@ fn text_boxes_fit_their_text() {
assert_eq!(long[2], 300.0);
assert!(long[3] - long[1] > 50.0);
}
/// The hover outline goes round the comment a press under the pointer would take (the top hit
/// there): not a highlight under a form field, which takes the press.
#[test]
fn the_hover_outline_goes_round_what_a_press_would_take() {
use super::paint::hover_outline;
use crate::page_cursor::Layer;
use crate::page_router::{CommentPart, Hit, Owner};
let page = PageInfo { width: 200.0, height: 200.0, label: String::new(), crop: [0.0, 0.0, 200.0, 200.0], rotation: 0 };
let xf = PageXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), rot: 0, pw: 200.0, ph: 200.0 };
let highlight = Annotation { rect: [50.0, 50.0, 150.0, 150.0], ..annotation("Highlight", None, false) };
let info = DocInfo { pages: vec![page], annotations: vec![highlight], ..Default::default() };
let prefs = CommentPrefs::default();
// The pointer is over the highlight.
let ptr = PagePointer { pos: Some(pos2(100.0, 100.0)), ..PagePointer::default() };
let cx = PageCx {
page: 0,
xf: &xf,
ptr: &ptr,
info: &info,
tool: QuickTool::Select,
prefs: &prefs,
allowed: true,
hidden: false,
redact_mark: ([0.0; 3], 1.0),
};
let on = |owner, layer| Hit::new(owner, layer, egui::CursorIcon::PointingHand);
let comment = on(Owner::Comment { index: 0, part: CommentPart::Body }, Layer::Comment);
let field = on(Owner::Field { field: 0, widget: 0 }, Layer::Field);
let cv = CommentView::default();
assert!(hover_outline(&cx, &cv, Some(&comment)).is_some(), "the highlight a press there selects");
assert!(hover_outline(&cx, &cv, Some(&field)).is_none(), "not under a field that takes the press");
assert!(hover_outline(&cx, &cv, None).is_none(), "nor where nothing would take it");
let selected = CommentView { selected: Some((0, 0)), ..CommentView::default() };
assert!(hover_outline(&cx, &selected, Some(&comment)).is_none(), "the selected comment has its own frame");
}
+1 -1
View File
@@ -23,7 +23,7 @@ mod layout;
pub(crate) use background::TextBackground;
pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection, image_menu_id};
pub(crate) use image::{
ImageCx, Matrix, body_hit as picture_hit, crop_controls_contain, handle_hit as picture_handle_hit, image_context_menu, image_input,
ImageCx, Matrix, body_hit as picture_hit, crop_controls_contain, crop_hit, handle_hit as picture_handle_hit, image_context_menu, image_input,
quad_contains, user_to_screen,
};
#[cfg(test)]
@@ -541,6 +541,14 @@ pub(crate) fn handle_hit(
Some(Hit::new(picture(PicturePart::Corner(grip)), Layer::Handle, obb.handle_cursor(handle)))
}
/// The picture being cropped on `page`: crop mode owns every press there, the controls beside its
/// frame included, so the paragraphs under it and the empty page's marquee never take a crop
/// button's press before the picture does. `None` when no picture on `page` is being cropped.
pub(crate) fn crop_hit(view: &DocView, page: usize) -> Option<Hit> {
let selection = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_some())?;
Some(Hit::new(Owner::Picture { index: selection.index, part: PicturePart::Body }, Layer::Handle, egui::CursorIcon::Default))
}
/// The picture under `p` (its shape, not only its box): a click selects it, a drag moves the
/// selected one. On the text layer, asked before the paragraphs: editable text on top of a
/// picture takes the press.
+3 -2
View File
@@ -455,7 +455,7 @@ pub(crate) struct PageInput<'a> {
}
/// Preview and clicks with a Fill & Sign tool on one page.
pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, view: &mut DocView) -> Option<FillAction> {
pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bool, view: &mut DocView) -> Option<FillAction> {
let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx;
let PageInput { tool, text_size, text_color, signature, initials, preview, author, date_text } = input;
let pointer = ptr.within(xf.rect)?;
@@ -475,7 +475,8 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, view: &m
} else {
view.cursor.claim(Layer::ToolArea, if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair });
}
if !resp.clicked() {
// The tool's own click: one nobody holds.
if !(free && resp.clicked()) {
return None;
}
// Typed text starts a little above the click, as displayed.
+31 -16
View File
@@ -15,7 +15,7 @@ use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::page_router::{Hit, Owner};
use crate::page_router::{Hit, Owner, PagePress};
use crate::theme::Tokens;
const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
@@ -182,19 +182,30 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, form: &[FormField
Some(Hit::new(Owner::Field { field, widget }, Layer::Field, icon))
}
/// Presses and clicks on form widgets of one page, on the field the press was on ([`hit`]).
/// `true` while a field holds the press.
pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: bool, view: &mut DocView) -> bool {
let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx;
// The field's tooltip (`/TU`) under the pointer, as Acrobat shows it on hover (#3854). ISO
// 32000-2 Table 226 makes it the name the user interface identifies the field by.
if let Some(p) = ptr.within(xf.rect)
&& let Some(Owner::Field { field, .. }) = hit(xf, info, page, form, allowed, p).map(|h| h.owner)
/// The tooltip (`/TU`) of the field under the pointer, as Acrobat shows it on hover (#3854): only
/// where the field is what a press would take (`hover`, the top hit there). ISO 32000-2 Table 226
/// makes it the name the user interface identifies the field by.
pub(crate) fn tooltip(resp: &egui::Response, form: &[FormField], hover: Option<&Hit>) {
if let Some(Owner::Field { field, .. }) = hover.map(|h| h.owner)
&& let Some(tip) = form.get(field).and_then(|f| f.tooltip.as_deref()).filter(|t| !t.trim().is_empty())
{
resp.clone().on_hover_text_at_pointer(tip);
}
let Some(Owner::Field { field, widget: wi }) = view.router.owner(page) else { return false };
}
/// Presses and clicks on form widgets of one page, on the field the press is on (`press`, from
/// [`hit`]). `released_over`: the press's click came up over the widget it began on. `true` while a
/// field holds the press.
pub(crate) fn page_input(
cx: PageToolContext<'_>,
form: &[FormField],
allowed: bool,
press: PagePress,
released_over: bool,
view: &mut DocView,
) -> bool {
let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx;
let PagePress::Mine(Owner::Field { field, widget: wi }) = press else { return false };
let Some((f, w)) = form.get(field).and_then(|f| f.widgets.get(wi).map(|w| (f, w))) else { return false };
let usable = usable(f, w, allowed);
// A text field opens on the press rather than the click, so a drag that starts on it
@@ -210,8 +221,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: b
}
// A click counts released over the widget its press was on, as a button's does: one that slips
// off it first does nothing (a check box's overlay closes on it too).
if !resp.clicked() || !ptr.pos.and_then(|p| hit(xf, info, page, form, allowed, p)).is_some_and(|h| h.owner == Owner::Field { field, widget: wi })
{
if !resp.clicked() || !released_over {
return true;
}
if !allowed {
@@ -489,7 +499,8 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st
picked
}
/// Paint focus and hover frames on a page, the pages' `pointer` over it.
/// Paint focus and hover frames on a page: the hover frame round the widget a press under the
/// pointer would take (`hover`, the top hit there).
pub(crate) fn paint_page(
painter: &egui::Painter,
xf: &PageXform,
@@ -497,9 +508,13 @@ pub(crate) fn paint_page(
info: &DocInfo,
form: &[FormField],
view: &DocView,
pointer: Option<Pos2>,
hover: Option<&Hit>,
) {
for f in form {
let hovered = match hover.map(|h| h.owner) {
Some(Owner::Field { field, widget }) => Some((field, widget)),
_ => None,
};
for (fi, f) in form.iter().enumerate() {
for (wi, w) in f.widgets.iter().enumerate().filter(|(_, w)| w.page == Some(page) && !w.hidden) {
let r = widget_rect(xf, info, page, w.rect);
let focused = view.forms.focus.as_ref().is_some_and(|x| x.name == f.name && x.widget == wi);
@@ -508,7 +523,7 @@ pub(crate) fn paint_page(
}
if view.forms.unbaked.contains(&(f.name.clone(), page)) {
paint_unbaked_widget(painter, xf, f, w, r);
} else if fillable(f) && pointer.is_some_and(|p| r.contains(p)) {
} else if fillable(f) && hovered == Some((fi, wi)) {
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside);
}
}
+68 -26
View File
@@ -4,14 +4,17 @@
use egui::{Color32, CornerRadius, Pos2, Rect, Stroke};
use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkItem, LinkStyle};
use pdfcraft_render::DocInfo;
use pdfcraft_render::{DocInfo, LinkTarget};
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::page_router::{Hit, Owner};
use crate::theme::{self, Tokens};
use crate::{PdfCraftApp, widgets};
use crate::{PdfCraftApp, QuickTool, widgets};
const LINK_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
/// How far around a link (screen points) the Link tool finds it, so a thin one can be picked.
const LINK_TOOL_MARGIN: f32 = 2.0;
#[derive(Clone, Debug, Default, PartialEq)]
pub struct LinkView {
@@ -46,7 +49,7 @@ pub struct LinkDraft {
pub style: LinkStyle,
}
pub(crate) fn screen_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect {
fn screen_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect {
xf.user_rect(info, page, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32])
}
@@ -56,8 +59,44 @@ 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> {
// A press off the page is no page's, so nothing there is a link's, the margin included.
if !xf.rect.contains(p) {
return None;
}
let (index, link) = if tool == QuickTool::Link {
let index = links.iter().rfind(|l| l.page == page && screen_rect(xf, info, page, l.rect).expand(LINK_TOOL_MARGIN).contains(p))?.index;
(index, info.links.iter().find(|l| l.page == page && l.index == index))
} else {
let link = info.links.iter().rfind(|l| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?;
(link.index, Some(link))
};
let tip = link.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: `free` when nobody holds the press and it began on this page,
/// `owner` the link that holds it (by its `/Annots` index), and `context` the link a right-click is
/// on. A drag from either draws a new link. Returns `true` when the gesture belongs to the tool.
pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, 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;
@@ -75,36 +114,39 @@ 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
// A drag that nobody holds, or from a link (whose hit lets the drag go), draws a new link from
// where its press began on this page. egui may see the drag only once the pointer is past the
// page's edge.
if resp.drag_started()
&& (free || owner.is_some())
&& let Some(origin) = ptr.origin.filter(|o| xf.rect.contains(*o))
{
lv.selected = Some((page, l.index));
lv.context = Some((page, l.index));
lv.drag = Some((page, origin));
return true;
}
if resp.clicked() || resp.double_clicked() {
if !pointer.is_some_and(|p| xf.rect.contains(p)) {
return false;
}
// 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;
}
// The tool's own click: on a link, or one nobody holds.
if (resp.clicked() || resp.double_clicked()) && (free || owner.is_some()) {
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;
}
if resp.drag_started() {
let origin = ptr.origin.unwrap_or(p);
lv.drag = Some((page, origin));
return true;
}
resp.is_pointer_button_down_on()
}
+17 -3
View File
@@ -202,9 +202,23 @@ fn snap_to_content(
None
}
/// The Measure tools' own clicks this frame: those of a press nobody holds.
#[derive(Clone, Copy)]
pub(crate) struct Clicks {
pub single: bool,
pub double: bool,
}
impl Clicks {
/// `resp`'s clicks, when the press is the tool's (`free`).
pub(crate) fn of(resp: &egui::Response, free: bool) -> Self {
Clicks { single: free && resp.clicked(), double: free && resp.double_clicked() }
}
}
pub(crate) fn page_input(
ui: &egui::Ui,
resp: &egui::Response,
clicks: Clicks,
doc: &Document,
view: &mut DocView,
cx: &crate::comments::PageCx<'_>,
@@ -254,7 +268,7 @@ pub(crate) fn page_input(
point = measure::snap::constrain(*last, point);
}
hover = Some(point);
if resp.clicked() {
if clicks.single {
if state.page != Some(page) {
state.cancel();
state.page = Some(page);
@@ -284,7 +298,7 @@ pub(crate) fn page_input(
}
}
if state.page == Some(page) && !state.points.is_empty() {
if (resp.double_clicked() || !typing && ui.input(|i| i.key_pressed(egui::Key::Enter))) && matches!(tool, Tool::Perimeter | Tool::Area) {
if (clicks.double || !typing && ui.input(|i| i.key_pressed(egui::Key::Enter))) && matches!(tool, Tool::Perimeter | Tool::Area) {
finish(state, &mut view.pending_edit, doc, page, tool, author, units);
}
if !typing && ui.input(|i| i.key_pressed(egui::Key::Backspace)) {
+75 -63
View File
@@ -13,7 +13,7 @@ use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::comments;
use crate::page_cursor::Layer;
use crate::page_router::{Hit, Owner};
use crate::page_router::{Hit, Owner, PagePress};
use crate::theme;
use crate::widgets;
@@ -285,8 +285,17 @@ pub(crate) fn hit(
Some(Hit::new(Owner::PrepareField { field, widget }, Layer::Placed, egui::CursorIcon::Move))
}
/// Pointer input on one page while preparing a form.
pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Option<FieldTool>, allowed: bool, view: &mut DocView) -> Outcome {
/// Pointer input on one page while preparing a form: `press` says whether the press is a field's
/// or a corner's ([`hit`]), nobody's (a field tool draws, the Select tool draws a selection
/// rectangle), or another page's.
pub(crate) fn page_input(
cx: PageToolContext<'_>,
form: &[FormField],
tool: Option<FieldTool>,
allowed: bool,
press: PagePress,
view: &mut DocView,
) -> Outcome {
let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx;
let mut out = Outcome { consumed: false, properties: false, placed: false, table: None };
let pointer = ptr.pos;
@@ -369,71 +378,30 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti
return out;
}
let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return out };
if !allowed {
return out;
}
if let Some(tool) = tool {
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
if resp.drag_started() || resp.clicked() {
let origin = ptr.origin.filter(|o| xf.rect.contains(*o)).unwrap_or(p);
prep.grab = Some((page, Grab::Draw(origin), origin));
out.consumed = true;
if resp.clicked() {
// A click without a drag: finish at once.
prep.grab = None;
if tool == FieldTool::TextTable {
out.table = Some((page, table_rect(xf, Rect::from_min_size(p, egui::Vec2::ZERO))));
out.placed = true;
return out;
}
let at = to_user(xf, info, page, p);
let (w, h) = tool.default_size();
let join = prep.selected.as_ref().and_then(|(n, _)| form.iter().find(|f| &f.name == n));
prep.select_added = Some(form.len());
view.pending_edit = Some(Edit::AddField { page, rect: [at[0], at[1] - h, at[0] + w, at[1]], kind: tool.new_field(join), name: None });
out.placed = true;
}
}
return out;
}
// Right-click a field: select it (the context menu acts on it).
if resp.secondary_clicked()
&& let Some((f, wi)) = form.iter().find_map(|f| {
f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && screen_rect(xf, info, page, w.rect).contains(p)).map(|(i, _)| (f, i))
})
{
let key = (f.name.clone(), wi);
if let Some(i) = prep.also.iter().position(|k| *k == key) {
// Part of the selection: it becomes the anchor the others line up with.
prep.also.remove(i);
if let Some(old) = prep.selected.replace(key) {
prep.also.push(old);
}
} else if prep.selected.as_ref() != Some(&key) {
prep.selected = Some(key);
prep.also.clear();
}
}
// The Select tool: what the press was on ([`hit`]), a corner handle or a field; elsewhere a
// drag draws a selection rectangle. A press held by something drawn over the fields (a
// comment) is not Prepare's.
let owner = view.router.owner(page);
let held = matches!(owner, Some(Owner::PrepareField { .. } | Owner::PrepareCorner { .. }));
let hit = match owner {
Some(Owner::PrepareField { field, widget }) => form.get(field).map(|f| (f, widget)),
// The Select tool: what the press is on ([`hit`]), a corner handle or a field; a press nobody
// holds draws a selection rectangle, or a field with a field tool. A press held by something
// drawn over the fields (a comment) is not Prepare's.
let free = press == PagePress::Free;
let held = matches!(press, PagePress::Mine(Owner::PrepareField { .. } | Owner::PrepareCorner { .. }));
let hit = match press {
PagePress::Mine(Owner::PrepareField { field, widget }) => form.get(field).map(|f| (f, widget)),
_ => None,
};
if resp.drag_started() && (held || owner.is_none()) {
let origin = ptr.origin.unwrap_or(p);
if let Some(Owner::PrepareCorner { left, top }) = owner {
// A drag starts from where its press began on this page: egui may see it only once the pointer
// is past the page's edge.
if allowed
&& resp.drag_started()
&& (held || free)
&& let Some(origin) = ptr.origin.filter(|o| xf.rect.contains(*o))
{
out.consumed = true;
if tool.is_some() {
prep.grab = Some((page, Grab::Draw(origin), origin));
} else if let PagePress::Mine(Owner::PrepareCorner { left, top }) = press {
prep.grab = Some((page, Grab::Corner(left, top), origin));
out.consumed = true;
} else if let Some((f, wi)) = hit {
prep.selected = Some((f.name.clone(), wi));
prep.grab = Some((page, Grab::Move, origin));
out.consumed = true;
} else {
prep.marquee_before = Some(prep.selected.iter().chain(prep.also.iter()).cloned().collect());
if !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
@@ -441,7 +409,29 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti
prep.also.clear();
}
prep.grab = Some((page, Grab::Select(origin), origin));
}
return out;
}
let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return out };
if !allowed {
return out;
}
if let Some(tool) = tool {
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
// A click without a drag places the field at once.
if free && resp.clicked() {
out.consumed = true;
if tool == FieldTool::TextTable {
out.table = Some((page, table_rect(xf, Rect::from_min_size(p, egui::Vec2::ZERO))));
out.placed = true;
return out;
}
let at = to_user(xf, info, page, p);
let (w, h) = tool.default_size();
let join = prep.selected.as_ref().and_then(|(n, _)| form.iter().find(|f| &f.name == n));
prep.select_added = Some(form.len());
view.pending_edit = Some(Edit::AddField { page, rect: [at[0], at[1] - h, at[0] + w, at[1]], kind: tool.new_field(join), name: None });
out.placed = true;
}
return out;
}
@@ -450,7 +440,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti
out.properties = true;
return out;
}
if resp.clicked() && (held || owner.is_none()) {
if resp.clicked() && (held || free) {
let extend = ui.input(|i| i.modifiers.shift || i.modifiers.command);
match hit {
// Shift/⌘-click adds or removes a field from the selection.
@@ -469,6 +459,28 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti
out
}
/// A right-click on widget `widget` of field `field` (the top hit there, [`hit`]): the field is
/// selected, so the menu acts on it. Part of a selection of several, it becomes the anchor the
/// others line up with. Not while a field is dragged or a rectangle drawn: the release acts on
/// the selection the drag began with.
pub(crate) fn context(form: &[FormField], field: usize, widget: usize, view: &mut DocView) {
let Some(f) = form.get(field) else { return };
let key = (f.name.clone(), widget);
let prep = &mut view.prepare;
if prep.grab.is_some() || prep.ignore_release {
return;
}
if let Some(i) = prep.also.iter().position(|k| *k == key) {
prep.also.remove(i);
if let Some(old) = prep.selected.replace(key) {
prep.also.push(old);
}
} else if prep.selected.as_ref() != Some(&key) {
prep.selected = Some(key);
prep.also.clear();
}
}
/// Align, Center, Distribute and Set Fields to Same Size (Prepare a form, several fields
/// selected).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+13 -13
View File
@@ -233,9 +233,9 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] {
}
/// Redact tool input on a page. Presses on text are left to text selection (the selection is
/// marked when it ends, see [`after_text`]); presses elsewhere draw a box. Returns `true` when
/// the gesture is a box.
pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bool, view: &mut DocView) -> bool {
/// marked when it ends, see [`after_text`]); presses nobody holds (`free`) elsewhere draw a box.
/// Returns `true` when the gesture is a box.
pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bool, free: bool, view: &mut DocView) -> bool {
let PageToolContext { resp, xf, ptr, page, info, .. } = cx;
let pointer = ptr.pos;
if let Some((dp, start)) = view.redact_drag
@@ -252,17 +252,17 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bo
}
return true;
}
let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false };
if !over_text(p) {
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
// The tool's own press, of the primary button: one nobody holds, which began on this page off
// its text. egui may see the drag only once the pointer is past the page's edge.
if resp.drag_started_by(egui::PointerButton::Primary)
&& free
&& let Some(origin) = ptr.origin.filter(|o| xf.rect.contains(*o) && !over_text(*o))
{
view.redact_drag = Some((page, origin));
return true;
}
// The tool's own press, of the primary button: nothing drawn over the page here holds it.
if resp.drag_started_by(egui::PointerButton::Primary) && view.router.owner(page).is_none() {
let origin = ptr.origin.unwrap_or(p);
if !over_text(origin) {
view.redact_drag = Some((page, origin));
return true;
}
if pointer.is_some_and(|p| xf.rect.contains(p) && !over_text(p)) {
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
}
false
}
+7 -14
View File
@@ -8,10 +8,9 @@ use egui::{Align, Color32, CornerRadius, Layout, Pos2, Rect, Stroke, vec2};
use pdfcraft_engine::sign::{self, Appearance, AppearanceLabels, Certificate, DigitalId, Name, PrivateKey};
use pdfcraft_engine::{SignOptions, SignatureImage, SignatureInfo, SignatureStatus};
use crate::canvas::{DocView, PageXform, Snapshot, SnapshotKind};
use crate::canvas::{DocView, PageToolContext, Snapshot, SnapshotKind};
use crate::fill_sign::SavedSig;
use crate::page_cursor::Layer;
use crate::page_pointer::PagePointer;
use crate::theme::{self, Tokens};
use crate::{PdfCraftApp, icons, widgets};
@@ -162,23 +161,17 @@ pub struct SignView {
pub field: Option<String>,
}
/// Drag a signature rectangle on one page (Digitally sign, Certify (visible signature)).
pub(crate) fn page_input(
ui: &egui::Ui,
resp: &egui::Response,
xf: &PageXform,
ptr: &PagePointer,
page: usize,
info: &pdfcraft_render::DocInfo,
view: &mut DocView,
) {
/// Drag a signature rectangle on one page (Digitally sign, Certify (visible signature)), from a
/// press nobody holds (`free`).
pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, view: &mut DocView) {
let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx;
let (pointer, origin) = (ptr.pos, ptr.origin);
if pointer.is_some_and(|p| xf.rect.contains(p)) {
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
}
// The tool's own press, of the primary button: nothing drawn over the page here holds it.
// The tool's own press, of the primary button: one nobody holds.
if resp.drag_started_by(egui::PointerButton::Primary)
&& view.router.owner(page).is_none()
&& free
&& let Some(o) = origin.filter(|o| xf.rect.contains(*o))
{
view.sign.drag = Some((page, o));
+50
View File
@@ -6,6 +6,8 @@ use std::sync::Arc;
use egui::{Align, Layout};
use serde::{Deserialize, Serialize};
use crate::canvas::{DocView, PageToolContext};
use crate::page_cursor::Layer;
use crate::theme::{self, Tokens};
use crate::{Dialog, PdfCraftApp, QuickTool, widgets};
@@ -41,6 +43,54 @@ mod b64 {
}
}
/// The Stamp tool on one page: a click that nobody holds (`free`) places stamp `kind` centred on
/// it, upright as the page is shown. `true` once one is placed.
pub(crate) fn place(cx: PageToolContext<'_>, kind: pdfcraft_engine::StampKind, by_line: &str, author: &str, free: bool, view: &mut DocView) -> bool {
let PageToolContext { resp, xf, ptr, page, info, .. } = cx;
let (Some(p), Some(page_info)) = (ptr.within(xf.rect), info.pages.get(page)) else { return false };
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
if !(free && resp.clicked()) {
return false;
}
let (vx, vy) = xf.screen_to_view(p);
let (w, h) = kind.size();
let a = page_info.view_to_user((f64::from(vx) - w / 2.0) as f32, (f64::from(vy) - h / 2.0) as f32);
let b = page_info.view_to_user((f64::from(vx) + w / 2.0) as f32, (f64::from(vy) + h / 2.0) as f32);
let rect = [f64::from(a[0].min(b[0])), f64::from(a[1].min(b[1])), f64::from(a[0].max(b[0])), f64::from(a[1].max(b[1]))];
let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.to_owned());
let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by };
view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
page,
style: pdfcraft_engine::Style::default_for(&shape),
shape,
contents: String::new(),
author: author.to_owned(),
}));
true
}
/// A custom stamp on one page: a click that nobody holds (`free`) places `stamp` centred on it, at
/// its natural size (the engine sizes it). `true` once one is placed.
pub(crate) fn place_custom(cx: PageToolContext<'_>, stamp: &CustomStamp, author: &str, free: bool, view: &mut DocView) -> bool {
let PageToolContext { resp, xf, ptr, page, info, .. } = cx;
let (Some(p), Some(page_info)) = (ptr.within(xf.rect), info.pages.get(page)) else { return false };
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
if !(free && resp.clicked()) {
return false;
}
let (vx, vy) = xf.screen_to_view(p);
let u = page_info.view_to_user(vx, vy);
let (x, y) = (f64::from(u[0]), f64::from(u[1]));
view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp {
page,
rect: [x, y, x, y],
name: stamp.name.clone(),
file: pdfcraft_engine::MarkFile { name: stamp.file.clone(), bytes: stamp.data.clone(), page: stamp.page },
author: author.to_owned(),
});
true
}
/// The Create Custom Stamp dialog: the chosen file, its category and name.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct StampDraft {
+862 -1
View File
@@ -11,7 +11,7 @@ use egui_kittest::kittest::Queryable;
use pdfcraft_engine::StampKind;
use pdfcraft_ui_egui::comments::CommentTool;
use pdfcraft_ui_egui::fill_sign::FillTool;
use pdfcraft_ui_egui::{PdfCraftApp, QuickTool, UnlockPrompt};
use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool, UnlockPrompt};
use crate::support::input::{coalesced_click, double_click, one_frame, touch_drag, touch_tap};
@@ -957,3 +957,864 @@ 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");
}
/// [`link_pdf`], with a highlight drawn over the middle of its link.
fn highlight_over_link_pdf() -> Vec<u8> {
pdf(&[
CATALOG,
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
"<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>",
"<< /Type /Annot /Subtype /Highlight /Rect [100 310 200 340] /QuadPoints [100 340 200 340 100 310 200 310] /C [1 1 0] /F 4 /T (Ana) >>",
])
}
/// A click on a highlight over a link follows the link, but a right-click there is the
/// highlight's: it is selected and its menu opens. The link's menu used to open, with the
/// highlight selected underneath.
#[test]
fn a_right_click_on_a_highlight_over_a_link_opens_the_highlights_menu() {
let mut h = open(highlight_over_link_pdf(), "150", false);
let on_both = at(&h, 150.0, 325.0);
right_click(&mut h, on_both);
let v = &h.state().views[0];
assert_eq!((v.links.context, v.comments.selected), (None, Some((0, 1))), "the highlight is selected, not the link");
assert!(h.query_by_label("Remove all links").is_none(), "the menu is the highlight's");
h.key_press(egui::Key::Escape);
h.run_steps(3);
mouse_click(&mut h, on_both);
assert!(h.state().pending_link.is_some(), "a click there still follows the link");
}
/// A comment's menu opens on a right-click whatever the tool, though a press there is the tool's:
/// with the Rectangle tool, a right-click on a comment selects it.
#[test]
fn a_right_click_on_a_comment_with_a_drawing_tool_opens_its_menu() {
let mut h = open(comment_on_text_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle);
h.run_steps(4);
let on_comment = at(&h, 100.0, 305.0);
right_click(&mut h, on_comment);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu");
assert!(h.query_all_by_label("Reply").next().is_some(), "and its menu is open");
}
/// The check box `agree` of [`checkbox_pdf`], with a rectangle comment at x 150..250, y 100..150.
fn comment_and_checkbox_pdf() -> Vec<u8> {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/ZaDb 0 Tf 0 g) /DR << /Font << /ZaDb 5 0 R >> >> >> >>",
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>",
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [100 200 120 220] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>",
"<< /Type /Annot /Subtype /Square /Rect [150 100 250 150] /C [1 0 0] /F 4 /T (Ana) >>",
])
}
/// A click on anything but the selected comment leaves it unselected, a form field included, as
/// a click on a link or on the page does. A field's click used to keep it selected.
#[test]
fn a_click_on_a_form_field_deselects_the_selected_comment() {
let mut h = open(comment_and_checkbox_pdf(), "150", false);
let comment = at(&h, 200.0, 125.0);
mouse_click(&mut h, comment);
assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the comment is selected");
let check_box = at(&h, 110.0, 210.0);
mouse_click(&mut h, check_box);
assert_eq!(h.state().views[0].comments.selected, None, "a click on the check box leaves it unselected");
assert_eq!(agree(&h).len(), 1, "and ticks the box");
}
/// The camera tool's quadrilateral, corner by corner, in page view points.
fn camera_corners(h: &Harness<'_, PdfCraftApp>) -> Option<[[f32; 2]; 4]> {
h.state().views[0].camera.map(|q| q.corners)
}
/// A corner of the camera tool's quadrilateral is a press of its own: Escape in the middle of its
/// drag cancels it, and once the button is up no corner follows the pointer. Today Escape also
/// leaves the camera tool before the pages see it; this holds whatever the order of the keys.
#[test]
fn escape_mid_corner_drag_leaves_no_corner_following_the_pointer() {
let mut h = open(text_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::EnhanceCamera;
h.run_steps(4);
// The left top corner starts 5% in from the page's edges: view (15, 20) on the 300×400 page.
let corner = at(&h, 15.0, 380.0);
let start = camera_corners(&h);
h.hover_at(corner);
h.run_steps(1);
h.drag_at(corner);
h.run_steps(1);
h.hover_at(corner + egui::vec2(30.0, 30.0));
h.run_steps(2);
assert_ne!(camera_corners(&h), start, "the corner follows its drag");
h.key_press(egui::Key::Escape);
h.run_steps(1);
let end = corner + egui::vec2(60.0, 60.0);
h.hover_at(end);
h.run_steps(1);
h.drop_at(end);
h.run_steps(2);
let after = camera_corners(&h);
h.hover_at(corner + egui::vec2(120.0, 90.0));
h.run_steps(3);
assert_eq!(camera_corners(&h), after, "no corner follows the pointer once the press is cancelled");
assert!(h.state().views[0].camera.is_none_or(|q| q.grab.is_none()), "and none is held");
}
/// A 300×400 page with a text field `name` (tooltip "Your name") at x 50..250, y 200..220, and a
/// rectangle comment drawn over its right end, x 150..260, y 190..230.
fn commented_text_field_pdf() -> Vec<u8> {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) >> >>",
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /TU (Your name) /Rect [50 200 250 220] /P 3 0 R /F 4 /DA (/Helv 12 Tf 0 g) >>",
"<< /Type /Annot /Subtype /Square /Rect [150 190 260 230] /C [1 0 0] /F 4 /T (Ana) >>",
])
}
/// Rests the pointer at `p` long enough for a tooltip.
fn rest_at(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) {
h.hover_at(p);
for _ in 0..40 {
h.step();
std::thread::sleep(std::time::Duration::from_millis(15));
}
}
/// A field's tooltip (`/TU`) shows only where a press would take the field: not under a comment
/// drawn over it. It used to show over the whole field.
#[test]
fn a_fields_tooltip_shows_only_where_the_field_takes_the_press() {
let mut h = open(commented_text_field_pdf(), "150", false);
let under_comment = at(&h, 200.0, 210.0);
rest_at(&mut h, under_comment);
assert!(h.query_by_label("Your name").is_none(), "no field tooltip where the comment takes the press");
let on_field = at(&h, 100.0, 210.0);
rest_at(&mut h, on_field);
assert!(h.query_all_by_label("Your name").next().is_some(), "the field's tooltip where it takes the press");
}
/// With the Link tool a right-click on a link is the link's, as its press there is, though a note
/// lies over it: the Link tool edits links and reaches no comment.
#[test]
fn a_right_click_with_the_link_tool_on_a_note_over_a_link_selects_the_link() {
let mut h = open(note_over_link_pdf(), "150", false);
link_tool(&mut h);
let on_both = at(&h, 150.0, 325.0);
right_click(&mut h, on_both);
let v = &h.state().views[0];
assert_eq!((v.links.selected, v.links.context, v.comments.selected), (Some((0, 0)), Some((0, 0)), None), "the link, not the note");
}
/// With Add text a right-click leaves comments alone, as before: Add text takes the page's
/// right-clicks.
#[test]
fn a_right_click_with_add_text_selects_no_comment() {
let mut h = open(comment_on_text_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::AddText;
h.run_steps(4);
// Above the line of text the comment covers, where no paragraph is.
let on_comment = at(&h, 100.0, 317.0);
right_click(&mut h, on_comment);
assert_eq!(h.state().views[0].comments.selected, None, "the comment under the pointer is not selected");
}
/// With the Select tool a right-click finds a link only where a click follows it: a link that
/// does nothing has no menu. The Link tool's own test used to give it one.
#[test]
fn a_right_click_with_the_select_tool_on_a_link_with_no_action_opens_no_link_menu() {
let mut h = open(dead_link_pdf(), "150", false);
let dead = at(&h, 25.0, 325.0);
right_click(&mut h, dead);
assert_eq!(h.state().views[0].links.context, None);
assert!(h.query_by_label("Remove all links").is_none(), "the page's menu has no link items");
}
/// [`link_pdf`], with a check box over the middle of its link.
fn field_over_link_pdf() -> Vec<u8> {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /DA (/ZaDb 0 Tf 0 g) /DR << /Font << /ZaDb 6 0 R >> >> >> >>",
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 /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [140 315 160 335] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>",
])
}
/// A right-click on a form field over a link is the field's, as a press there is: no link menu.
#[test]
fn a_right_click_on_a_field_over_a_link_opens_no_link_menu() {
let mut h = open(field_over_link_pdf(), "150", false);
let on_field = at(&h, 150.0, 325.0);
right_click(&mut h, on_field);
assert_eq!(h.state().views[0].links.context, None, "the field is on top");
assert!(h.query_by_label("Remove all links").is_none());
}
/// While the palette is open, a field beside it still shows its tooltip, as before: the palette
/// keeps the page's own tips away, not egui's tooltip of a field.
#[test]
fn a_fields_tooltip_shows_beside_the_open_palette() {
let mut h = open(commented_text_field_pdf(), "150", false);
h.state_mut().palette_open = true;
h.run_steps(4);
let palette = h.ctx.memory(|m| m.area_rect(egui::Id::unique("palette"))).expect("palette shown");
let on_field = at(&h, 60.0, 210.0);
assert!(!palette.expand(20.0).contains(on_field), "the field is beside the palette {palette:?}");
rest_at(&mut h, on_field);
assert!(h.query_all_by_label("Your name").next().is_some(), "the field's tooltip shows");
}
/// Two blank 300×400 pages.
fn two_pages_pdf() -> Vec<u8> {
pdf(&[
CATALOG,
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>",
"<< /Type /Page /Parent 2 0 R >>",
"<< /Type /Page /Parent 2 0 R >>",
])
}
/// Sets a tool up (`tool`), presses above page 1's bottom edge and drags onto page 2 in one frame,
/// so egui sees the drag only once the pointer is on page 2, and returns the app after the release.
fn drag_seen_only_past_page_ones_edge(tool: impl FnOnce(&mut PdfCraftApp)) -> Harness<'static, PdfCraftApp> {
let mut h = open(two_pages_pdf(), "100", false);
tool(h.state_mut());
h.run_steps(4);
let (one, two) = {
let v = &h.state().views[0];
(v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen"))
};
let press = egui::pos2(one.min.x + 50.0, one.max.y - 30.0);
let release = egui::pos2(one.min.x + 150.0, two.min.y + 20.0);
h.hover_at(press);
h.run_steps(1);
h.drag_at(press);
h.run_steps(1);
h.hover_at(release);
h.run_steps(1);
h.drop_at(release);
h.run_steps(4);
h
}
// A drag seen only past its page's edge still draws on the page its press began on, as zoom, crop
// and Sign area do. With these three it used to draw nothing anywhere.
#[test]
fn a_redaction_box_dragged_off_its_page_is_drawn_on_it() {
let h = drag_seen_only_past_page_ones_edge(|app| app.quick_tool = QuickTool::Redact);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert!(doc.info.annotations.iter().any(|a| a.subtype == "Redact" && a.page == 0), "a redaction mark on page 1");
}
#[test]
fn a_link_rectangle_dragged_off_its_page_is_drawn_on_it() {
let h = drag_seen_only_past_page_ones_edge(|app| app.quick_tool = QuickTool::Link);
assert_eq!(h.state().link_draft.as_ref().map(|d| (d.page, d.index)), Some((0, None)), "a new link on page 1");
}
#[test]
fn a_field_dragged_off_its_page_is_drawn_on_it() {
let h = drag_seen_only_past_page_ones_edge(|app| assert!(app.execute("form.add.text"), "Prepare a form, with the text field tool"));
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert!(doc.form.iter().any(|f| f.widgets.iter().any(|w| w.page == Some(0))), "a field on page 1");
}
/// With the Link tool a right-click on a link is the link's and leaves comments as they are, as
/// before: the selected comment stays selected.
#[test]
fn a_right_click_with_the_link_tool_on_a_link_keeps_the_selected_comment() {
let mut h = open(note_over_link_pdf(), "150", false);
let on_note = at(&h, 150.0, 325.0);
mouse_click(&mut h, on_note);
assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note is selected");
link_tool(&mut h);
// On the link, away from the note.
let on_link = at(&h, 70.0, 325.0);
right_click(&mut h, on_link);
let v = &h.state().views[0];
assert_eq!((v.links.context, v.comments.selected), (Some((0, 0)), Some((0, 1))), "the link's menu; the note stays selected");
}
/// A 300×400 page with a rectangle comment round the camera tool's first corner (view 15, 20).
fn comment_at_camera_corner_pdf() -> Vec<u8> {
pdf(&[
CATALOG,
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
"<< /Type /Annot /Subtype /Square /Rect [5 365 45 395] /C [1 0 0] /F 4 /T (Ana) >>",
])
}
/// A camera corner has no menu: a right-click on a comment under it opens the comment's, as
/// before.
#[test]
fn a_right_click_on_a_comment_under_a_camera_corner_opens_the_comments_menu() {
let mut h = open(comment_at_camera_corner_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::EnhanceCamera;
h.run_steps(4);
let corner = at(&h, 15.0, 380.0);
right_click(&mut h, corner);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu");
}
/// A 300×400 page with a web link along its left edge, x 0..100, y 300..350.
fn edge_link_pdf() -> Vec<u8> {
pdf(&[
CATALOG,
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
"<< /Type /Annot /Subtype /Link /Rect [0 300 100 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>",
])
}
/// The Link tool's margin round a link stops at the page's edge: beside the page, where a press
/// does nothing, no hand promises one (as before the margin moved into the one link test).
#[test]
fn the_link_tools_hand_stops_at_the_pages_edge() {
let mut h = open(edge_link_pdf(), "150", false);
link_tool(&mut h);
let on_link = at(&h, 10.0, 325.0);
assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand, "on the link");
let beside = at(&h, 0.0, 325.0) - egui::vec2(1.0, 0.0);
assert_ne!(cursor_at(&mut h, beside), egui::CursorIcon::PointingHand, "just off the page");
}
/// With Edit text a right-click on a paragraph is Edit text's, as before, though a comment lies
/// over it: the comment is not selected. The page's menu is anchored there all the same (Paste on
/// the picture menu, Delete page); it used to stay where the last right-click comments saw was.
#[test]
fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_no_comment() {
let mut h = open(comment_on_text_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::EditText;
h.run_steps(4);
let on_text = at(&h, 100.0, 303.0);
wait_for_cursor(&mut h, on_text, |c| c != egui::CursorIcon::Default);
right_click(&mut h, on_text);
assert_eq!(h.state().views[0].comments.selected, None);
assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0), "the menu is anchored on the page");
}
/// With a Prepare field tool a right-click is the tool's, as before: a comment under it is not
/// selected.
#[test]
fn a_right_click_with_a_field_tool_selects_no_comment() {
let mut h = open(comment_and_checkbox_pdf(), "150", false);
assert!(h.state_mut().execute("form.add.text"));
h.run_steps(4);
let on_comment = at(&h, 200.0, 125.0);
right_click(&mut h, on_comment);
assert_eq!(h.state().views[0].comments.selected, None);
}
/// With the Link tool a click that began off the page (in the gutter beside it) is no page's: the
/// selected link stays selected. It used to unselect it.
#[test]
fn a_click_that_began_off_the_page_keeps_the_selected_link() {
let mut h = open(link_pdf(), "150", false);
link_tool(&mut h);
let on_link = at(&h, 150.0, 325.0);
mouse_click(&mut h, on_link);
assert_eq!(h.state().views[0].links.selected, Some((0, 0)), "the link is selected");
let left = page(&h).min.x;
slipping_click(&mut h, egui::pos2(left - 2.0, on_link.y), egui::pos2(left + 3.0, on_link.y));
assert_eq!(h.state().views[0].links.selected, Some((0, 0)), "a click that began off the page leaves it selected");
}
/// A right-click while a comment is moved leaves the moved comment selected: the move ends on
/// it. The right-click used to select the comment under it mid-drag.
#[test]
fn a_right_click_during_a_comment_move_keeps_the_moved_comment_selected() {
let mut h = open(stacked_comments_pdf(), "150", false);
// "Bottom note" alone, then "Top note" alone.
let (bottom, top) = (at(&h, 60.0, 160.0), at(&h, 190.0, 290.0));
h.hover_at(bottom);
h.run_steps(1);
h.drag_at(bottom);
h.run_steps(1);
h.hover_at(bottom + egui::vec2(0.0, 20.0));
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: top, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE });
}
h.run_steps(1);
let end = bottom + egui::vec2(0.0, 40.0);
h.hover_at(end);
h.run_steps(1);
h.drop_at(end);
h.run_steps(3);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the moved comment stays selected");
}
/// A click on added text leaves the selected comment unselected, as a click on a form field does
/// (ruling 1). It used to keep it selected.
#[test]
fn a_click_on_added_text_deselects_the_selected_comment() {
let mut h = open(comment_on_text_pdf(), "150", false);
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: "Added".into(), ..Default::default() },
}));
// The Edit panel open, with the Select tool: added text is picked up.
assert!(h.state_mut().execute("edit.text"));
h.run_steps(3);
h.state_mut().quick_tool = QuickTool::Select;
h.run_steps(3);
let on_comment = at(&h, 150.0, 315.0);
mouse_click(&mut h, on_comment);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected");
let on_added = at(&h, 90.0, 202.0);
mouse_click(&mut h, on_added);
assert_eq!(h.state().views[0].comments.selected, None, "a click on the added text leaves it unselected");
}
/// A comment gesture left over with no button down (a release the pages never saw) does not keep
/// a right-click from selecting the comment under it.
#[test]
fn a_right_click_selects_its_comment_though_a_gesture_was_left_over() {
let mut h = open(stacked_comments_pdf(), "150", false);
let bottom = at(&h, 60.0, 160.0);
h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 0, from: bottom });
let top = at(&h, 190.0, 290.0);
right_click(&mut h, top);
assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note under the right-click is selected");
}
/// A click that began off the page (in the gutter beside it) is no page's: the selected comment
/// stays selected, as the Link tool's and Prepare's selections do. It used to unselect it.
#[test]
fn a_click_that_began_off_the_page_keeps_the_selected_comment() {
let mut h = open(stacked_comments_pdf(), "150", false);
let bottom = at(&h, 60.0, 160.0);
mouse_click(&mut h, bottom);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the note is selected");
let left = page(&h).min.x;
slipping_click(&mut h, egui::pos2(left - 2.0, bottom.y), egui::pos2(left + 3.0, bottom.y));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "a click that began off the page leaves it selected");
}
/// A touch's long press on a comment selects it for its menu, as a right-click does: egui reports
/// it as a right-click with the finger still down, which holds no drag.
#[test]
fn a_long_press_on_a_comment_selects_it_for_its_menu() {
let mut h = open(stacked_comments_pdf(), "150", false);
let top = at(&h, 190.0, 290.0);
let touch = |phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(1), phase, pos, force: None };
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
one_frame(&mut h, [touch(egui::TouchPhase::Start, top), egui::Event::PointerMoved(top), button(top, true)]);
// Held still, past egui's long-press time.
h.run_steps(6);
assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note under the finger is selected");
one_frame(&mut h, [touch(egui::TouchPhase::End, top), button(top, false), egui::Event::PointerGone]);
h.run_steps(2);
}
/// With the page menu anchored on page 1, presses on page 2 and holds the button, presses Escape
/// first with `escape`, right-clicks on page 2, and gives the page the menu is anchored on.
fn menu_page_after_a_right_click_with_the_button_held(escape: bool) -> Option<usize> {
let mut h = open(two_pages_pdf(), "100", false);
h.run_steps(2);
h.state_mut().views[0].comments.context_at = Some((0, [10.0, 10.0]));
let two = h.state().views[0].page_screen_rect(1).expect("page 2 on screen");
let press = two.min + egui::vec2(50.0, 30.0);
h.hover_at(press);
h.run_steps(1);
h.drag_at(press);
h.run_steps(1);
h.hover_at(press + egui::vec2(40.0, 0.0));
h.run_steps(1);
if escape {
h.key_press(egui::Key::Escape);
h.run_steps(1);
}
let menu = press + egui::vec2(100.0, 60.0);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: menu, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE });
}
h.run_steps(1);
let page = h.state().views[0].comments.context_at.map(|(page, _)| page);
h.drop_at(press + egui::vec2(40.0, 0.0));
h.run_steps(2);
page
}
/// A right-click while the primary button is held anchors the page's menu where it is (Delete page,
/// Paste): the held press leaves only the selections alone. It used to keep the menu on the page
/// right-clicked before.
#[test]
fn a_right_click_with_the_button_held_anchors_the_menu_on_its_page() {
assert_eq!(menu_page_after_a_right_click_with_the_button_held(false), Some(1), "the menu is page 2's");
}
/// So does a right-click while a press Escape cancelled is held, though the cancelled press takes
/// nothing else.
#[test]
fn a_right_click_after_escape_cancelled_the_press_anchors_the_menu_on_its_page() {
assert_eq!(menu_page_after_a_right_click_with_the_button_held(true), Some(1), "the menu is page 2's");
}
/// With Edit text a right-click on added text reaches comments, as before (Edit text keeps those on
/// its own paragraphs and pictures): the selected comment is unselected.
#[test]
fn a_right_click_with_edit_text_on_added_text_unselects_the_comment() {
let mut h = open(comment_on_text_pdf(), "150", false);
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: "Added".into(), ..Default::default() },
}));
h.state_mut().quick_tool = QuickTool::EditText;
h.run_steps(4);
h.state_mut().views[0].comments.selected = Some((0, 0));
let on_added = at(&h, 90.0, 202.0);
right_click(&mut h, on_added);
assert_eq!(h.state().views[0].comments.selected, None);
}
/// Two pages with their gaps hidden, after `tool` set the tool up, and a point on the row of pixels
/// where they touch, which lies on both.
fn touching_pages(tool: impl FnOnce(&mut PdfCraftApp)) -> (Harness<'static, PdfCraftApp>, egui::Pos2) {
let mut h = open(two_pages_pdf(), "100", false);
h.state_mut().views[0].show_page_gaps = false;
tool(h.state_mut());
h.run_steps(4);
let (one, two) = {
let v = &h.state().views[0];
(v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen"))
};
let seam = egui::pos2(one.min.x + 100.0, one.max.y);
assert!(one.contains(seam) && two.contains(seam), "the pages touch: {one:?} {two:?}");
(h, seam)
}
/// The pages the document's stamps are on.
fn stamp_pages(h: &Harness<'_, PdfCraftApp>) -> Vec<usize> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Stamp").map(|a| a.page).collect()
}
/// Where pages touch (the gaps hidden), a press on the row they share is the first page's: a stamp
/// clicked there lands on page 1. The second page used to take the press over.
#[test]
fn a_stamp_clicked_where_two_pages_touch_lands_on_the_first() {
let (mut h, seam) = touching_pages(|app| app.quick_tool = QuickTool::Stamp(StampKind::DynApproved));
mouse_click(&mut h, seam);
assert_eq!(stamp_pages(&h), vec![0]);
}
/// A click pressed on one page and released on the next (the gaps hidden, within egui's click
/// distance) places nothing: the press is the first page's, and the pointer is off it. `main` placed
/// the stamp on the second page.
#[test]
fn a_stamp_click_pressed_on_one_page_and_released_on_the_next_places_nothing() {
let (mut h, seam) = touching_pages(|app| app.quick_tool = QuickTool::Stamp(StampKind::DynApproved));
slipping_click(&mut h, seam - egui::vec2(0.0, 2.0), seam + egui::vec2(0.0, 2.0));
assert_eq!(stamp_pages(&h), Vec::<usize>::new());
}
/// Where pages touch (the gaps hidden), a right-click on the row they share is the first page's:
/// the menu is anchored there. Both pages used to take it, and the second's anchor stood.
#[test]
fn a_right_click_where_two_pages_touch_is_the_first_pages() {
let (mut h, seam) = touching_pages(|_| {});
right_click(&mut h, seam);
assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0));
}
/// With Prepare open, a click that began off the page (in the gutter beside it) is no page's: the
/// selected field stays selected, as the Link tool's and comments' selections do.
#[test]
fn with_prepare_a_click_that_began_off_the_page_keeps_the_selected_field() {
let mut h = open(comment_and_checkbox_pdf(), "150", false);
assert!(h.state_mut().execute("form.prepare"));
h.run_steps(4);
let on_box = at(&h, 110.0, 210.0);
mouse_click(&mut h, on_box);
assert!(h.state().views[0].prepare.selected.is_some(), "the field is selected");
let left = page(&h).min.x;
slipping_click(&mut h, egui::pos2(left - 2.0, on_box.y), egui::pos2(left + 3.0, on_box.y));
assert!(h.state().views[0].prepare.selected.is_some(), "a click that began off the page leaves it selected");
}
/// With the Edit panel open and the Note tool, a click on added text is the added text's: no note is
/// started under it, as before.
#[test]
fn a_note_tool_click_on_added_text_starts_no_note() {
let mut h = open(comment_on_text_pdf(), "150", false);
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: "Added".into(), ..Default::default() },
}));
assert!(h.state_mut().execute("edit.text"));
h.run_steps(3);
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Note);
h.run_steps(3);
let on_added = at(&h, 90.0, 202.0);
mouse_click(&mut h, on_added);
assert!(h.state().views[0].comments.composer.is_none(), "no note is started");
}
/// With Fill & Sign, a mark being moved on the page keeps the page's presses, as before: a click while
/// its move is still live (its release never seen) places no new mark.
#[test]
fn a_fill_and_sign_click_places_nothing_while_a_mark_is_moved() {
let mut h = open(comment_on_text_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::Fill(FillTool::Check);
h.run_steps(4);
let marks = |h: &Harness<'_, PdfCraftApp>| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().info.annotations.len()
};
let before = marks(&h);
let blank = at(&h, 150.0, 100.0);
h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 0, from: blank });
mouse_click(&mut h, blank);
assert_eq!(marks(&h), before, "no mark is placed");
}
/// A right-click between the pages anchors no page's menu, so Delete page and Paste act on the
/// current page. The menu used to keep the anchor of the last right-click on a page.
#[test]
fn a_right_click_between_the_pages_anchors_no_pages_menu() {
let mut h = open(two_pages_pdf(), "100", false);
h.run_steps(2);
h.state_mut().views[0].comments.context_at = Some((0, [10.0, 10.0]));
let (one, two) = {
let v = &h.state().views[0];
(v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen"))
};
let gap = egui::pos2(one.center().x, (one.max.y + two.min.y) / 2.0);
assert!(!one.contains(gap) && !two.contains(gap), "between the pages: {one:?} {two:?}");
right_click(&mut h, gap);
assert_eq!(h.state().views[0].comments.context_at, None);
}
/// Two 300×400 pages, a rectangle comment on the first.
fn two_pages_and_a_comment_pdf() -> Vec<u8> {
pdf(&[
CATALOG,
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>",
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>",
"<< /Type /Page /Parent 2 0 R >>",
"<< /Type /Annot /Subtype /Square /Rect [30 290 200 320] /C [1 0 0] /F 4 /T (Reviewer) >>",
])
}
/// A right-click between the pages selects no comment, as one on a page's empty part does not, so
/// the menu that opens is the current page's. The selected comment's menu used to open there.
#[test]
fn a_right_click_between_the_pages_opens_the_page_menu_not_the_selected_comments() {
let mut h = open(two_pages_and_a_comment_pdf(), "100", false);
h.state_mut().views[0].comments.selected = Some((0, 0));
h.run_steps(2);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected");
let (one, two) = {
let v = &h.state().views[0];
(v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen"))
};
let gap = egui::pos2(one.center().x, (one.max.y + two.min.y) / 2.0);
assert!(!one.contains(gap) && !two.contains(gap), "between the pages: {one:?} {two:?}");
right_click(&mut h, gap);
assert_eq!(h.state().views[0].comments.selected, None);
assert!(h.query_by_label("Rotate view counterclockwise").is_some(), "the page menu opens");
assert!(h.query_by_label("Reply").is_none(), "not the comment's");
}
/// Where pages touch, a right-click on the row they share is page 1's, and a picture on page 2 there
/// opens its menu, whose Paste then places the image as Ctrl+V does. It used to centre the image on
/// page 1's point, which on page 2 lies at its foot.
#[test]
fn paste_from_a_picture_menu_where_two_pages_touch_keeps_off_the_first_pages_point() {
let (mut h, seam) = touching_pages(|app| {
app.system_clipboard = false;
app.quick_tool = QuickTool::EditText;
});
let clip = || pdfcraft_ui_egui::Clip::Image { width: 60, height: 40, rgba: [50u8, 150, 250, 255].repeat(60 * 40) };
h.state_mut().clipboard_override = Some(clip());
assert!(h.state_mut().paste_image_at(1, [100.0, 400.0]), "a picture across page 2's top edge");
h.state_mut().clipboard_override = Some(clip());
h.state_mut().views[0].image_selection = None;
h.run_steps(4);
right_click(&mut h, seam);
assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0), "page 1 takes the right-click");
assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| s.page), Some(1), "page 2's picture opens its menu");
h.get_by_label("Paste").click();
h.run_steps(3);
let pasted = {
let s = h.state();
s.session.get(s.views[0].id).unwrap().page_images(1)
};
assert_eq!(pasted.len(), 2, "the image is pasted on page 2");
let r = pasted[1].rect;
assert!((r[1] + r[3]) / 2.0 > 100.0, "not centred at page 2's foot: {r:?}");
}
/// With the camera tool, a click on another page moves the quadrilateral there, as a drag from it
/// does. The click was located by its press's origin, which egui has cleared by the release frame,
/// so it did nothing (on `main` too).
#[test]
fn a_camera_click_on_another_page_moves_the_quadrilateral_there() {
let mut h = open(two_pages_pdf(), "100", false);
h.state_mut().quick_tool = QuickTool::EnhanceCamera;
h.run_steps(4);
assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(0), "it starts on the current page");
let two = h.state().views[0].page_screen_rect(1).expect("page 2 on screen");
mouse_click(&mut h, two.min + egui::vec2(100.0, 60.0));
assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(1));
}
/// A camera click is located where it came up, not where the pointer went after it in the same
/// frame: pressed on page 2 and released on page 1 (the gaps hidden), it moves nothing, though the
/// pointer is back on page 2 by the frame's end. The pointer's last position used to move the
/// quadrilateral to page 2.
#[test]
fn a_camera_click_released_off_its_page_moves_nothing() {
let (mut h, seam) = touching_pages(|app| app.quick_tool = QuickTool::EnhanceCamera);
assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(0), "it starts on the current page");
let (press, release, back) = (seam + egui::vec2(0.0, 2.0), seam - egui::vec2(0.0, 2.0), seam + egui::vec2(0.0, 40.0));
h.hover_at(press);
h.run_steps(1);
h.drag_at(press);
h.run_steps(1);
let up = egui::Event::PointerButton { pos: release, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE };
one_frame(&mut h, [up, egui::Event::PointerMoved(back)]);
h.run_steps(2);
assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(0));
}
+82
View File
@@ -1562,3 +1562,85 @@ fn button_export_properties_apply_cancel_and_undo() {
assert_eq!(f.export_of(0), Some(original.as_str()));
}
}
/// The screen rectangle of widget 0 of field `name` on the first view.
fn field_rect(h: &Harness<'static, PdfCraftApp>, name: &str) -> egui::Rect {
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
let f = doc.form.iter().find(|f| f.name == name).unwrap();
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen")
}
/// A right-click while a field is dragged leaves the dragged field selected, so the drag moves it
/// and not the field right-clicked. The right-click used to select the other field, and the release
/// moved that one.
#[test]
fn a_right_click_during_a_field_drag_moves_the_dragged_field() {
let mut h = harness();
h.state_mut().execute("form.prepare");
h.run_steps(3);
let (name, city) = (field_rect(&h, "name"), field_rect(&h, "city"));
let a = name.center();
h.hover_at(a);
h.run_steps(1);
h.drag_at(a);
h.run_steps(1);
h.hover_at(a + egui::vec2(0.0, 30.0));
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: city.center(), button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() });
}
h.run_steps(1);
let end = a + egui::vec2(0.0, 60.0);
h.hover_at(end);
h.run_steps(1);
h.drop_at(end);
h.run_steps(3);
assert_eq!(field_rect(&h, "city"), city, "the field right-clicked stays where it was");
assert_ne!(field_rect(&h, "name"), name, "the dragged field moved");
}
/// With Prepare open, a drag with a shape tool on the page's empty part draws Prepare's selection
/// rectangle and no comment, as before: Prepare takes the press nobody holds. It used to draw both.
#[test]
fn with_prepare_open_a_shape_tool_drag_draws_no_comment() {
let mut h = harness();
h.state_mut().execute("form.prepare");
h.run_steps(3);
assert!(h.state_mut().execute("comment.square"));
h.run_steps(3);
assert!(h.state().is_preparing(), "picking the tool leaves Prepare open");
// Below the fields.
let (from, to) = (at(&h, 40.0, 260.0), at(&h, 200.0, 360.0));
drag(&mut h, from, to);
let s = h.state();
let squares = s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Square").count();
assert_eq!(squares, 0, "no rectangle under Prepare's selection rectangle");
}
/// With Prepare open, a double-click on a field is the field's (it opens its properties) and leaves
/// a polygon being clicked out unfinished, as before. It used to finish the polygon too.
#[test]
fn with_prepare_open_a_double_click_on_a_field_leaves_the_polygon_unfinished() {
let mut h = harness();
h.state_mut().execute("form.prepare");
h.run_steps(3);
assert!(h.state_mut().execute("comment.polygon"));
h.run_steps(3);
// Three points below the fields.
for (x, y) in [(40.0, 260.0), (200.0, 260.0), (120.0, 360.0)] {
let p = at(&h, x, y);
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(3);
}
let on_field = field_rect(&h, "name").center();
crate::support::input::double_click(&mut h, on_field);
let s = h.state();
let polygons = s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Polygon").count();
assert_eq!(polygons, 0, "the polygon is not finished");
assert!(matches!(s.views[0].comments.gesture, Some(pdfcraft_ui_egui::comments::Gesture::Draw { .. })), "it is still being clicked out");
}