Merge pull request #4709 from alehel/fix/page-cursor-writer

fix(ui): the page sets its cursor once, from the top claim the tool reaches
This commit is contained in:
aiongg
2026-10-28 17:57:29 -04:00
committed by GitHub
28 changed files with 772 additions and 264 deletions
+1
View File
@@ -22,6 +22,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
- **Privacy.** When observing Acrobat, use synthetic fixtures only. Never capture the Home view, recent files or account info. Capture by window id (`plan/acrobat/tools/`). Never commit Acrobat outputs, corpus files or personal data.
- **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.
- **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.
+40 -76
View File
@@ -12,6 +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_pointer::PagePointer;
use crate::theme::{self, Tokens};
use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets};
@@ -26,6 +27,7 @@ pub(super) mod fling;
pub(super) mod image_pick;
mod navigation;
mod organize;
pub(super) mod page_cursor;
pub(super) mod page_pointer;
pub mod scroll_speed;
mod selection;
@@ -38,8 +40,7 @@ pub(super) mod zoom_snap;
pub(crate) use area::signature_badges;
use area::{
SUPPRESS_SECS, SUPPRESS_TAIL_SECS, annotation_hover_text, drawing_edge_pointer, glide_interrupted, page_scroll, presentation_click, run_button,
set_layers, zoom_anchor,
SUPPRESS_SECS, SUPPRESS_TAIL_SECS, drawing_edge_pointer, glide_interrupted, page_scroll, presentation_click, run_button, set_layers, zoom_anchor,
};
#[cfg(test)]
use bars::in_size_hint_zone;
@@ -470,6 +471,8 @@ pub struct DocView {
/// A queued Fill & Sign signature: select it after its edit succeeds, then leave placement.
pub(crate) fill_signature_page: Option<usize>,
pub(crate) signature_drag: crate::signature_drag::SignatureDrag,
/// What the page modules claim for the cursor this frame; `document_area` sets it once.
pub(crate) cursor: crate::page_cursor::PageCursor,
/// A non-edit action requested by the organize toolbar, handled by the app.
pub pending_action: Option<ViewAction>,
/// The grid gap the next inserted files go to (set by a "+" between pages); otherwise they
@@ -722,6 +725,7 @@ impl DocView {
fill_text: None,
fill_signature_page: None,
signature_drag: Default::default(),
cursor: Default::default(),
}
}
@@ -873,7 +877,6 @@ struct PageEnv<'a> {
/// What the page-scroll closure produces for the rest of the frame.
#[derive(Default)]
struct PageOut {
badge_hover: Option<(Pos2, String)>,
canvas_action: Option<comments::CanvasAction>,
clicked_link: Option<LinkTarget>,
copy_selected: bool,
@@ -1065,8 +1068,6 @@ fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>
struct PageInput {
/// A tool, a field or a comment took the pointer: text selection must leave it alone.
consumed: bool,
/// A form field is under the pointer and has set its cursor.
over_field: bool,
/// Redact or Highlight draws boxes off text.
area_tool: bool,
}
@@ -1129,7 +1130,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
&& allowed
&& let Some(p) = ptr.within(xf.rect)
{
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
if resp.clicked() {
// Centred on the click, upright as the page is shown.
let (vx, vy) = xf.screen_to_view(p);
@@ -1153,7 +1154,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
&& allowed
&& let Some(p) = ptr.within(xf.rect)
{
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
if resp.clicked() {
// Centred on the click at its natural size (the engine sizes it).
let (vx, vy) = xf.screen_to_view(p);
@@ -1205,7 +1206,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
crate::zoom_snap::page_input(ui, resp, &xf, ptr, i, view);
}
if tool == QuickTool::EnhanceCamera {
crate::camera_ui::page_input(ui, resp, &xf, ptr, i, view, can_modify);
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 })
@@ -1213,8 +1214,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
view.current = i;
frame.out.open_boxes = true;
}
// `over_field`: a form field is under the pointer and has set its cursor.
let (on_field, over_field) = if preparing {
let on_field = if preparing {
let field_tool = match tool {
QuickTool::Field(f) => Some(f),
_ => None,
@@ -1225,18 +1225,12 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
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(), false)
o.consumed || field_tool.is_some()
} else {
use crate::forms_ui::FieldPointer;
let field = if tool == QuickTool::Select
tool == QuickTool::Select
&& !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, ptr, i, info, added, view))
&& !comments::select_grabs(&pcx, view)
{
crate::forms_ui::page_input(cx, form, can_fill, view)
} else {
FieldPointer::Away
};
(field == FieldPointer::Owned, field == FieldPointer::Over)
&& 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;
@@ -1328,7 +1322,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
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);
PageInput { consumed, over_field, area_tool }
PageInput { consumed, area_tool }
}
/// Text layer: find matches, selection, I-beam and drag-to-select.
@@ -1342,12 +1336,7 @@ fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>,
&& !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, over_field: input.over_field },
);
text_pointer_input(ui, view, &text, TextPointer { page: i, xf, rect: r, info, tool, resp, ptr: ptr.clone(), pointer: p });
}
}
}
@@ -1397,7 +1386,6 @@ struct TextPointer<'a> {
resp: &'a egui::Response,
ptr: PagePointer,
pointer: Pos2,
over_field: bool,
}
struct TextGesture<'a> {
@@ -1416,24 +1404,24 @@ struct TextGesture<'a> {
}
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, over_field } = pointer;
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(ui, resp, over_field, &gesture);
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(ui: &egui::Ui, resp: &egui::Response, over_field: bool, gesture: &TextGesture<'_>) {
let cursor_free = !gesture.over_link && !over_field && (resp.contains_pointer() || resp.dragged());
if gesture.column && cursor_free {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
} else if gesture.over_text && cursor_free {
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
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);
}
}
@@ -1611,16 +1599,13 @@ fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_
// 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);
// Fill & Sign keeps its placement cursor clear of link/comment hover feedback. Tips
// draw above the palette, so none shows while it is open, even beside it.
if !matches!(tool, QuickTool::Fill(_))
&& unobstructed
&& let Some(p) = pointer
{
// 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.
if unobstructed && let Some(p) = pointer {
for l in info.links.iter().filter(|l| l.page == i) {
let sr = xf.user_rect(info, i, l.rect);
if sr.contains(p) {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
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("?"))])
@@ -1630,32 +1615,15 @@ fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_
LinkTarget::RemoteGoTo { file, .. } => file.clone(),
LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]),
};
frame.out.hover_text = Some((p, label));
view.cursor.claim_with_tip(Layer::Link, egui::CursorIcon::PointingHand, Some(label));
if resp.clicked() && tool == QuickTool::Select && !input.consumed {
frame.out.clicked_link = Some(l.target.clone());
}
}
}
// Annotation hover shows the comment, as Acrobat's popups do; not while drawing
// freehand, where it would cover the next stroke (#429).
let freehand = matches!(tool, QuickTool::Comment(comments::CommentTool::Ink | comments::CommentTool::Eraser));
let gesturing = view.comments.gesture.is_some() || freehand;
// Inspection sorts annotations spatially, but the renderer paints in /Annots
// order. Keep the shared list intact and choose the last eligible raw index.
if frame.out.hover_text.is_none()
&& !gesturing
&& let Some((_, text)) = info
.annotations
.iter()
.filter(|a| a.page == i)
.filter(|a| !(prefs.hide_empty_highlight_hover && a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty)))
.filter(|a| pcx.screen_rects(a).any(|r| r.contains(p)))
.filter_map(|a| annotation_hover_text(a).map(|text| (a.index, text)))
.max_by_key(|(index, _)| *index)
{
frame.out.hover_text = Some((p, text));
}
}
// 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);
@@ -1700,15 +1668,12 @@ fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_
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);
if let Some(p) = pointer.filter(|p| mark.expand(2.0).contains(*p)) {
frame.out.badge_hover = Some((p, badge.summary.to_string()));
// 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());
}
}
// Overlapping widgets paint in order, so the topmost badge owns the hover; an
// earlier link or annotation hover still wins over any badge.
if frame.out.hover_text.is_none() {
frame.out.hover_text = frame.out.badge_hover.take();
}
}
fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInfo, form: &[pdfcraft_engine::FormField], page: usize, enabled: bool) {
@@ -1737,7 +1702,6 @@ fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInf
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;
frame.out.badge_hover = None;
let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp);
if !r.intersects(visible.expand(400.0)) {
return;
@@ -1824,6 +1788,7 @@ fn draw_pages(
preparing,
selection_wheel,
single_paging,
tool,
unobstructed,
visible_pages,
y_shift,
@@ -1856,9 +1821,9 @@ fn draw_pages(
// The Hand tool pans by this widget's drag, before the pages are laid out (above).
if hand {
if resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing);
} else if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Grab);
}
}
let origin = resp_rect.min - vec2(0.0, y_shift);
@@ -1903,14 +1868,13 @@ fn draw_pages(
wanted, visible_now, mut current, best_overlap, current_overlap, current_height, pressed_page, calc_hover, out: page_out, ..
} = frame;
let PageOut {
badge_hover,
mut canvas_action,
clicked_link,
mut copy_selected,
mut field_menu,
field_placed,
field_props,
hover_text,
hover_text: _,
image_action,
open_boxes,
open_initials,
@@ -1926,6 +1890,8 @@ fn draw_pages(
let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str());
crate::calc_order_ui::paint_trace(ui, painter, visible, &view.calc_order, generation, selected);
}
// The pages' one cursor, from the claim on the highest layer the tool reaches, and the tip.
let hover_text = pointer.filter(|_| unobstructed).zip(view.cursor.resolve(ui.ctx(), tool));
// ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag
// scrolls the page itself (#721). After the page loop, so it keeps scrolling once the
// pointer leaves the selection's page for the gap or the next page; over the whole
@@ -2077,7 +2043,6 @@ fn draw_pages(
canvas_action = comments::context_menu(ui, view, info, prefs, allowed, can_modify).or(copy_image);
});
*out = PageOut {
badge_hover,
canvas_action,
clicked_link,
copy_selected,
@@ -3074,7 +3039,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
draw_pages(ui, viewport, view, &env, &mut page_out, &mut app.signature_preview)
});
let PageOut {
badge_hover: _,
canvas_action,
clicked_link,
mut copy_selected,
-11
View File
@@ -24,17 +24,6 @@ pub(crate) fn signature_badges(signatures: &[pdfcraft_engine::SignatureInfo], pa
.collect()
}
/// The hover popup for an annotation. Replies and Fill & Sign marks (typed text, checks, signatures)
/// show none: they are page content being edited, not comments.
pub(super) fn annotation_hover_text(a: &pdfcraft_render::Annotation) -> Option<String> {
if a.is_reply() || a.fill_sign {
return None;
}
let who = a.author.clone().unwrap_or_else(|| a.subtype.clone());
let body = a.contents.clone().unwrap_or_default();
Some(if body.is_empty() { who } else { format!("{who}\n{body}") })
}
/// Suppression window for egui's eased copy of point input.
///
/// egui 0.36.2 drains `WheelState::after_events` tail deltas by 90% per 0.1 s.
+2 -1
View File
@@ -6,6 +6,7 @@ use pdfcraft_engine::{BoxSpec, Edit, PageBox};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
use crate::page_cursor::Layer;
use crate::page_pointer::PagePointer;
/// A crop rectangle being dragged: (page, start on screen).
@@ -32,7 +33,7 @@ pub(crate) fn page_input(ui: &egui::Ui, input: CropPageInput<'_>) -> bool {
let CropPageInput { resp, xf, ptr, page, info, view, allowed } = input;
let (pointer, origin) = (ptr.pos, ptr.origin);
if pointer.is_some_and(|p| xf.rect.contains(p)) {
ui.ctx().set_cursor_icon(if allowed { egui::CursorIcon::Crosshair } else { egui::CursorIcon::NotAllowed });
view.cursor.claim(Layer::ToolArea, if allowed { egui::CursorIcon::Crosshair } else { egui::CursorIcon::NotAllowed });
}
if !allowed {
return false;
+332
View File
@@ -0,0 +1,332 @@
//! The cursor the pages show, and the tip beside it, set once a frame.
//!
//! A page module claims the cursor for what it has under the pointer, on a [`Layer`], and never
//! sets egui's cursor itself: where each module set it, the last write of a frame won, so the
//! order of the per-page chain decided the cursor and not what a press there does. A link's hand
//! showed over every tool though only the Select tool follows links, and the I-beam of the text
//! under a comment hid that a drag there moves the comment. `canvas::document_area` sets the
//! cursor once, from the claim on the highest layer the active tool reaches. The tip beside it is
//! that claim's own (a link's target, so only where a press acts on the link: the Select tool
//! follows it, the Link tool edits it), or else what lies under the pointer says (a comment, or a
//! signature's validation badge), with any tool, as Acrobat shows a comment's tooltip. A test in
//! `page_pointer/tests.rs` keeps page modules from setting the cursor.
use egui::CursorIcon;
use crate::QuickTool;
/// What a claim is for, from the bottom up: a claim on a higher layer wins.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Layer {
/// The page under the active tool: the crosshair it places or draws with, its I-beam or hand.
ToolArea,
/// The page's text, to select, and Edit text's paragraphs.
Text,
/// Links.
Link,
/// Comments: notes, shapes and markup.
Comment,
/// Form fields.
Field,
/// What a drag moves whole: signatures and Fill & Sign's marks, added text and images,
/// selected objects, pictures in Edit text.
Placed,
/// The grips of the selected item: resize, rewrap and crop handles, paragraph edges.
Handle,
/// An open editor: a paragraph being typed in, a picture being cropped.
Editor,
/// A drag in progress: what is dragged keeps its cursor until the release.
Drag,
}
/// Every layer, bottom first.
const LAYERS: [Layer; 9] =
[Layer::ToolArea, Layer::Text, Layer::Link, Layer::Comment, Layer::Field, Layer::Placed, Layer::Handle, Layer::Editor, Layer::Drag];
impl Layer {
/// Whether a press with `tool` acts on what lies on this layer, so that the layer's claims
/// count. Only the Select tool follows links (the Link tool edits them) and picks up
/// comments: every other tool draws, places or selects text over them. The other layers are
/// claimed only by the modules of the tools that act on them, so they count for every tool.
pub(crate) fn reached_by(self, tool: QuickTool) -> bool {
match self {
Layer::Link => matches!(tool, QuickTool::Select | QuickTool::Link),
Layer::Comment => tool == QuickTool::Select,
Layer::ToolArea | Layer::Text | Layer::Field | Layer::Placed | Layer::Handle | Layer::Editor | Layer::Drag => true,
}
}
}
/// A claim on the cursor: the icon, and the tip that goes with it (a link's target).
#[derive(Clone, Debug, PartialEq)]
struct Claim {
icon: CursorIcon,
tip: Option<String>,
}
/// What a description tells about, from the least up: a higher one shows over a lower one.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Subject {
/// A signature's validation badge: viewer chrome over the signer's own artwork.
SignatureBadge,
/// A comment: who wrote it, and what it says.
Comment,
}
/// What the page modules claim for the cursor this frame. `canvas::document_area` sets the
/// cursor from it once, after the pages ([`PageCursor::resolve`]).
#[derive(Debug, Default)]
pub(crate) struct PageCursor {
/// The last claim on each layer, bottom layer first.
claims: [Option<Claim>; LAYERS.len()],
/// What lies under the pointer says ([`PageCursor::describe`]).
description: Option<(Subject, String)>,
}
impl PageCursor {
/// Shows `icon` while the pointer is over what lies on `layer`, unless the active tool
/// reaches a higher layer that is claimed too.
pub(crate) fn claim(&mut self, layer: Layer, icon: CursorIcon) {
self.claim_with_tip(layer, icon, None);
}
/// As [`Self::claim`], with `tip` beside the pointer where the claim wins.
pub(crate) fn claim_with_tip(&mut self, layer: Layer, icon: CursorIcon, tip: Option<String>) {
if let Some(slot) = self.claims.get_mut(layer as usize) {
*slot = Some(Claim { icon, tip });
}
}
/// Says what lies under the pointer without claiming the cursor: the tip shows with any tool,
/// beside that tool's cursor, as Acrobat shows a comment's tooltip, unless the winning claim
/// has a tip of its own. The description of the highest subject stands, and the last of
/// equals (the topmost of overlapping badges).
pub(crate) fn describe(&mut self, subject: Subject, text: String) {
if self.description.as_ref().is_none_or(|(held, _)| *held <= subject) {
self.description = Some((subject, text));
}
}
/// The claim on the highest layer `tool` reaches.
fn winner(claims: [Option<Claim>; LAYERS.len()], tool: QuickTool) -> Option<Claim> {
LAYERS.into_iter().zip(claims).rev().find_map(|(layer, claim)| claim.filter(|_| layer.reached_by(tool)))
}
/// Sets the cursor, once, to the icon of the claim on the highest layer `tool` reaches, and
/// returns the tip: that claim's own, or else the description. With no such claim the cursor
/// is left as it is. The claims are spent: the next frame starts with none.
pub(crate) fn resolve(&mut self, ctx: &egui::Context, tool: QuickTool) -> Option<String> {
let PageCursor { claims, description } = std::mem::take(self);
let winner = Self::winner(claims, tool);
if let Some(w) = &winner {
ctx.set_cursor_icon(w.icon);
}
winner.and_then(|w| w.tip).or(description.map(|(_, text)| text))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::comments::CommentTool;
use crate::fill_sign::FillTool;
use crate::prepare::FieldTool;
/// One of each tool. The match stops compiling when a tool is added, so that it gets a row
/// in [`Layer::reached_by`]'s tests.
fn tools() -> Vec<QuickTool> {
let tools = vec![
QuickTool::Select,
QuickTool::Hand,
QuickTool::Comment(CommentTool::Rectangle),
QuickTool::Comment(CommentTool::Highlight),
QuickTool::Measure(crate::measure_ui::Tool::Distance),
QuickTool::Crop,
QuickTool::EnhanceCamera,
QuickTool::Fill(FillTool::Signature),
QuickTool::Field(FieldTool::Text),
QuickTool::Redact,
QuickTool::AddText,
QuickTool::EditText,
QuickTool::Stamp(pdfcraft_engine::StampKind::DynApproved),
QuickTool::CustomStamp(0),
QuickTool::Link,
QuickTool::SignArea { certify: false },
QuickTool::MarqueeZoom,
QuickTool::Snapshot,
QuickTool::ColumnSelect,
];
for tool in &tools {
match tool {
QuickTool::Select
| QuickTool::Hand
| QuickTool::Comment(_)
| QuickTool::Measure(_)
| QuickTool::Crop
| QuickTool::EnhanceCamera
| QuickTool::Fill(_)
| QuickTool::Field(_)
| QuickTool::Redact
| QuickTool::AddText
| QuickTool::EditText
| QuickTool::Stamp(_)
| QuickTool::CustomStamp(_)
| QuickTool::Link
| QuickTool::SignArea { .. }
| QuickTool::MarqueeZoom
| QuickTool::Snapshot
| QuickTool::ColumnSelect => {}
}
}
tools
}
#[test]
fn layers_are_listed_bottom_first_in_their_order() {
for (i, layer) in LAYERS.into_iter().enumerate() {
assert_eq!(layer as usize, i, "{layer:?}");
}
assert!(LAYERS.windows(2).all(|w| w[0] < w[1]));
}
#[test]
fn links_count_for_the_select_and_link_tools_only() {
for tool in tools() {
assert_eq!(Layer::Link.reached_by(tool), matches!(tool, QuickTool::Select | QuickTool::Link), "{tool:?}");
}
}
#[test]
fn comments_count_for_the_select_tool_only() {
for tool in tools() {
assert_eq!(Layer::Comment.reached_by(tool), tool == QuickTool::Select, "{tool:?}");
}
}
#[test]
fn the_other_layers_count_for_every_tool() {
for tool in tools() {
for layer in [Layer::ToolArea, Layer::Text, Layer::Field, Layer::Placed, Layer::Handle, Layer::Editor, Layer::Drag] {
assert!(layer.reached_by(tool), "{layer:?} for {tool:?}");
}
}
}
fn claims(claims: &[(Layer, CursorIcon, Option<&str>)]) -> PageCursor {
let mut cursor = PageCursor::default();
for (layer, icon, tip) in claims {
cursor.claim_with_tip(*layer, *icon, tip.map(str::to_string));
}
cursor
}
fn winner(cursor: PageCursor, tool: QuickTool) -> Option<Claim> {
PageCursor::winner(cursor.claims, tool)
}
#[test]
fn the_highest_layer_the_tool_reaches_wins_with_its_tip() {
let on_text_under_a_link = [(Layer::Text, CursorIcon::Text, None), (Layer::Link, CursorIcon::PointingHand, Some("https://example.org/"))];
let select = winner(claims(&on_text_under_a_link), QuickTool::Select);
assert_eq!(select, Some(Claim { icon: CursorIcon::PointingHand, tip: Some("https://example.org/".into()) }));
// A markup tool selects the text there, and the link's tip goes with its hand.
let highlight = winner(claims(&on_text_under_a_link), QuickTool::Comment(CommentTool::Highlight));
assert_eq!(highlight, Some(Claim { icon: CursorIcon::Text, tip: None }));
// A tool reaching nothing claimed leaves the cursor alone.
assert_eq!(winner(claims(&[(Layer::Link, CursorIcon::PointingHand, None)]), QuickTool::Hand), None);
}
#[test]
fn a_higher_claim_without_a_tip_hides_a_lower_ones_tip() {
let field_over_a_link = [(Layer::Link, CursorIcon::PointingHand, Some("Go to page 2")), (Layer::Field, CursorIcon::Text, None)];
assert_eq!(winner(claims(&field_over_a_link), QuickTool::Select), Some(Claim { icon: CursorIcon::Text, tip: None }));
}
#[test]
fn the_last_claim_on_a_layer_wins() {
let two_links = [(Layer::Link, CursorIcon::PointingHand, Some("first")), (Layer::Link, CursorIcon::PointingHand, Some("second"))];
assert_eq!(winner(claims(&two_links), QuickTool::Select).and_then(|c| c.tip).as_deref(), Some("second"));
}
/// Runs one frame that sets the cursor to `before`, if any, then resolves `claims` for
/// `tool`. Returns the frame's cursor and the tip.
fn frame(ctx: &egui::Context, before: Option<CursorIcon>, claims: &mut PageCursor, tool: QuickTool) -> (CursorIcon, Option<String>) {
let mut tip = None;
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
if let Some(icon) = before {
ui.ctx().set_cursor_icon(icon);
}
tip = claims.resolve(ui.ctx(), tool);
});
// An input-only test has no renderer to take the font texture.
output.textures_delta.clear();
(output.platform_output.cursor_icon, tip)
}
#[test]
fn resolve_sets_the_winners_cursor_and_spends_the_claims() {
let ctx = egui::Context::default();
let on_a_link = || claims(&[(Layer::ToolArea, CursorIcon::Crosshair, None), (Layer::Link, CursorIcon::PointingHand, Some("tip"))]);
assert_eq!(frame(&ctx, None, &mut on_a_link(), QuickTool::Select), (CursorIcon::PointingHand, Some("tip".into())));
let stamp = QuickTool::Stamp(pdfcraft_engine::StampKind::DynApproved);
assert_eq!(frame(&ctx, None, &mut on_a_link(), stamp), (CursorIcon::Crosshair, None), "the stamp's crosshair, and no link tip");
// Nothing the tool reaches: the cursor stays as the frame set it.
let mut only_a_link = claims(&[(Layer::Link, CursorIcon::PointingHand, Some("tip"))]);
only_a_link.describe(Subject::Comment, "Ana".into());
assert_eq!(frame(&ctx, Some(CursorIcon::Help), &mut only_a_link, QuickTool::Hand), (CursorIcon::Help, Some("Ana".into())));
// Resolving spends the claims, reached or not, and the description.
assert_eq!(only_a_link.description, None);
assert_eq!(winner(only_a_link, QuickTool::Select), None);
}
#[test]
fn a_description_shows_with_any_tool_unless_the_winner_has_a_tip_of_its_own() {
let ctx = egui::Context::default();
let stamp = QuickTool::Stamp(pdfcraft_engine::StampKind::DynApproved);
let link = (Layer::Link, CursorIcon::PointingHand, Some("https://example.org/"));
let described = |claimed: &[(Layer, CursorIcon, Option<&str>)]| {
let mut cursor = claims(claimed);
cursor.describe(Subject::Comment, "Ana\nCheck this".into());
cursor
};
let comment = Some("Ana\nCheck this".to_string());
// A comment under the stamp's crosshair: the tool's cursor, and what the comment says.
let crosshair = (Layer::ToolArea, CursorIcon::Crosshair, None);
assert_eq!(frame(&ctx, None, &mut described(&[crosshair]), stamp), (CursorIcon::Crosshair, comment.clone()));
// A link the tool follows speaks for itself; one it doesn't keeps its tip to itself.
assert_eq!(
frame(&ctx, None, &mut described(&[crosshair, link]), QuickTool::Select),
(CursorIcon::PointingHand, Some("https://example.org/".into()))
);
assert_eq!(frame(&ctx, None, &mut described(&[crosshair, link]), stamp), (CursorIcon::Crosshair, comment.clone()));
// A winner without a tip of its own, such as the Select tool's comment claim.
let picked_up = (Layer::Comment, CursorIcon::Move, None);
assert_eq!(frame(&ctx, None, &mut described(&[link, picked_up]), QuickTool::Select), (CursorIcon::Move, comment));
}
#[test]
fn a_comment_says_more_than_a_signature_badge_and_the_last_badge_stands() {
let badge_then_comment = {
let mut cursor = PageCursor::default();
cursor.describe(Subject::SignatureBadge, "Signature is valid".into());
cursor.describe(Subject::Comment, "Ana".into());
cursor
};
let comment_then_badges = {
let mut cursor = PageCursor::default();
cursor.describe(Subject::Comment, "Ana".into());
cursor.describe(Subject::SignatureBadge, "Signature is valid".into());
cursor
};
let two_badges = {
let mut cursor = PageCursor::default();
cursor.describe(Subject::SignatureBadge, "Signature is valid".into());
cursor.describe(Subject::SignatureBadge, "Signature is invalid".into());
cursor
};
let said = |cursor: PageCursor| cursor.description.map(|(_, text)| text);
assert_eq!(said(badge_then_comment).as_deref(), Some("Ana"));
assert_eq!(said(comment_then_badges).as_deref(), Some("Ana"), "whichever came first");
assert_eq!(said(two_badges).as_deref(), Some("Signature is invalid"), "the badge painted last is on top");
}
}
+59 -20
View File
@@ -247,22 +247,32 @@ fn declares(line: &str) -> Option<&str> {
rest.find(|c: char| !(c.is_alphanumeric() || c == '_')).map(|end| &rest[..end])
}
/// Whether `line` calls `read`, and not a longer name that ends in it.
fn reads(line: &str, read: &str) -> bool {
line.match_indices(read).any(|(at, _)| !line[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_'))
/// Ways to set egui's cursor.
const CURSOR_WRITES: [&str; 4] = ["set_cursor_icon(", ".cursor_icon = ", "on_hover_cursor(", "on_hover_and_drag_cursor("];
/// Functions in the page modules that set a cursor other than the pages': (file, function, why).
const NOT_PAGE_CURSOR: [(&str, &str, &str); 3] = [
("tools/fill_sign/scan.rs", "scan_tab", "the signature dialog's Scan tab"),
("tools/edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page, drawn after the pages' cursor is set"),
("tools/calc_order_ui.rs", "panel", "the Calculation order panel, whose rows are dragged to reorder them"),
];
/// Whether `line` uses `name`, and not a longer name that ends in it (a field, after its `.`, is
/// never part of a longer name).
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 == '_'))
}
/// Page modules read the pointer only through `PagePointer`. egui's raw state misses what is
/// drawn over the pages and where a touch lifted, so a raw read in a page module brings back
/// the bugs `PagePointer` fixed: a cursor or a press through a dialog, a tap that does nothing.
#[test]
fn page_modules_read_the_pointer_only_through_page_pointer() {
/// 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");
let mut problems = Vec::new();
let mut needed = vec![false; NOT_PAGE_INPUT.len()];
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 themselves.
// 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);
@@ -277,24 +287,36 @@ fn page_modules_read_the_pointer_only_through_page_pointer() {
if line.trim_start().starts_with("//") {
continue;
}
for read in RAW_READS.into_iter().filter(|read| reads(line, read)) {
match NOT_PAGE_INPUT.iter().position(|(f, name, _)| *f == file && function == Some(*name)) {
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}:{}: `{read}` reads egui's pointer state in a page module. Read `PagePointer` \
(src/page_pointer.rs) instead: it knows what is drawn over the pages and where a touch \
lifted. Not page input? Add the function to NOT_PAGE_INPUT there, with why.",
n + 1
)),
None => problems.push(format!("src/{file}:{}: `{name}` {why}", n + 1)),
}
}
}
}
for ((file, name, _), needed) in NOT_PAGE_INPUT.iter().zip(needed) {
for ((file, name, _), needed) in allowed.iter().zip(needed) {
if !needed {
problems.push(format!("src/{file}: fn {name} reads no pointer state any more: remove it from NOT_PAGE_INPUT."));
problems.push(format!("src/{file}: fn {name} {unused}"));
}
}
problems
}
/// Page modules read the pointer only through `PagePointer`. egui's raw state misses what is
/// drawn over the pages and where a touch lifted, so a raw read in a page module brings back
/// the bugs `PagePointer` fixed: a cursor or a press through a dialog, a tap that does nothing.
#[test]
fn page_modules_read_the_pointer_only_through_page_pointer() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut problems = misuses(
&RAW_READS,
&NOT_PAGE_INPUT,
"reads egui's pointer state in a page module. Read `PagePointer` (src/canvas/page_pointer.rs) instead: it \
knows what is drawn over the pages and where a touch lifted. Not page input? Add the function to \
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.
let mut dirs = vec![src.clone()];
while let Some(dir) = dirs.pop() {
@@ -322,3 +344,20 @@ fn page_modules_read_the_pointer_only_through_page_pointer() {
}
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
/// Page modules claim the cursor and never set it. Where each set it, the last write of a frame
/// won, so the order of the per-page chain decided the cursor and not what a press does: a
/// link's hand over every tool, the I-beam over a comment that a drag moves.
#[test]
fn page_modules_claim_the_cursor_and_never_set_it() {
let problems = misuses(
&CURSOR_WRITES,
&NOT_PAGE_CURSOR,
"sets the cursor in a page module, where the last write of a frame would win. Claim it instead, on the layer \
of what is under the pointer (`view.cursor.claim`, src/canvas/page_cursor.rs): `document_area` sets it once, \
from the claim on the highest layer the active tool reaches. Not the pages' cursor? Add the function to \
NOT_PAGE_CURSOR in src/canvas/page_pointer/tests.rs, with why.",
"sets no cursor any more: remove it from NOT_PAGE_CURSOR.",
);
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
-30
View File
@@ -57,36 +57,6 @@ fn grid_pages_are_centred_and_as_large_as_the_cell_allows() {
}
}
fn annotation(fill_sign: bool) -> pdfcraft_render::Annotation {
pdfcraft_render::Annotation {
page: 0,
subtype: "FreeText".into(),
author: None,
contents: Some("Hello".into()),
modified: None,
name: None,
in_reply_to: None,
parent: None,
grouped: false,
rect: [0.0; 4],
color: None,
index: 0,
state: None,
quads: Vec::new(),
locked: false,
intent: None,
fill_sign,
no_rotate: false,
points: Vec::new(),
}
}
#[test]
fn hover_popup_skips_fill_and_sign_marks() {
assert_eq!(annotation_hover_text(&annotation(false)).as_deref(), Some("FreeText\nHello"));
assert_eq!(annotation_hover_text(&annotation(true)), None);
}
fn signature(signed: bool, visible: bool, page: Option<usize>, rect: Option<[f64; 4]>) -> pdfcraft_engine::SignatureInfo {
pdfcraft_engine::SignatureInfo {
field: "Signature1".into(),
+2 -1
View File
@@ -5,6 +5,7 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Stroke};
use pdfcraft_render::{RenderConfig, RenderRequest, RequestKind, Tile};
use crate::canvas::{DocView, PageXform};
use crate::page_cursor::Layer;
use crate::page_pointer::PagePointer;
use crate::{PdfCraftApp, QuickTool};
@@ -16,7 +17,7 @@ pub type Marquee = (usize, Rect, [f32; 4]);
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView) {
let (pointer, origin) = (ptr.pos, ptr.origin);
if pointer.is_some_and(|p| xf.rect.contains(p)) {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
}
if resp.drag_started()
&& let Some(o) = origin.filter(|o| xf.rect.contains(*o))
+1
View File
@@ -75,6 +75,7 @@ use canvas::autoscroll;
use canvas::crop;
use canvas::fling;
use canvas::image_pick;
use canvas::page_cursor;
use canvas::page_pointer;
pub use canvas::scroll_speed;
use canvas::signature_drag;
+5 -4
View File
@@ -5,6 +5,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::theme::Tokens;
pub use pdfcraft_engine::camera_clean::Look;
@@ -59,11 +60,11 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 {
}
/// Corner dragging on one page. A press on a page without the quadrilateral moves it there.
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool) {
pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool) {
let (pointer, origin) = (ptr.pos, ptr.origin);
if !allowed {
if pointer.is_some_and(|p| xf.rect.contains(p)) {
ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed);
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::NotAllowed);
}
return;
}
@@ -93,7 +94,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p
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));
if let Some(i) = q.grab {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing);
if let Some(p) = pointer {
let (x, y) = xf.screen_to_view(p);
if let Some(c) = q.corners.get_mut(i) {
@@ -104,7 +105,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p
q.grab = None;
}
} else if near {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab);
}
}
+3 -1
View File
@@ -35,7 +35,9 @@ use geometry::handle_pos;
pub(crate) use geometry::{caret_snap, snapped_caret};
pub(crate) use geometry::{corner_ratio, page_rotation, resized};
pub use geometry::{drawn_text_box_rect, text_box_rect};
pub(crate) use input::{fill_grabs, keys, nudge_keys, page_after_text, page_input, select_grabs};
#[cfg(test)]
use input::hover_tip;
pub(crate) use input::{describe, fill_grabs, keys, nudge_keys, page_after_text, page_input, select_grabs};
pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid};
#[cfg(test)]
use menu::{RECENT_CUSTOM, same_colour};
+72 -26
View File
@@ -4,7 +4,8 @@ use super::geometry::{
};
use super::paint::{drawn_shape, edit_anchor, erase, new_comment};
use super::state::{Composer, ComposerKind, Gesture, PageCx};
use super::{Annotation, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2};
use super::{Annotation, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2};
use crate::page_cursor::{Layer, Subject};
/// Comment input on one page, before text selection runs. Returns `true` when the pointer
/// gesture belongs to commenting (text selection must ignore it).
@@ -108,6 +109,46 @@ fn on_handle(cx: &PageCx<'_>, view: &DocView, p: Pos2, pick: impl Fn(&Annotation
})
}
/// 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))
}
/// The tip for the comment under the pointer, with any tool, beside that tool's own cursor: Acrobat
/// shows a comment's tooltip whatever tool is active. Not with Fill & Sign, whose placement stays
/// clear; not while drawing freehand, where it would cover the next stroke (#429); and not while a
/// comment is moved, resized or drawn.
pub(crate) fn describe(cx: &PageCx<'_>, view: &mut DocView) {
let freehand = matches!(cx.tool, QuickTool::Comment(CommentTool::Ink | CommentTool::Eraser));
if matches!(cx.tool, QuickTool::Fill(_)) || freehand || view.comments.gesture.is_some() {
return;
}
let Some(p) = cx.ptr.pos else { return };
// Inspection sorts comments by position, but the renderer paints them in `/Annots` order: the
// tip is that of the one drawn on top, among those with something to say (#3620).
let top = cx
.comments()
.filter(|a| cx.screen_rects(a).any(|r| r.contains(p)))
.filter_map(|a| hover_tip(a, cx.prefs).map(|text| (a.index, text)))
.max_by_key(|(index, _)| *index);
if let Some((_, text)) = top {
view.cursor.describe(Subject::Comment, text);
}
}
/// What hovering `a` says, as Acrobat's popups do. Replies and Fill & Sign's marks (typed text,
/// checks, signatures) say nothing: they are page content being edited, not comments. Neither
/// does a highlight with no text of its own, when the preferences say so.
pub(super) fn hover_tip(a: &Annotation, prefs: &CommentPrefs) -> Option<String> {
let empty_highlight = a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty);
if a.is_reply() || a.fill_sign || prefs.hide_empty_highlight_hover && empty_highlight {
return None;
}
let who = a.author.clone().unwrap_or_else(|| a.subtype.clone());
let body = a.contents.clone().unwrap_or_default();
Some(if body.is_empty() { who } else { format!("{who}\n{body}") })
}
/// The Select tool: hover, select, move, resize, edit, delete.
fn select_input(
ui: &egui::Ui,
@@ -132,17 +173,26 @@ fn select_input(
let mut consumed = false;
if let Some(p) = pointer {
if point_at(p).is_some() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
view.cursor.claim(Layer::Handle, egui::CursorIcon::Crosshair);
} else if let Some(h) = handle_at(p) {
ui.ctx().set_cursor_icon(match h {
(0, _) => egui::CursorIcon::ResizeVertical,
(_, 0) => egui::CursorIcon::ResizeHorizontal,
(-1, -1) | (1, 1) => egui::CursorIcon::ResizeNwSe,
_ => egui::CursorIcon::ResizeNeSw,
});
} else if let Some(a) = cx.hit(p) {
view.cursor.claim(
Layer::Handle,
match h {
(0, _) => egui::CursorIcon::ResizeVertical,
(_, 0) => egui::CursorIcon::ResizeHorizontal,
(-1, -1) | (1, 1) => egui::CursorIcon::ResizeNwSe,
_ => egui::CursorIcon::ResizeNeSw,
},
);
} else if let Some(a) = cx.hit(p)
// A click on a highlight over a link follows the link, so the link's hand and tip
// show there.
&& !(a.subtype == "Highlight" && on_link(cx, p))
{
let movable = cx.allowed && !is_markup(&a.subtype);
ui.ctx().set_cursor_icon(if movable { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand });
let icon = if movable { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand };
// Signatures and Fill & Sign's marks are placed on the page rather than comments on it.
view.cursor.claim(if a.fill_sign { Layer::Placed } else { Layer::Comment }, icon);
}
}
if resp.drag_started()
@@ -216,12 +266,8 @@ fn select_input(
// (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 {
let follow_link = cx.tool == QuickTool::Select
&& !consumed
&& cv.gesture.is_none()
&& !resp.double_clicked()
&& pointer
.is_some_and(|p| cx.info.links.iter().any(|link| link.page == cx.page && cx.xf.user_rect(cx.info, cx.page, link.rect).contains(p)));
let follow_link =
cx.tool == QuickTool::Select && !consumed && cv.gesture.is_none() && !resp.double_clicked() && pointer.is_some_and(|p| on_link(cx, p));
match pointer.and_then(|p| cx.hit(p)) {
Some(a) if a.subtype == "Highlight" && follow_link => {
// A plain click follows the link beneath text highlighting. Keep other
@@ -301,7 +347,7 @@ impl Gestures<'_> {
return false;
}
if over_page {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
}
if resp.drag_started()
&& pressed_here
@@ -363,7 +409,7 @@ impl Gestures<'_> {
return false;
}
if over_page {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
}
let finish = resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter));
// Backspace (or Delete) takes back the last point, e.g. after a wrong click; Undo does too.
@@ -417,14 +463,14 @@ impl Gestures<'_> {
}
fn callout(&self, view: &mut DocView) -> bool {
let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self;
let Self { resp, cx, pointer, origin, page_rect, pressed_here, over_page, .. } = *self;
let cv = &mut view.comments;
// Drag from what the callout points at to where its text box goes.
if !cx.allowed {
return false;
}
if over_page {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
}
if resp.drag_started()
&& pressed_here
@@ -455,13 +501,13 @@ impl Gestures<'_> {
}
fn attach(&self, view: &mut DocView) -> bool {
let Self { ui, resp, cx, pointer, over_page, .. } = *self;
let Self { resp, cx, pointer, over_page, .. } = *self;
let cv = &mut view.comments;
if !cx.allowed {
return false;
}
if over_page {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
}
if resp.clicked()
&& over_page
@@ -474,13 +520,13 @@ impl Gestures<'_> {
}
fn caret(&self, view: &mut DocView, caret_text: Option<&pdfcraft_render::PageText>) -> bool {
let Self { ui, resp, cx, pointer, over_page, .. } = *self;
let Self { resp, cx, pointer, over_page, .. } = *self;
let cv = &mut view.comments;
if !cx.allowed {
return false;
}
if over_page {
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Text);
}
if resp.clicked()
&& over_page
@@ -496,13 +542,13 @@ impl Gestures<'_> {
}
fn note_or_box(&self, view: &mut DocView, tool: CommentTool) -> bool {
let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self;
let Self { resp, cx, pointer, origin, page_rect, pressed_here, over_page, .. } = *self;
let cv = &mut view.comments;
if !cx.allowed {
return false;
}
if over_page {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
}
// A text box also takes a drag, as in Acrobat: the box is drawn at that size and its
// text wraps inside it (#2669).
@@ -95,6 +95,44 @@ fn line(chars: &str, x: f32, y: f32, w: f32, em: f32, direction: [f32; 2]) -> Pa
PageText { glyphs, line_of: vec![0; n], space_before: vec![false; n] }
}
fn annotation(subtype: &str, contents: Option<&str>, fill_sign: bool) -> Annotation {
Annotation {
page: 0,
subtype: subtype.into(),
author: None,
contents: contents.map(str::to_string),
modified: None,
name: None,
in_reply_to: None,
parent: None,
grouped: false,
rect: [0.0; 4],
color: None,
index: 0,
state: None,
quads: Vec::new(),
locked: false,
intent: None,
fill_sign,
no_rotate: false,
points: Vec::new(),
}
}
#[test]
fn the_hover_tip_skips_fill_and_sign_marks_replies_and_bare_highlights_when_asked() {
let prefs = CommentPrefs::default();
assert_eq!(hover_tip(&annotation("FreeText", Some("Hello"), false), &prefs).as_deref(), Some("FreeText\nHello"));
assert_eq!(hover_tip(&annotation("FreeText", Some("Hello"), true), &prefs), None);
let reply = Annotation { in_reply_to: Some("1 0 R".into()), parent: Some((0, 1)), ..annotation("Text", Some("Agreed"), false) };
assert_eq!(hover_tip(&reply, &prefs), None);
let bare = Annotation { author: Some("Ana".into()), ..annotation("Highlight", None, false) };
assert_eq!(hover_tip(&bare, &prefs).as_deref(), Some("Ana"));
let hide = CommentPrefs { hide_empty_highlight_hover: true, ..CommentPrefs::default() };
assert_eq!(hover_tip(&bare, &hide), None);
assert_eq!(hover_tip(&annotation("Highlight", Some("Check this"), false), &hide).as_deref(), Some("Highlight\nCheck this"));
}
fn near(a: Option<([f32; 2], f32)>, b: ([f32; 2], f32)) -> bool {
a.is_some_and(|([x, y], s)| (x - b.0[0]).abs() < 1e-3 && (y - b.0[1]).abs() < 1e-3 && (s - b.1).abs() < 1e-3)
}
+7 -11
View File
@@ -8,6 +8,7 @@ use pdfcraft_engine::{Added, AddedContent, AddedText, Edit};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::page_pointer::PagePointer;
use crate::theme::Tokens;
use crate::widgets;
@@ -353,7 +354,7 @@ pub(crate) fn page_input(
style: &AddedText,
view: &mut DocView,
) -> bool {
let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx;
let PageToolContext { resp, xf, ptr, page, info, .. } = cx;
let mut consumed = false;
let pointer = ptr.pos;
let cv = &mut view.content;
@@ -393,13 +394,8 @@ pub(crate) fn page_input(
.and_then(|(_, si)| items.iter().find(|(i, _)| *i == si))
.map(|(_, a)| screen_rect(xf, info, page, a.content.rect()));
let corner = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(p) <= 6.0));
// Cursors only where the page shows: the mode bar and side panels over a page scrolled under
// them keep their own (#2755).
let shows = resp.contains_pointer();
if adding_text && hit.is_none() {
if shows {
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
}
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Text);
if resp.clicked() {
// Text that can't be drawn stays where it is being typed (#125).
if cv.hold_blocked() {
@@ -424,10 +420,10 @@ pub(crate) fn page_input(
}
return consumed;
}
if let Some((_, left, top)) = corner.filter(|_| shows) {
ui.ctx().set_cursor_icon(if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw });
} else if hit.is_some() && shows {
ui.ctx().set_cursor_icon(egui::CursorIcon::Move);
if let Some((_, left, top)) = corner {
view.cursor.claim(Layer::Handle, if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw });
} else if hit.is_some() {
view.cursor.claim(Layer::Placed, egui::CursorIcon::Move);
}
if resp.drag_started() {
let origin = ptr.origin.unwrap_or(p);
+9 -12
View File
@@ -12,6 +12,7 @@ use pdfcraft_engine::{Edit, ObjectKind, ObjectTarget};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::page_pointer::PagePointer;
mod background;
@@ -387,7 +388,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text
if resizing {
draw_handles(preview);
}
ui.ctx().set_cursor_icon(if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Move });
view.cursor.claim(Layer::Drag, if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Move });
if resp.drag_stopped() || !ptr.any_down {
view.block_drag = None;
let (from, to) = (user_box(xf, info, page, b), user_box(xf, info, page, preview));
@@ -436,17 +437,13 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text
draw_handles(boxes[hit]);
}
// A closed paragraph moves when dragged (the four-way arrow says so; a click still opens it
// for typing); an edge rewraps it. The open one takes the text cursor. Only where the page
// shows, so the mode bar and side panels over a paragraph scrolled under them keep their own
// cursor (#2755).
if resp.contains_pointer() {
ui.ctx().set_cursor_icon(if on_edge {
egui::CursorIcon::ResizeHorizontal
} else if active.is_none() {
egui::CursorIcon::Move
} else {
egui::CursorIcon::Text
});
// for typing); an edge rewraps it. The open one takes the text cursor.
if on_edge {
view.cursor.claim(Layer::Handle, egui::CursorIcon::ResizeHorizontal);
} else if active.is_none() {
view.cursor.claim(Layer::Text, egui::CursorIcon::Move);
} else {
view.cursor.claim(if active == Some(hit) { Layer::Editor } else { Layer::Text }, egui::CursorIcon::Text);
}
if resp.clicked() {
view.image_selection = None;
+23 -16
View File
@@ -1,4 +1,5 @@
use super::{ACCENT, Color32, CornerRadius, DocInfo, DocView, Edit, MAX_OUTLINES, PagePointer, PageXform, Pos2, Rect, Stroke, user_box};
use crate::page_cursor::Layer;
/// A selected page image, and what the pointer is doing to it.
#[derive(Clone, Debug, PartialEq)]
@@ -163,7 +164,9 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, b: Rect) -> bool {
painter.text(done.center(), egui::Align2::CENTER_CENTER, tl!("Done"), font.clone(), Color32::WHITE);
painter.rect(cancel, CornerRadius::same(11), t.panel, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
painter.text(cancel.center(), egui::Align2::CENTER_CENTER, tl!("Cancel"), font, t.text);
if let Some(p) = hover_pos {
// Cropping takes over its page's cursor, and the buttons' wherever they are; other pages
// keep theirs.
if let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p) || done.contains(*p) || cancel.contains(*p)) {
let icon = if let Some(g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0) {
match g {
0 | 3 => egui::CursorIcon::ResizeNwSe,
@@ -178,7 +181,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, b: Rect) -> bool {
} else {
egui::CursorIcon::Default
};
ui.ctx().set_cursor_icon(icon);
view.cursor.claim(Layer::Editor, icon);
}
let clicked = |r: Rect| allow_interaction && resp.clicked() && hover_pos.is_some_and(|p| r.contains(p));
let keys = |key: egui::Key| allow_interaction && ui.input(|inp| inp.key_pressed(key)) && !ui.ctx().egui_wants_keyboard_input();
@@ -350,7 +353,7 @@ fn turn_preview(cx: &ImageCx<'_>, view: &mut DocView, i: usize, b: Rect, knob: P
let label = Rect::from_center_size(turn(knob) - egui::vec2(0.0, 14.0), galley.size() + egui::vec2(8.0, 4.0));
painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle);
painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT);
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing);
if resp.drag_stopped() || !ptr.primary_down {
if let Some(s) = view.image_selection.as_mut() {
s.drag = None;
@@ -441,11 +444,14 @@ fn drag_preview(
Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, target)) });
}
}
ui.ctx().set_cursor_icon(match opposite {
Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw,
Some(_) => egui::CursorIcon::ResizeNwSe,
None => egui::CursorIcon::Move,
});
view.cursor.claim(
Layer::Drag,
match opposite {
Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw,
Some(_) => egui::CursorIcon::ResizeNwSe,
None => egui::CursorIcon::Move,
},
);
}
/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used.
@@ -503,7 +509,7 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
draw_handles(painter, b);
}
if allow_interaction && let Some(corner) = hover_pos.and_then(|p| corners.iter().position(|c| c.distance(p) < 8.0)) {
ui.ctx().set_cursor_icon(if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw });
view.cursor.claim(Layer::Handle, if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw });
}
// The rotate handle: a knob above the top edge, joined to it by a short stem.
let (knob, stem) = rotate_knob(b, ui.clip_rect());
@@ -511,7 +517,7 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
painter.line_segment([stem, knob], Stroke::new(1.0, ACCENT));
painter.circle(knob, 5.0, Color32::WHITE, Stroke::new(1.0, ACCENT));
if allow_interaction && hover_pos.is_some_and(|p| p.distance(knob) < 8.0) {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab);
}
}
if allow_interaction
@@ -580,12 +586,13 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
}
let Some(b) = boxes.get(hit).copied() else { return false };
let corner = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()].iter().position(|c| c.distance(p) < 8.0);
ui.ctx().set_cursor_icon(match (selected == Some(hit), corner) {
(true, Some(0 | 3)) => egui::CursorIcon::ResizeNwSe,
(true, Some(_)) => egui::CursorIcon::ResizeNeSw,
(true, None) => egui::CursorIcon::Move,
_ => egui::CursorIcon::PointingHand,
});
match (selected == Some(hit), corner) {
(true, Some(0 | 3)) => view.cursor.claim(Layer::Handle, egui::CursorIcon::ResizeNwSe),
(true, Some(_)) => view.cursor.claim(Layer::Handle, egui::CursorIcon::ResizeNeSw),
(true, None) => view.cursor.claim(Layer::Placed, egui::CursorIcon::Move),
// A click selects it.
_ => view.cursor.claim(Layer::Placed, egui::CursorIcon::PointingHand),
}
if resp.clicked() || resp.secondary_clicked() {
view.image_selection = Some(ImageSelection { page, index: hit, drag: None, crop: None, turning: false, settled: None });
}
+3 -2
View File
@@ -9,6 +9,7 @@ use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, Signature
use pdfcraft_render::{DocInfo, PageInfo};
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::theme::Tokens;
mod scan;
@@ -455,10 +456,10 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, view: &m
if let Some(rect) = image.rect(p, at, tool == FillTool::Initials) {
let tex = image_texture(ui, image, preview);
xf.paint_user_image(ui.painter(), tex, p, rect, i64::from(p.rotation), Color32::WHITE);
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::None);
}
} else {
ui.ctx().set_cursor_icon(if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair });
view.cursor.claim(Layer::ToolArea, if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair });
}
if !resp.clicked() {
return None;
+15 -26
View File
@@ -14,6 +14,7 @@ use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, fi
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::theme::Tokens;
const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
@@ -150,23 +151,10 @@ fn toggle_edit(f: &FormField, w: &FormWidget) -> Option<Edit> {
Some(Edit::SetFieldValue { name: f.name.clone(), value })
}
/// Where the pointer is relative to one page's form widgets.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FieldPointer {
/// Not over a field.
Away,
/// Over a field, which has set the cursor. Nothing is pressed, so the layers below still see
/// the hover, but they must not replace the cursor: a check box drawn with a glyph, or set
/// on printed text, is a button, not text to select.
Over,
/// The field owns the press or click.
Owned,
}
/// Clicks on form widgets of one page.
pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: bool, view: &mut DocView) -> FieldPointer {
/// Clicks on form widgets of one page. `true` when a field owns the press or click.
pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: bool, view: &mut DocView) -> bool {
let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx;
let Some(p) = ptr.within(xf.rect) else { return FieldPointer::Away };
let Some(p) = ptr.within(xf.rect) else { return false };
let hit = form.iter().find_map(|f| {
f.widgets
.iter()
@@ -174,7 +162,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: b
.find(|(_, w)| w.page == Some(page) && !w.hidden && widget_rect(xf, info, page, w.rect).contains(p))
.map(|(i, w)| (f, i, w))
});
let Some((f, wi, w)) = hit else { return FieldPointer::Away };
let Some((f, wi, w)) = hit else { return false };
// The field's tooltip (`/TU`), 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(tip) = f.tooltip.as_deref().filter(|t| !t.trim().is_empty()) {
@@ -185,15 +173,16 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: b
let pressable = f.kind == FormFieldKind::PushButton && !f.read_only() && f.button.is_some();
// A radio button of a group can be read-only on its own.
let usable = allowed && !w.read_only && (fillable(f) || pressable || f.kind == FormFieldKind::Signature);
// Only where the page shows: the mode bar and side panels over a field scrolled under them
// keep their own cursor (#2755).
if resp.contains_pointer() {
ui.ctx().set_cursor_icon(match (usable, f.kind) {
// Over the page's text too: a check box drawn with a glyph, or set on printed text, is a
// button, not text to select.
view.cursor.claim(
Layer::Field,
match (usable, f.kind) {
(false, _) => egui::CursorIcon::NotAllowed,
(true, FormFieldKind::Text) => egui::CursorIcon::Text,
_ => egui::CursorIcon::PointingHand,
});
}
},
);
// A text field opens on the press rather than the click, so a drag that starts on it
// selects its text (#2213).
let pressed = f.kind == FormFieldKind::Text && usable && resp.is_pointer_button_down_on() && ptr.pressed().is_some();
@@ -203,11 +192,11 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: b
view.forms.pressed = true;
}
if !resp.clicked() {
return if resp.is_pointer_button_down_on() { FieldPointer::Owned } else { FieldPointer::Over };
return resp.is_pointer_button_down_on();
}
if !allowed {
view.forms.notice = Some(FormNotice::Security);
return FieldPointer::Owned;
return true;
}
match f.kind {
_ if f.read_only() || w.read_only => view.forms.notice = Some(FormNotice::ReadOnly(f.name.clone())),
@@ -278,7 +267,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: b
}
}
}
FieldPointer::Owned
true
}
/// The edit the focused field's draft makes, if it differs from the field's value.
+5 -1
View File
@@ -7,6 +7,7 @@ use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkItem, LinkStyle};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::theme::{self, Tokens};
use crate::{PdfCraftApp, widgets};
@@ -76,7 +77,10 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, links: &[LinkItem], view: &mut
}
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));
ui.ctx().set_cursor_icon(if hit.is_some() { egui::CursorIcon::PointingHand } else { egui::CursorIcon::Crosshair });
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
{
+2 -2
View File
@@ -1,5 +1,5 @@
//! Measure objects: page-space clicks, live readings and persistent drawing scales.
use crate::{LeftPanel, PdfCraftApp, QuickTool, canvas::DocView, theme::Tokens, widgets};
use crate::{LeftPanel, PdfCraftApp, QuickTool, canvas::DocView, page_cursor::Layer, theme::Tokens, widgets};
use egui::{Color32, Pos2, Stroke};
use pdfcraft_engine::{
Document, Edit, Style,
@@ -236,7 +236,7 @@ pub(crate) fn page_input(
let mut hover = None;
let mut snap = None;
if let Some(pos) = pos {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
let (vx, vy) = xf.screen_to_view(pos);
let [x, y] = info.view_to_user(vx, vy);
let mut point = [f64::from(x), f64::from(y)];
+3 -2
View File
@@ -8,6 +8,7 @@ use pdfcraft_engine::{Document, Edit, EditableObject, MAX_MOVE_OBJECTS, ObjectKi
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
const BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
@@ -238,7 +239,7 @@ fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) -
} else {
s.gesture = Some(gesture);
}
ui.ctx().set_cursor_icon(if gesture.marquee { egui::CursorIcon::Crosshair } else { egui::CursorIcon::Move });
view.cursor.claim(Layer::Drag, if gesture.marquee { egui::CursorIcon::Crosshair } else { egui::CursorIcon::Move });
return true;
}
let Some(p) = pointer.filter(|p| xf.rect.contains(*p) && ui.clip_rect().contains(*p) && resp.rect.contains(*p)) else {
@@ -248,7 +249,7 @@ fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) -
if s.page == Some(page) && !s.selected.is_empty() {
let union = s.selected.iter().fold(Rect::NOTHING, |r, o| r.union(screen(xf, info, page, o)));
if union.contains(p) {
ui.ctx().set_cursor_icon(egui::CursorIcon::Move);
view.cursor.claim(Layer::Placed, egui::CursorIcon::Move);
}
return true;
}
+4 -3
View File
@@ -12,6 +12,7 @@ use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::comments;
use crate::page_cursor::Layer;
use crate::theme;
use crate::widgets;
@@ -340,7 +341,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti
return out;
}
if let Some(tool) = tool {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
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));
@@ -393,9 +394,9 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti
f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && screen_rect(xf, info, page, w.rect).contains(p)).map(|(i, _)| (f, i))
});
if let Some((_, left, top)) = corner {
ui.ctx().set_cursor_icon(if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw });
view.cursor.claim(Layer::Handle, if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw });
} else if hit.is_some() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Move);
view.cursor.claim(Layer::Field, egui::CursorIcon::Move);
}
if resp.drag_started() {
let origin = ptr.origin.unwrap_or(p);
+3 -2
View File
@@ -8,6 +8,7 @@ use pdfcraft_engine::{Edit, NewAnnotation, REDACT_PATTERNS, REDACTION_CODE_SETS,
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::theme::Tokens;
use crate::{PdfCraftApp, widgets};
@@ -235,7 +236,7 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] {
/// 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 {
let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx;
let PageToolContext { resp, xf, ptr, page, info, .. } = cx;
let pointer = ptr.pos;
if let Some((dp, start)) = view.redact_drag
&& dp == page
@@ -253,7 +254,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bo
}
let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false };
if !over_text(p) {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
}
if resp.drag_started() {
let origin = ptr.origin.unwrap_or(p);
+2 -1
View File
@@ -10,6 +10,7 @@ use pdfcraft_engine::{SignOptions, SignatureImage, SignatureInfo, SignatureStatu
use crate::canvas::{DocView, PageXform, 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};
@@ -173,7 +174,7 @@ pub(crate) fn page_input(
) {
let (pointer, origin) = (ptr.pos, ptr.origin);
if pointer.is_some_and(|p| xf.rect.contains(p)) {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
}
if resp.drag_started()
&& let Some(o) = origin.filter(|o| xf.rect.contains(*o))
+2
View File
@@ -3975,12 +3975,14 @@ fn dragging_the_rotate_knob_turns_a_page_image() {
let to = egui::pos2(c.x + 100.0, c.y);
h.hover_at(knob);
h.run_steps(1);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grab, "the knob offers to turn the picture");
h.drag_at(knob);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(knob + (to - knob) * (k as f32 / 4.0));
h.run_steps(1);
}
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "and holds it while it turns");
h.drop_at(to);
h.run_steps(4);
let doc = h.state().session.get(id).unwrap();
+87 -6
View File
@@ -3,12 +3,14 @@
//! dialog, the palette, the quick bar, the chrome over a page scrolled under it) lets the page's
//! cursor, tips or presses through.
//!
//! The ignored tests pin live bugs that later steps of the page pointer router fix: one cursor
//! writer (step 2), one hit test (step 3), and press-time capture with cancel (step 4).
//! The ignored tests pin live bugs that later steps of the page pointer router fix: one hit test
//! (step 3), and press-time capture with cancel (step 4).
use egui_kittest::Harness;
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 crate::support::input::{coalesced_click, double_click, one_frame, touch_drag, touch_tap};
@@ -76,6 +78,18 @@ fn comment_on_text_pdf() -> Vec<u8> {
])
}
/// A 300×400 page with two rectangle comments overlapping at x 100..150, y 200..250: "Bottom
/// note" first in `/Annots`, then "Top note" over it.
fn stacked_comments_pdf() -> Vec<u8> {
pdf(&[
CATALOG,
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
"<< /Type /Annot /Subtype /Square /Rect [50 150 150 250] /C [1 0 0] /F 4 /T (Ana) /Contents (Bottom note) >>",
"<< /Type /Annot /Subtype /Square /Rect [100 200 200 300] /C [0 0 1] /F 4 /T (Ben) /Contents (Top note) >>",
])
}
/// A 300×400 page with one line of text at x 40, y 300.
fn text_pdf() -> Vec<u8> {
let text = stream(LINE);
@@ -173,6 +187,12 @@ fn agree(h: &Harness<'_, PdfCraftApp>) -> Vec<String> {
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "agree").unwrap().value.clone()
}
/// Whether the page's hover tip shows and says `text`.
fn tip_says(h: &Harness<'_, PdfCraftApp>, text: &str) -> bool {
let Some(tip) = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-hover"))).filter(|_| tip_shown(h)) else { return false };
h.query_all_by_label_contains(text).any(|n| tip.contains(n.rect().center()))
}
/// Hovers `p` until `want` accepts the cursor there (text layers and paragraph boxes arrive from
/// worker threads), and returns the last cursor seen.
fn wait_for_cursor(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2, want: impl Fn(egui::CursorIcon) -> bool) -> egui::CursorIcon {
@@ -191,7 +211,6 @@ fn wait_for_cursor(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2, want: impl F
// The cursor promises one thing and a press does another.
#[test]
#[ignore = "step 2 of the page pointer router: one cursor writer"]
fn stamp_tool_over_a_link_shows_its_crosshair() {
let mut h = open(link_pdf(), "150", false);
stamp_tool(&mut h);
@@ -205,7 +224,6 @@ fn stamp_tool_over_a_link_shows_its_crosshair() {
}
#[test]
#[ignore = "step 2 of the page pointer router: one cursor writer"]
fn rectangle_tool_over_a_link_shows_its_crosshair() {
let mut h = open(link_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle);
@@ -218,7 +236,6 @@ fn rectangle_tool_over_a_link_shows_its_crosshair() {
}
#[test]
#[ignore = "step 2 of the page pointer router: one cursor writer"]
fn edit_text_over_a_linked_word_offers_the_move_arrow() {
let mut h = open(linked_text_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::EditText;
@@ -242,7 +259,6 @@ fn edit_text_over_a_linked_word_offers_the_move_arrow() {
}
#[test]
#[ignore = "step 2 of the page pointer router: one cursor writer"]
fn select_tool_over_a_comment_on_text_offers_the_move_arrow() {
let mut h = open(comment_on_text_pdf(), "150", false);
// Inside the rectangle where there is no text, and inside it over a word.
@@ -259,6 +275,71 @@ fn select_tool_over_a_comment_on_text_offers_the_move_arrow() {
assert_eq!(over_text, egui::CursorIcon::Move, "so it offers the arrow where text lies under it too");
}
#[test]
fn a_links_tip_shows_only_with_its_hand() {
let mut h = open(link_pdf(), "150", false);
let on_link = at(&h, 150.0, 325.0);
let hover = |h: &mut Harness<'_, PdfCraftApp>, tool: QuickTool, nudge: f32| {
h.state_mut().quick_tool = tool;
h.run_steps(4);
// A new position each time, so the hover is fresh.
let cursor = cursor_at(h, on_link + egui::vec2(nudge, 0.0));
h.run_steps(2);
(cursor, tip_says(h, "https://example.org/"))
};
use egui::CursorIcon::{Crosshair, PointingHand};
assert_eq!(hover(&mut h, QuickTool::Select, 0.0), (PointingHand, true), "the Select tool follows the link, and the tip says where to");
assert_eq!(hover(&mut h, QuickTool::Link, 1.0), (PointingHand, true), "the Link tool edits it");
let stamp = QuickTool::Stamp(StampKind::DynApproved);
assert_eq!(hover(&mut h, stamp, 2.0), (Crosshair, false), "a click places a stamp, so neither hand nor tip offers the link");
let rectangle = QuickTool::Comment(CommentTool::Rectangle);
assert_eq!(hover(&mut h, rectangle, 3.0), (Crosshair, false), "a drag draws a rectangle");
}
/// Over two comments the tip names the one drawn on top (#3620), and a click should select that
/// one too. The renderer paints in `/Annots` order, but the hit test takes the last comment in
/// inspection's order, which is by top edge, and selects the one underneath.
#[test]
#[ignore = "step 3 of the page pointer router: one hit test"]
fn a_click_on_stacked_comments_selects_the_one_drawn_on_top() {
let mut h = open(stacked_comments_pdf(), "150", false);
let overlap = at(&h, 125.0, 225.0);
assert_eq!(cursor_at(&mut h, overlap), egui::CursorIcon::Move);
h.run_steps(2);
assert!(tip_says(&h, "Top note") && !tip_says(&h, "Bottom note"), "the tip names the comment drawn on top");
mouse_click(&mut h, overlap);
assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "a click selects it too");
}
#[test]
fn a_comments_tip_shows_with_every_tool_beside_its_cursor() {
let mut h = open(comment_on_text_pdf(), "150", false);
// Inside the rectangle comment by "Reviewer": where there is no text, and over a word.
let (blank, on_text) = (at(&h, 120.0, 315.0), at(&h, 60.0, 303.0));
let hover = |h: &mut Harness<'_, PdfCraftApp>, tool: QuickTool, p: egui::Pos2, want: egui::CursorIcon| {
h.state_mut().quick_tool = tool;
h.run_steps(4);
let cursor = wait_for_cursor(h, p, |c| c == want);
h.run_steps(2);
(cursor, tip_says(h, "Reviewer"))
};
use egui::CursorIcon::{Crosshair, Move, Text};
// A new position each time, so the hover is fresh.
let nudge = |n: f32| blank + egui::vec2(n, 0.0);
assert_eq!(hover(&mut h, QuickTool::Select, blank, Move), (Move, true), "the Select tool picks the comment up");
let stamp = QuickTool::Stamp(StampKind::DynApproved);
assert_eq!(hover(&mut h, stamp, nudge(1.0), Crosshair), (Crosshair, true), "a stamp keeps its crosshair, and the comment still says whose it is");
let rectangle = QuickTool::Comment(CommentTool::Rectangle);
assert_eq!(hover(&mut h, rectangle, nudge(2.0), Crosshair), (Crosshair, true), "so does a drawing tool");
let highlight = QuickTool::Comment(CommentTool::Highlight);
assert_eq!(hover(&mut h, highlight, on_text, Text), (Text, true), "and the highlighter its I-beam");
// Not while drawing freehand, where it would cover the next stroke (#429), nor with Fill & Sign.
let ink = QuickTool::Comment(CommentTool::Ink);
assert_eq!(hover(&mut h, ink, nudge(3.0), Crosshair), (Crosshair, false), "freehand");
let fill = QuickTool::Fill(FillTool::Check);
assert_eq!(hover(&mut h, fill, nudge(4.0), Crosshair), (Crosshair, false), "Fill & Sign");
}
/// Narrows the paragraph by dragging its left edge 100 pt inward, starting `dx` screen points
/// left of the edge. Returns the cursor shown at the press point and the line count after.
fn narrow_from_left(dx: f32) -> (egui::CursorIcon, usize) {
+50 -8
View File
@@ -369,13 +369,25 @@ fn clicking_an_empty_signature_field_signs_it() {
let dir = dir().join("field");
std::fs::create_dir_all(&dir).unwrap();
let mut h = harness(dir.clone());
sign_the_empty_field(&mut h);
let s = h.state();
let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap();
assert!(sig.signed && sig.visible);
}
/// The centre of `WITH_FIELD`'s signature field, opened as the second document.
fn field_centre(h: &Harness<'static, PdfCraftApp>) -> Pos2 {
let r = h.state().views[1].page_screen_rect(0).expect("on screen");
pos2(r.left() + 150.0 / 300.0 * r.width(), r.top() + (200.0 - 50.0) / 200.0 * r.height())
}
/// Opens `WITH_FIELD` as the second document and signs its empty field with the test ID, by
/// clicking the field.
fn sign_the_empty_field(h: &mut Harness<'static, PdfCraftApp>) {
h.state_mut().open_bytes("form.pdf", None, WITH_FIELD.to_vec()).unwrap();
h.state_mut().active = Some(1);
h.run_steps(6);
let p = {
let r = h.state().views[1].page_screen_rect(0).expect("on screen");
pos2(r.left() + 150.0 / 300.0 * r.width(), r.top() + (200.0 - 50.0) / 200.0 * r.height())
};
let p = field_centre(h);
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
@@ -399,11 +411,41 @@ fn clicking_an_empty_signature_field_signs_it() {
d.password = "test".into();
}
h.run_steps(2);
support::outside_menus(&h, "Sign").click();
support::outside_menus(h, "Sign").click();
h.run_steps(6);
let s = h.state();
let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap();
assert!(sig.signed && sig.visible);
}
/// Whether the page's hover tip shows and says `text`.
fn tip_says(h: &Harness<'static, PdfCraftApp>, text: &str) -> bool {
let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("canvas-hover"));
let Some(tip) = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-hover")).filter(|_| m.areas().is_visible(&layer))) else { return false };
h.query_all_by_label_contains(text).any(|n| tip.contains(n.rect().center()))
}
/// A signed field's validation badge is the viewer's own mark, not part of the page: its verdict
/// is the tip with any tool, beside that tool's cursor.
#[test]
fn a_signature_badge_says_its_verdict_with_any_tool() {
let dir = dir().join("badge");
std::fs::create_dir_all(&dir).unwrap();
let mut h = harness(dir.clone());
sign_the_empty_field(&mut h);
let verdict = {
let s = h.state();
let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap();
sig.summary()
};
let p = field_centre(&h);
let hover = |h: &mut Harness<'static, PdfCraftApp>, tool: QuickTool, at: Pos2| {
h.state_mut().quick_tool = tool;
h.run_steps(4);
h.hover_at(at);
h.run_steps(4);
(h.output().platform_output.cursor_icon, tip_says(h, verdict))
};
assert!(hover(&mut h, QuickTool::Select, p).1, "the Select tool shows the verdict");
let stamp = QuickTool::Stamp(pdfcraft_engine::StampKind::DynApproved);
assert_eq!(hover(&mut h, stamp, p + egui::vec2(1.0, 0.0)), (egui::CursorIcon::Crosshair, true), "so does a stamp, beside its crosshair");
}
#[test]
+2 -2
View File
@@ -1202,8 +1202,8 @@ milestone = "M3"
status = "shipped"
commands = []
tools = []
evidence = ["crates/ui-egui/tests/it/comments.rs::hovering_a_comment_shows_its_author_and_text", "crates/ui-egui/tests/it/comments.rs::multiline_markup_hover_stays_on_marked_lines", "crates/ui-egui/tests/it/comments.rs::neighboring_markup_hover_changes_to_its_own_comment", "crates/ui-egui/tests/it/comments.rs::overlapping_markup_hover_shows_the_topmost_comment", "crates/ui-egui/tests/it/comments.rs::overlapping_markup_hover_uses_annotation_index_with_different_top_edges", "crates/ui-egui/tests/it/comments.rs::markup_hover_without_quads_keeps_the_rectangle_fallback", "crates/ui-egui/tests/it/comments.rs::markup_hover_skips_popup_containers", "crates/ui-egui/tests/it/comments.rs::preference_hides_author_only_highlight_hover_previews"]
notes = "A popup with the author and comment text. Text markup uses selection's per-quad screen bounds, including page and view rotation, so unmarked parts of a multiline Rect do not claim the hover (#3541). The last-painted eligible comment wins where annotations overlap (#3620), using the original /Annots index independently of spatial inspection order. Missing complete quads retain the Rect fallback; author-only highlight hiding and dialog suppression remain. Bounds are rectangular, not exact hit testing of skewed quadrilaterals. Pointer feedback is UI-only, reachable through the opt-in control channel, with no document automation tool."
evidence = ["crates/ui-egui/tests/it/comments.rs::hovering_a_comment_shows_its_author_and_text", "crates/ui-egui/tests/it/pointer.rs::a_comments_tip_shows_with_every_tool_beside_its_cursor", "crates/ui-egui/tests/it/comments.rs::multiline_markup_hover_stays_on_marked_lines", "crates/ui-egui/tests/it/comments.rs::neighboring_markup_hover_changes_to_its_own_comment", "crates/ui-egui/tests/it/comments.rs::overlapping_markup_hover_shows_the_topmost_comment", "crates/ui-egui/tests/it/comments.rs::overlapping_markup_hover_uses_annotation_index_with_different_top_edges", "crates/ui-egui/tests/it/comments.rs::markup_hover_without_quads_keeps_the_rectangle_fallback", "crates/ui-egui/tests/it/comments.rs::markup_hover_skips_popup_containers", "crates/ui-egui/tests/it/comments.rs::preference_hides_author_only_highlight_hover_previews"]
notes = "A popup with the author and comment text, as Acrobat shows it: with every tool, beside that tool's own cursor (Acrobat does the same with Select, Hand, Add comment, Highlight, Rectangle and Stamp); not with Fill & Sign, while drawing freehand (#429) or while a comment is moved, resized or drawn. Text markup uses selection's per-quad screen bounds, including page and view rotation, so unmarked parts of a multiline Rect do not claim the hover (#3541). The last-painted eligible comment wins where annotations overlap (#3620), using the original /Annots index independently of spatial inspection order. Missing complete quads retain the Rect fallback; author-only highlight hiding and dialog suppression remain. Bounds are rectangular, not exact hit testing of skewed quadrilaterals. Still missing: Acrobat's hover outline on a highlight with every tool, and its hit on an unfilled shape's border only. Pointer feedback is UI-only, reachable through the opt-in control channel, with no document automation tool."
[[feature]]
id = "view.page-size-hover"