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