mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
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:
commit
a9a4e0e732
29 files changed
+2931
-1146
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);"));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user