refactor(ui): one gate for the presses nobody holds

The tools that act wherever on the page each decided for themselves whether a press was
theirs: Sign area, zoom and snapshot, crop, redact and the stamps asked the router, and Measure,
Fill & Sign, the camera and the Link tool's rectangle acted on any press. Stamp placement lived
inline in the page view.

`tool_input` is now the one gate. Measure, the Stamp tool and custom stamps, Fill & Sign, Sign
area, zoom and snapshot, the camera and crop are each told whether the press is the tool's (it
began on their page and no hit won it), and act only then; their cursors and gestures run as
before. Redact's boxes and the Link tool's rectangle are told the same where they are called.
Stamp placement moves to `stamps_ui::place` and `place_custom`, beside custom stamps. Measure
is told its clicks (`measure_ui::Clicks`), Sign area takes a `PageToolContext`.

The camera's corners are hits (`camera_ui::hit`, `Owner::CameraCorner` on the handle layer), so
a corner's drag is an owned press like any other: it starts on the corner's own press, ends on
`drag_stopped() || !primary_down`, and `cancel_pointer_gestures` drops it. Until now only the
order of two key handlers kept Escape mid-drag from leaving a corner following the pointer:
Escape leaves the camera tool before the pages see it. The kittest for Escape mid-drag can't
fail with that order; a unit test of the cancel comes later in this series.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alehelandClaude Opus 5.5 committed 2026-11-02 20:38:56 +01:00
1 parent 84fe5e54b3
commit 951ef7e362
14 files changed
+281 -169

No files matched your search

+3
View File
@@ -863,6 +863,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;
+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));
+100 -110
View File
@@ -39,30 +39,20 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
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;
@@ -103,106 +93,11 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
// 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);
let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, view, p));
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() && press == PagePress::Free {
// 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() && press == PagePress::Free {
// 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;
}
}
// A mark already placed takes its own press (comments, below), and with no button down the
// tool's preview hides over one, where a press would pick it up.
// Fill & Sign tool's preview hides over one, where a press would pick it up.
let over_mark = matches!(tool, QuickTool::Fill(_))
&& (matches!(press, PagePress::Mine(_)) || !ptr.any_down && pointer.and_then(|p| top(hits.at(view, p), tool)).is_some());
if let QuickTool::Fill(ft) = tool
&& allowed
&& !over_mark
{
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;
}
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, view);
frame.out.field_props |= o.properties;
@@ -220,7 +115,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
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)
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
@@ -281,7 +176,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
}
}
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, link_owner, link_context, view);
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
@@ -315,6 +210,100 @@ fn link_of(owner: Option<Owner>) -> Option<usize> {
}
}
/// 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. `over_mark`: a Fill & Sign mark takes the press, or would take one under the
/// pointer.
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`: it goes to what lies on top there ([`PageHits::context_at`]), which the
/// page's menu then acts on. A comment there is selected, and none elsewhere; a Prepare field is
/// selected; links take theirs from what this returns.
@@ -460,9 +449,10 @@ impl PageHits<'_> {
// together.
let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText);
let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten();
let corner = (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, fields].into_iter().flatten().collect()
[link, comment, pictures, paragraphs, added, objects, grips, corner, fields].into_iter().flatten().collect()
}
}
@@ -150,7 +150,7 @@ 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; 36] = [
const PAGE_MODULES: [&str; 37] = [
"canvas.rs",
"canvas/page_input.rs",
"canvas/area.rs",
@@ -187,6 +187,7 @@ const PAGE_MODULES: [&str; 36] = [
"canvas/image_pick.rs",
"canvas/signature_drag.rs",
"tools/calc_order_ui.rs",
"tools/stamps_ui.rs",
];
/// egui's raw pointer state: where the pointer is, and which buttons are down or went down.
@@ -434,11 +435,12 @@ const PRESS_IGNORERS: [(&str, &str, &str); 1] =
/// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who
/// may).
const PAGE_HIT_CALLS: [&str; 10] = [
const PAGE_HIT_CALLS: [&str; 11] = [
"link_ui::hit(",
"prepare::hit(",
"forms_ui::hit(",
"comments::hit(",
"camera_ui::hit(",
"edit_text_ui::hit(",
"edit_text_ui::picture_hit(",
"edit_text_ui::picture_handle_hit(",
+2
View File
@@ -36,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.
+6 -5
View File
@@ -13,15 +13,15 @@ use crate::{PdfCraftApp, QuickTool};
/// [x0, y0, x1, y1]); a plain click has an empty rectangle.
pub type Marquee = (usize, Rect, [f32; 4]);
/// 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));
@@ -40,7 +40,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);
+37 -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,28 @@ 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, or on a page
/// without the quadrilateral.
pub(crate) fn hit(xf: &PageXform, page: usize, view: &DocView, p: Pos2) -> Option<Hit> {
if view.camera_done.is_some() {
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 +97,21 @@ 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);
}
if (resp.drag_started() || resp.clicked())
&& press == PagePress::Free
&& origin.is_some_and(|o| xf.rect.contains(o))
&& 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 +120,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);
}
}
+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.
+5 -5
View File
@@ -87,10 +87,10 @@ fn target_label(info: &DocInfo, target: &LinkTarget) -> String {
}
}
/// Link tool input on one page: `owner` is the link that holds the press (by its `/Annots`
/// index), and `context` the one a right-click is on. Returns `true` when the gesture belongs to
/// the tool.
pub(crate) fn page_input(cx: PageToolContext<'_>, owner: Option<usize>, context: Option<usize>, view: &mut DocView) -> bool {
/// 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;
@@ -128,7 +128,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, owner: Option<usize>, context:
}
return true;
}
if resp.drag_started() {
if resp.drag_started() && (free || owner.is_some()) {
let origin = ptr.origin.unwrap_or(p);
lv.drag = Some((page, origin));
return true;
+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, Debug, Default)]
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)) {
+5 -5
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
@@ -256,8 +256,8 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bo
if !over_text(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.
if resp.drag_started_by(egui::PointerButton::Primary) && view.router.owner(page).is_none() {
// The tool's own press, of the primary button: one nobody holds.
if resp.drag_started_by(egui::PointerButton::Primary) && free {
let origin = ptr.origin.unwrap_or(p);
if !over_text(origin) {
view.redact_drag = Some((page, origin));
+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 {
+37
View File
@@ -1155,3 +1155,40 @@ fn a_click_on_a_form_field_deselects_the_selected_comment() {
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");
}