Integration: fix(ui): one page pointer per frame, so taps work and nothing over a page lets it through (#2685)

This commit is contained in:
aiongg
2026-10-24 22:48:11 +00:00
27 changed files with 1679 additions and 182 deletions
+1
View File
@@ -21,6 +21,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
- See `plan/README.md` §Clean-room and `plan/adr/0001`.
- **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.
- **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.
+2 -3
View File
@@ -724,10 +724,9 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
/// The calculation order on page `page`: the links of the field in focus, with the fields it has
/// nothing to do with faded, and the steps of the focus and its linked fields; with nothing in
/// focus, the steps of the fields whose place is wrong. A form with many calculations stays
/// readable either way. Returns the field under the pointer.
/// readable either way. Returns the field under the pages' `pointer`.
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint_page(
ui: &egui::Ui,
painter: &egui::Painter,
xf: &PageXform,
page: usize,
@@ -736,6 +735,7 @@ pub(crate) fn paint_page(
view: &CalcOrderView,
generation: u64,
selected: Option<&str>,
pointer: Option<Pos2>,
) -> Option<String> {
let listed = view.current(generation)?;
// Each field's first widget on this page.
@@ -749,7 +749,6 @@ pub(crate) fn paint_page(
if on_page.is_empty() {
return None;
}
let pointer = ui.input(|i| i.pointer.hover_pos());
let hovered = pointer.and_then(|p| on_page.iter().rev().find(|(_, r)| r.contains(p))).map(|(n, _)| (*n).to_owned());
let at: HashMap<&str, Rect> = on_page.iter().copied().collect();
let zoom = xf.zoom().clamp(0.5, 3.0);
+3 -3
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_pointer::PagePointer;
use crate::theme::Tokens;
pub use pdfcraft_engine::camera_clean::Look;
@@ -58,9 +59,8 @@ 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, page: usize, view: &mut DocView, allowed: bool) {
let pointer = ui.input(|i| i.pointer.hover_pos());
let origin = ui.input(|i| i.pointer.press_origin());
pub(crate) fn page_input(ui: &egui::Ui, 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);
+78 -66
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_pointer::PagePointer;
use crate::theme::{self, Tokens};
use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets};
@@ -2791,7 +2792,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
None => {}
}
// No dialog, close prompt or palette over the page: only then does page input count.
let unobstructed = !app.modal_open() && !app.palette_open;
let modal = app.modal_open();
let unobstructed = !modal && !app.palette_open;
let organize_zoom = app.organize_zoom;
let view = &mut app.views[index];
if view.organize {
@@ -2835,11 +2837,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// Full screen: a click on the document goes forward a page and a shift-click back, as in
// Acrobat, unless the click did something else (a link, a word selected). Decided below,
// once the pages have had the click.
let present_click = view.presentation.is_some()
&& navigation_enabled
&& ui.rect_contains_pointer(navigation_rect)
&& ui.input(|i| i.pointer.primary_clicked())
&& app.quick_tool == QuickTool::Select;
let present_click =
view.presentation.is_some() && navigation_enabled && presentation_click(ui, navigation_rect) && app.quick_tool == QuickTool::Select;
// Shift as held now, or as the click's own event carried it (a synthetic click).
let present_back = present_click
&& ui.input(|i| {
@@ -2850,7 +2849,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
});
if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && navigation_enabled && ui.rect_contains_pointer(navigation_rect) {
let z = view.zoom * zoom_delta;
match ui.input(|i| i.pointer.hover_pos()) {
match zoom_anchor(ui) {
Some(p) => view.zoom_at(z, p),
None => view.set_zoom(z),
}
@@ -2866,7 +2865,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// Drawing a comment: the pointer in a band along any edge (or just past it) scrolls the view
// that way, so a point beyond what is shown can be reached without letting go of the tool.
let edge_delta = if unobstructed && matches!(view.comments.gesture, Some(comments::Gesture::Draw { .. })) {
let (pointer, dt) = ui.input(|i| (if i.pointer.any_down() { i.pointer.latest_pos() } else { i.pointer.hover_pos() }, i.stable_dt));
let (pointer, dt) = drawing_edge_pointer(ui);
crate::autoscroll::edge_scroll(pointer, avail, dt)
} else {
Vec2::ZERO
@@ -3006,7 +3005,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates;
let over_page = ui.rect_contains_pointer(avail);
ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page));
let interrupted = ui.input(|i| i.pointer.any_pressed() || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. })));
let interrupted = glide_interrupted(ui);
if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 {
view.fling.stop();
}
@@ -3164,15 +3163,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let mut open_signature = false;
let mut hover_text: Option<(Pos2, String)> = None;
let mut badge_hover: Option<(Pos2, String)> = None;
// A dialog, save prompt or palette covers the page. Hover tips are drawn above those
// windows, so a comment under the pointer would otherwise float over the dialog.
let page_covered = app.dialog.is_some()
|| app.close_request.is_some()
|| app.palette_open
|| app.password_prompt.is_some()
|| app.pending_link.is_some()
|| app.pending_remote_link.is_some()
|| app.updates.open;
let mut clicked_link: Option<LinkTarget> = None;
let mut remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> = None;
let mut canvas_action: Option<comments::CanvasAction> = None;
@@ -3261,11 +3251,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let mut best_overlap = -1.0f32;
let mut current_overlap = -1.0f32;
let mut current_height = f32::INFINITY;
let pointer = ui.input(|i| i.pointer.hover_pos());
// The pointer as the pages see it: nothing over them (a dialog from the frame it opens),
// or a press they hold. The page modules read it rather than egui's raw pointer state.
let ptr = PagePointer::read(ui, &resp, modal);
let pointer = ptr.pos;
// A press on a page makes it the current one (as in Acrobat), so a page command with
// no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page
// gone to earlier that still shows whole beside it.
let pressed = unobstructed && ui.input(|i| i.pointer.primary_pressed()) && ui.rect_contains_pointer(avail);
let pressed = unobstructed && ptr.pressed().is_some();
let mut pressed_page = None;
if resp.secondary_clicked() {
// Set again below when the right-click lands on a link.
@@ -3421,14 +3414,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
view.text_background.paint(painter, &xf, i, view.line_editor.as_ref(), view.content.draft.as_ref());
if editing_content {
let page_tex = view.pages.get(&i).map(|p| p.tex.id());
view.move_background.paint(ui, painter, &xf, i, info, &view.content, page_tex);
view.move_background.paint(painter, &xf, i, info, &view.content, page_tex, pointer);
}
if view.show_page_gaps {
painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside);
}
// Comments: tools, selection, moving and resizing come before text selection.
let pcx = comments::PageCx { page: i, xf: &xf, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
let pcx = comments::PageCx { page: i, xf: &xf, ptr: &ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
if let QuickTool::Measure(measure_tool) = tool {
crate::measure_ui::page_input(ui, &resp, doc, view, &pcx, measure_tool, units);
}
@@ -3437,7 +3430,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// every viewing mode); then comments; then text selection.
if let QuickTool::Stamp(kind) = tool
&& allowed
&& let Some(p) = ui.input(|inp| inp.pointer.hover_pos()).filter(|p| xf.rect.contains(*p))
&& let Some(p) = ptr.within(xf.rect)
{
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
if resp.clicked() {
@@ -3461,7 +3454,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
if let Some(cs) = custom_stamp.as_ref()
&& allowed
&& let Some(p) = ui.input(|inp| inp.pointer.hover_pos()).filter(|p| xf.rect.contains(*p))
&& let Some(p) = ptr.within(xf.rect)
{
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
if resp.clicked() {
@@ -3482,12 +3475,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// Over a mark already placed, the Fill & Sign tool picks it up (comments, below).
if let QuickTool::Fill(ft) = tool
&& allowed
&& !comments::fill_grabs(ui, &pcx, view)
&& !comments::fill_grabs(&pcx, view)
{
match crate::fill_sign::page_input(
ui,
&resp,
&xf,
&ptr,
i,
info,
ft,
@@ -3511,15 +3505,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
}
if matches!(tool, QuickTool::SignArea { .. }) {
crate::sign_ui::page_input(ui, &resp, &xf, i, info, view);
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, i, view);
crate::zoom_snap::page_input(ui, &resp, &xf, &ptr, i, view);
}
if tool == QuickTool::EnhanceCamera {
crate::camera_ui::page_input(ui, &resp, &xf, i, view, can_modify);
crate::camera_ui::page_input(ui, &resp, &xf, &ptr, i, view, can_modify);
}
if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, i, info, view, can_crop) {
if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, &ptr, i, info, view, can_crop) {
view.current = i;
open_boxes = true;
}
@@ -3531,7 +3525,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
};
// While a table is being set up, nothing else is drawn or moved.
let allowed = can_modify && table_preview.is_none() && open_table.is_none();
let o = crate::prepare::page_input(ui, &resp, &xf, i, info, &form, field_tool, allowed, view);
let o = crate::prepare::page_input(ui, &resp, &xf, &ptr, i, info, &form, field_tool, allowed, view);
field_props |= o.properties;
field_placed |= o.placed;
open_table = open_table.or(o.table);
@@ -3539,10 +3533,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
} else {
use crate::forms_ui::FieldPointer;
let field = if tool == QuickTool::Select
&& !(editing_content && can_modify && crate::content_ui::select_grabs(ui, &xf, i, info, &added, view))
&& !comments::select_grabs(ui, &pcx, view)
&& !(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(ui, &resp, &xf, i, info, &form, can_fill, view)
crate::forms_ui::page_input(ui, &resp, &xf, &ptr, i, info, &form, can_fill, view)
} else {
FieldPointer::Away
};
@@ -3556,11 +3550,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
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(ui, &resp, &xf, i, info, over_text, view)
crate::redact_ui::page_input(ui, &resp, &xf, &ptr, i, info, over_text, view)
};
let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, i, info, view);
let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, &ptr, i, info, view);
let on_objects =
!image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, i, doc, view);
!image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, &ptr, i, doc, view);
let lines = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify {
let generation = doc.edit_generation();
match view.edit_lines.get(&i) {
@@ -3592,6 +3586,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
ui,
&resp,
&xf,
&ptr,
i,
info,
&added,
@@ -3620,11 +3615,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
};
// 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(ui, &resp, &xf, i, doc, &lines, view, !others_own_pointer && !image_owns_pointer);
let text_used =
crate::edit_text_ui::page_input(ui, &resp, &xf, &ptr, i, doc, &lines, view, !others_own_pointer && !image_owns_pointer);
let image_used = crate::edit_text_ui::image_input(
ui,
&resp,
&xf,
&ptr,
i,
info,
&images,
@@ -3634,7 +3631,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
);
!others_own_pointer && (text_used || image_used)
};
let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, i, info, &doc_links, view);
let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, &ptr, i, info, &doc_links, view);
if tool == QuickTool::Select
&& resp.secondary_clicked()
&& can_modify
@@ -3712,23 +3709,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
} else if over_text && cursor_free {
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
}
let press_here = ui
.input(|inp| {
inp.pointer.press_origin().or_else(|| {
// A press and release can arrive in one pass; egui has already
// cleared press_origin, but the press still starts multi-click selection.
inp.events.iter().find_map(|event| match event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, .. }
if pos.is_finite() =>
{
Some(*pos)
}
_ => None,
})
})
})
.is_some_and(|o| r.contains(o));
let pressed = ui.input(|inp| inp.pointer.primary_pressed());
// The pages' press, kept when its release came in the same frame (#2100): it
// still starts multi-click selection.
let press_here = ptr.origin.is_some_and(|o| r.contains(o));
let pressed = ptr.pressed().is_some();
if pressed && press_here && !over_link {
let now = ui.input(|inp| inp.time);
// The same limits egui uses to count clicks on release.
@@ -3753,7 +3737,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// re-drag over text whose bar Copy closed would keep the bar hidden.
view.actions_dismissed = None;
view.selection_drag = true;
let origin = ui.input(|inp| inp.pointer.press_origin()).unwrap_or(p);
let origin = ptr.origin.unwrap_or(p);
let (ox, oy) = xf.screen_to_view(origin);
if view.clicks < 2 {
view.selection = if column {
@@ -3843,11 +3827,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
crate::redact_ui::after_text(&resp, i, info, view);
}
if area_tool {
crate::redact_ui::paint(ui, painter, i, view);
crate::redact_ui::paint(painter, i, view, pointer);
}
comments::paint_page(ui, painter, &pcx, view);
if editing_content {
crate::content_ui::paint_page(ui, painter, &xf, i, info, &added, view);
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);
@@ -3856,21 +3840,23 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
crate::camera_ui::paint(painter, &xf, i, view);
}
if tool == QuickTool::Link {
crate::link_ui::paint(ui, painter, &xf, i, info, &doc_links, view);
crate::link_ui::paint(painter, &xf, i, info, &doc_links, view, pointer);
}
if preparing {
crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view);
crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view, pointer);
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(name) = crate::calc_order_ui::paint_page(ui, painter, &xf, i, info, &form, &view.calc_order, generation, selected) {
if let Some(name) =
crate::calc_order_ui::paint_page(painter, &xf, i, info, &form, &view.calc_order, generation, selected, pointer)
{
calc_hover = Some(name);
}
}
} else {
crate::forms_ui::paint_page(ui, painter, &xf, i, info, &form, view);
crate::forms_ui::paint_page(painter, &xf, i, info, &form, view, pointer);
}
// Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a
@@ -3899,8 +3885,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
}
// Fill & Sign keeps its placement cursor clear of link/comment hover feedback.
if !page_covered
&& !matches!(tool, QuickTool::Fill(_))
if !matches!(tool, QuickTool::Fill(_))
&& let Some(p) = pointer
{
for l in info.links.iter().filter(|l| l.page == i) {
@@ -4022,7 +4007,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
&& navigation_enabled
&& unobstructed
&& !single_paging
&& let Some(p) = ui.input(|i| i.pointer.latest_pos())
&& let Some(p) = ptr.pos
{
let dy = crate::autoscroll::edge_delta(p.y, avail, ui.input(|i| i.stable_dt));
if dy != 0.0 {
@@ -4307,7 +4292,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
view.frame_visible = visible_now.iter().copied().collect();
if let Some((pos, text)) = hover_text {
egui::Area::new(egui::Id::new("canvas-hover")).order(egui::Order::Tooltip).fixed_pos(pos + vec2(14.0, 16.0)).show(ui.ctx(), |ui| {
// Click-through, as a tooltip is: the pages lose the pointer under anything that isn't.
let tip = egui::Area::new(egui::Id::new("canvas-hover")).order(egui::Order::Tooltip).interactable(false);
tip.fixed_pos(pos + vec2(14.0, 16.0)).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_max_width(320.0);
ui.label(text);
@@ -4729,6 +4716,31 @@ fn set_layers(app: &mut PdfCraftApp, index: usize, changes: &[(LayerOp, (u32, u1
}
}
/// Where a pinch or ⌘-scroll zooms the view about: the pointer, wherever it is over the view. Not
/// page input: it is read before the pages' widget exists, once `rect_contains_pointer` has found
/// the view under the pointer.
fn zoom_anchor(ui: &egui::Ui) -> Option<Pos2> {
ui.input(|i| i.pointer.hover_pos())
}
/// The pointer and frame time that scroll the view while a comment is drawn. Not page input: it is
/// read before the pages' widget exists, and the pointer may be past the view's edge.
fn drawing_edge_pointer(ui: &egui::Ui) -> (Option<Pos2>, f32) {
ui.input(|i| (i.pointer.latest_pos(), i.stable_dt))
}
/// A press anywhere, or a key, stops the touchpad's glide (#531).
fn glide_interrupted(ui: &egui::Ui) -> bool {
ui.input(|i| i.pointer.any_pressed() || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. })))
}
/// Full screen's click on the document, which turns the page: a primary click this frame with the
/// view on top under it in `area`. Not page input: it is read before the pages' widget exists, and
/// the turn waits until the pages have had the click.
fn presentation_click(ui: &egui::Ui, area: Rect) -> bool {
ui.rect_contains_pointer(area) && ui.input(|i| i.pointer.primary_clicked())
}
/// Whether `p` lies in the bottom-left quadrant of `area`, where the page size shows (#1855).
fn in_size_hint_zone(area: Rect, p: Pos2) -> bool {
p.x >= area.min.x && p.x < area.center().x && p.y > area.center().y && p.y <= area.max.y
+30 -18
View File
@@ -17,6 +17,7 @@ use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewS
use pdfcraft_render::{Annotation, DocInfo, PageInfo, PageText};
use crate::canvas::{DocView, PageXform};
use crate::page_pointer::PagePointer;
use crate::theme::{self, Tokens};
use crate::{QuickTool, icons};
@@ -594,6 +595,8 @@ pub enum SortBy {
pub(crate) struct PageCx<'a> {
pub page: usize,
pub xf: &'a PageXform,
/// The pointer as the pages see it this frame.
pub ptr: &'a PagePointer,
pub info: &'a DocInfo,
pub tool: QuickTool,
pub prefs: &'a CommentPrefs,
@@ -808,13 +811,12 @@ pub(crate) fn resized(r: Rect, (hx, hy): (i8, i8), d: egui::Vec2, aspect_ratio:
/// Comment input on one page, before text selection runs. Returns `true` when the pointer
/// gesture belongs to commenting (text selection must ignore it).
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) -> bool {
let pointer = ui.input(|i| i.pointer.hover_pos());
let origin = ui.input(|i| i.pointer.press_origin());
let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin);
let page_rect = cx.xf.rect;
let pressed_here = origin.is_some_and(|o| page_rect.contains(o));
let over_page = pointer.is_some_and(|p| page_rect.contains(p));
let fill_grab = fill_grabs(ui, cx, view);
let draw_grab = draw_grabs(ui, cx, view);
let fill_grab = fill_grabs(cx, view);
let draw_grab = draw_grabs(cx, view);
let caret_text = if cx.tool == QuickTool::Comment(CommentTool::Caret) { view.page_text(cx.page) } else { None };
let cv = &mut view.comments;
if cv.gesture.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
@@ -1083,20 +1085,20 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>,
/// moves it and the selected one's handles resize it. Elsewhere the tool places its mark. While
/// the button is down the press decides; otherwise the pointer (egui clears the press origin on
/// release, so a click is located by the pointer).
pub(crate) fn fill_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool {
matches!(cx.tool, QuickTool::Fill(_)) && grabs(ui, cx, view, |a| a.fill_sign)
pub(crate) fn fill_grabs(cx: &PageCx<'_>, view: &DocView) -> bool {
matches!(cx.tool, QuickTool::Fill(_)) && grabs(cx, view, |a| a.fill_sign)
}
/// With the Select tool, a comment that would move (not text markup) is picked up before a form
/// field under it, so a signature placed over a field can still be dragged off it.
pub(crate) fn select_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool {
cx.tool == QuickTool::Select && grabs(ui, cx, view, |a| !is_markup(&a.subtype))
pub(crate) fn select_grabs(cx: &PageCx<'_>, view: &DocView) -> bool {
cx.tool == QuickTool::Select && grabs(cx, view, |a| !is_markup(&a.subtype))
}
/// With a shape tool (rectangle, oval, line, polygon…), the selected comment's resize handles
/// are picked up as with the Select tool, so the shape just drawn can be resized straight away
/// (#2338). Only the handles: a press anywhere else, the shape itself included, still draws.
pub(crate) fn draw_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool {
pub(crate) fn draw_grabs(cx: &PageCx<'_>, view: &DocView) -> bool {
let QuickTool::Comment(tool) = cx.tool else { return false };
if !(tool.draws() || tool.clicks_points()) || tool == CommentTool::Eraser || cx.hidden {
return false;
@@ -1109,12 +1111,12 @@ pub(crate) fn draw_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool
if cv.gesture.is_some() {
return false;
}
press_point(ui, cx).is_some_and(|p| on_handle(cx, view, p, |_| true))
press_point(cx).is_some_and(|p| on_handle(cx, view, p, |_| true))
}
/// Whether the press (or, with no button down, the pointer) is on a `pick` comment or on the
/// selected one's resize handles.
fn grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotation) -> bool) -> bool {
fn grabs(cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotation) -> bool) -> bool {
if cx.hidden {
return false;
}
@@ -1122,15 +1124,14 @@ fn grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotati
{
return true;
}
let Some(p) = press_point(ui, cx) else { return false };
let Some(p) = press_point(cx) else { return false };
on_handle(cx, view, p, &pick) || cx.hit(p).is_some_and(pick)
}
/// Where a gesture on this page is decided: the press while the button is down, otherwise the
/// pointer (egui clears the press origin on release). `None` off the page.
fn press_point(ui: &egui::Ui, cx: &PageCx<'_>) -> Option<Pos2> {
let (pointer, origin, down) = ui.input(|i| (i.pointer.hover_pos(), i.pointer.press_origin(), i.pointer.any_down()));
(if down { origin } else { pointer }).filter(|p| cx.xf.rect.contains(*p))
fn press_point(cx: &PageCx<'_>) -> Option<Pos2> {
(if cx.ptr.any_down { cx.ptr.origin } else { cx.ptr.pos }).filter(|p| cx.xf.rect.contains(*p))
}
/// Whether `p` is on a resize handle of the selected comment, when it is a resizable `pick`.
@@ -1320,7 +1321,7 @@ pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut
/// Paint comment selection, hover and gesture previews on a page.
pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) {
let cv = &view.comments;
let pointer = ui.input(|i| i.pointer.hover_pos());
let pointer = cx.ptr.pos;
// A redaction mark shows its fill while the pointer is over it, as Acrobat's does, so the
// marked area reads before anything is applied.
if cv.gesture.is_none()
@@ -1471,7 +1472,7 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin
CommentTool::Polygon | CommentTool::PolyLine | CommentTool::Cloud => {
// The vertices so far, and a rubber band to the pointer.
let mut line = pts.clone();
if let Some(p) = painter.ctx().input(|i| i.pointer.hover_pos()) {
if let Some(p) = cx.ptr.pos {
line.push(p);
}
painter.add(egui::Shape::line(line, stroke));
@@ -1582,7 +1583,17 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
}
return None;
};
let cx = PageCx { page, xf: &xf, info, tool: QuickTool::Select, prefs, allowed: true, hidden: false, redact_mark: ([0.89, 0.13, 0.13], 1.0) };
let cx = PageCx {
page,
xf: &xf,
ptr: &PagePointer::default(),
info,
tool: QuickTool::Select,
prefs,
allowed: true,
hidden: false,
redact_mark: ([0.89, 0.13, 0.13], 1.0),
};
let anchor = cx.to_screen(c.at);
let viewport = view.viewport_rect();
let t = Tokens::get(ctx);
@@ -2328,6 +2339,7 @@ mod tests {
let cx = PageCx {
page: 0,
xf: &xf,
ptr: &PagePointer::default(),
info: &info,
tool: QuickTool::Select,
prefs: &prefs,
+22 -13
View File
@@ -8,6 +8,7 @@ use pdfcraft_engine::{Added, AddedContent, AddedText, Edit};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
use crate::page_pointer::PagePointer;
use crate::theme::Tokens;
use crate::widgets;
@@ -198,17 +199,18 @@ impl MoveBackground {
}
/// The page without the moving item, then the item's pixels taken from `page_tex` (the page
/// as rendered, item included where it was) at the dragged or applied position.
/// as rendered, item included where it was) at the dragged (to the pages' `pointer`) or
/// applied position.
#[allow(clippy::too_many_arguments)]
pub fn paint(
&self,
ui: &egui::Ui,
painter: &egui::Painter,
xf: &PageXform,
page: usize,
info: &DocInfo,
cv: &ContentView,
page_tex: Option<egui::TextureId>,
pointer: Option<Pos2>,
) {
let (Some((_, kp, _)), Some(background), Some(page_tex), Some(from)) = (self.key, &self.texture, page_tex, self.from) else { return };
if kp != page {
@@ -216,7 +218,7 @@ impl MoveBackground {
}
let r = screen_rect(xf, info, page, from);
let to = if let Some((_, grab, start)) = cv.grab {
let Some(p) = ui.input(|i| i.pointer.hover_pos()) else { return };
let Some(p) = pointer else { return };
dragged(r, grab, p - start, self.picture)
} else if let Some(to) = self.settling {
let to = screen_rect(xf, info, page, to);
@@ -302,14 +304,12 @@ fn dragged(r: Rect, grab: Grab, d: egui::Vec2, keep_aspect: bool) -> Rect {
}
/// Added text and images keep pointer ownership over a form field while editing content.
pub(crate) fn select_grabs(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, added: &[Added], view: &DocView) -> bool {
pub(crate) fn select_grabs(xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, added: &[Added], view: &DocView) -> bool {
let cv = &view.content;
if cv.grab.is_some_and(|(grab_page, _, _)| grab_page == page) {
return true;
}
let Some(p) = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())).filter(|p| xf.rect.contains(*p)) else {
return false;
};
let Some(p) = ptr.within(xf.rect) else { return false };
on_page(added, page).into_iter().any(|(index, item)| {
let rect = screen_rect(xf, info, page, item.content.rect());
rect.expand(2.0).contains(p)
@@ -324,6 +324,7 @@ pub(crate) fn page_input(
ui: &egui::Ui,
resp: &egui::Response,
xf: &PageXform,
ptr: &PagePointer,
page: usize,
info: &DocInfo,
added: &[Added],
@@ -333,13 +334,13 @@ pub(crate) fn page_input(
view: &mut DocView,
) -> bool {
let mut consumed = false;
let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos()));
let pointer = ptr.pos;
let cv = &mut view.content;
if let Some((gp, grab, start)) = cv.grab
&& gp == page
{
consumed = true;
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
if resp.drag_stopped() || !ptr.primary_down {
cv.grab = None;
let end = pointer.unwrap_or(start);
if let Some((sp, si)) = cv.selected
@@ -408,7 +409,7 @@ pub(crate) fn page_input(
ui.ctx().set_cursor_icon(egui::CursorIcon::Move);
}
if resp.drag_started() {
let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p);
let origin = ptr.origin.unwrap_or(p);
if let Some((_, left, top)) = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(origin) <= 6.0)) {
cv.grab = Some((page, Grab::Corner(left, top), origin));
consumed = true;
@@ -450,10 +451,18 @@ pub(crate) fn page_input(
consumed || hit.is_some() && resp.is_pointer_button_down_on()
}
/// Outlines of added items (hover and selection with handles) and the box being dragged.
pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, added: &[Added], view: &DocView) {
/// Outlines of added items (hover and selection with handles) and the box being dragged, the
/// pages' `pointer` over them.
pub(crate) fn paint_page(
painter: &egui::Painter,
xf: &PageXform,
page: usize,
info: &DocInfo,
added: &[Added],
view: &DocView,
pointer: Option<Pos2>,
) {
let cv = &view.content;
let pointer = ui.input(|i| i.pointer.hover_pos());
for (i, a) in on_page(added, page) {
if cv.draft.as_ref().is_some_and(|d| d.page == page && d.index == Some(i)) {
continue;
+4 -2
View File
@@ -6,6 +6,7 @@ use pdfcraft_engine::{BoxSpec, Edit, PageBox};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
use crate::page_pointer::PagePointer;
/// A crop rectangle being dragged: (page, start on screen).
pub type CropDrag = Option<(usize, Pos2)>;
@@ -17,17 +18,18 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] {
}
/// Crop gestures on one page. Returns `Some(true)` to open Set Page Boxes for this page.
#[allow(clippy::too_many_arguments)]
pub(crate) fn page_input(
ui: &egui::Ui,
resp: &egui::Response,
xf: &PageXform,
ptr: &PagePointer,
page: usize,
info: &DocInfo,
view: &mut DocView,
allowed: bool,
) -> bool {
let pointer = ui.input(|i| i.pointer.hover_pos());
let origin = ui.input(|i| i.pointer.press_origin());
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 });
}
+39 -19
View File
@@ -12,6 +12,7 @@ use pdfcraft_engine::{Edit, ObjectKind, ObjectTarget};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
use crate::page_pointer::PagePointer;
const ACCENT: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
@@ -245,6 +246,9 @@ pub struct LineEditor {
size: f32,
/// Take the keyboard focus on the next frame.
pub(crate) focus: bool,
/// Whether the text box had the keyboard at the end of the editor's last frame
/// ([`lost_focus_once`]).
had_focus: bool,
/// The Format text panel's values, and what the paragraph had (to send only changes).
pub look: pdfcraft_engine::AddedText,
look0: pdfcraft_engine::AddedText,
@@ -497,6 +501,7 @@ fn crop_input(
ui: &egui::Ui,
resp: &egui::Response,
xf: &PageXform,
ptr: &PagePointer,
page: usize,
info: &DocInfo,
view: &mut DocView,
@@ -506,12 +511,12 @@ fn crop_input(
) -> bool {
let painter = ui.painter();
let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false };
let hover_pos = ui.input(|inp| inp.pointer.hover_pos());
let hover_pos = ptr.pos;
let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b);
// A drag in progress moves a grip; the box stays inside the picture and at least 6 px.
if allow_interaction
&& resp.drag_started()
&& let Some(o) = ui.input(|inp| inp.pointer.press_origin())
&& let Some(o) = ptr.origin
{
let k = keep_screen(mode.keep);
let grip = crop_handles(k).iter().position(|h| h.distance(o) < 8.0).or_else(|| k.contains(o).then_some(8));
@@ -558,7 +563,7 @@ fn crop_input(
max.x = max.x.clamp(min.x + min_w, b.max.x);
max.y = max.y.clamp(min.y + min_h, b.max.y);
mode.keep = user_box(xf, info, page, Rect::from_min_max(min, max));
if resp.drag_stopped() || !ui.input(|inp| inp.pointer.primary_down()) {
if resp.drag_stopped() || !ptr.primary_down {
mode.drag = None;
}
}
@@ -664,7 +669,7 @@ fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::Texture
/// Once a selected corner or the rotate knob is grabbed, text under it cannot take over the gesture. The same
/// applies after the pointer leaves the image/page while dragging.
pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, view: &DocView) -> bool {
pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, view: &DocView) -> bool {
let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false };
if selection.drag.is_some() {
return true;
@@ -672,7 +677,7 @@ pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, inf
let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false };
let Some(rect) = image.visible_rect else { return false };
let b = xf.user_rect(info, page, rect.map(|v| v as f32));
let p = ui.input(|i| if i.pointer.primary_down() { i.pointer.press_origin() } else { i.pointer.hover_pos() });
let p = if ptr.primary_down { ptr.origin } else { ptr.pos };
let knob = rotate_knob(b, ui.clip_rect()).0;
p.is_some_and(|p| [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom(), knob].iter().any(|c| c.distance(p) < 8.0))
}
@@ -800,6 +805,7 @@ pub(crate) fn image_input(
ui: &egui::Ui,
resp: &egui::Response,
xf: &PageXform,
ptr: &PagePointer,
page: usize,
info: &DocInfo,
images: &[pdfcraft_engine::PageImage],
@@ -809,7 +815,7 @@ pub(crate) fn image_input(
) -> bool {
let boxes: Vec<Rect> = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).collect();
let painter = ui.painter();
let hover_pos = ui.input(|i| i.pointer.hover_pos());
let hover_pos = ptr.pos;
let image_contains = |index: usize, point: Pos2| {
let (x, y) = xf.screen_to_view(point);
let Some(p) = info.pages.get(page) else { return false };
@@ -853,7 +859,7 @@ pub(crate) fn image_input(
if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) {
let b = boxes[i];
if view.image_selection.as_ref().is_some_and(|s| s.crop.is_some()) {
return crop_input(ui, resp, xf, page, info, view, i, b, allow_interaction);
return crop_input(ui, resp, xf, ptr, page, info, view, i, b, allow_interaction);
}
let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()];
let handles = |rect: Rect| {
@@ -903,10 +909,9 @@ pub(crate) fn image_input(
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
}
let origin = ui.input(|i| i.pointer.press_origin());
if allow_interaction
&& resp.drag_started()
&& let Some(o) = origin
&& let Some(o) = ptr.origin
&& o.distance(knob) < 8.0
{
if let Some(s) = view.image_selection.as_mut() {
@@ -915,7 +920,7 @@ pub(crate) fn image_input(
}
} else if allow_interaction
&& resp.drag_started()
&& let Some(o) = origin
&& let Some(o) = ptr.origin
{
let corner = corners.iter().position(|c| c.distance(o) < 8.0);
if corner.is_some() || image_contains(i, o) {
@@ -942,7 +947,7 @@ pub(crate) fn image_input(
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);
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
if resp.drag_stopped() || !ptr.primary_down {
if let Some(s) = view.image_selection.as_mut() {
s.drag = None;
s.turning = false;
@@ -1000,7 +1005,7 @@ pub(crate) fn image_input(
painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
handles(preview);
}
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
if resp.drag_stopped() || !ptr.primary_down {
if let Some(s) = view.image_selection.as_mut() {
s.drag = None;
let norm = |r: Rect| {
@@ -1194,6 +1199,7 @@ fn open_editor(
max_width,
size: (l.size as f32 * scale).clamp(8.0, 72.0),
focus: true,
had_focus: false,
look: look_of(l),
look0: look_of(l),
family_chosen: false,
@@ -1209,6 +1215,7 @@ pub(crate) fn page_input(
ui: &egui::Ui,
resp: &egui::Response,
xf: &PageXform,
ptr: &PagePointer,
page: usize,
doc: &pdfcraft_engine::Document,
lines: &[pdfcraft_engine::TextBlock],
@@ -1256,7 +1263,7 @@ pub(crate) fn page_input(
if let Some(d) = view.block_drag.filter(|d| d.page == page)
&& let (Some(b), Some(l)) = (boxes.get(d.block).copied(), lines.get(d.block))
{
let p = ui.input(|i| i.pointer.interact_pos()).unwrap_or(d.start);
let p = ptr.pos.unwrap_or(d.start);
let dx = p.x - d.start.x;
let preview = match d.grip {
Grip::Move => b.translate(p - d.start),
@@ -1269,7 +1276,7 @@ pub(crate) fn page_input(
draw_handles(preview);
}
ui.ctx().set_cursor_icon(if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Move });
if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) {
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));
let edit = if resizing {
@@ -1301,13 +1308,13 @@ pub(crate) fn page_input(
// quick flick has left the edge by then.
if resp.drag_started()
&& active.is_none()
&& let Some(o) = ui.input(|i| i.pointer.press_origin())
&& let Some(o) = ptr.origin
&& let Some((block, grip)) = grab_at(&boxes, o, upright)
{
view.block_drag = Some(BlockDrag { page, block, generation: doc.edit_generation(), start: o, grip });
return true;
}
let Some(p) = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) else { return false };
let Some(p) = ptr.within(xf.rect) else { return false };
let Some((hit, grip)) = grab_at(&boxes, p, upright) else { return false };
if active != Some(hit) {
painter.rect_stroke(boxes[hit], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
@@ -1553,8 +1560,9 @@ fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width:
/// The inline editor; returns the edit once the text is applied.
pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option<Edit> {
// Clicks outside the document (the Format text panel) keep the paragraph open.
let outside = ctx.input(|i| i.pointer.latest_pos()).is_some_and(|p| !view.viewport_rect().contains(p));
// Clicks outside the document (the Format text panel) keep the paragraph open. Where the
// click landed is `interact_pos`: `latest_pos` is gone on the frame a tap lifts.
let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| !view.viewport_rect().contains(p));
let page = view.line_editor.as_ref()?.page;
let xf = view.page_xform(page)?;
let hold = view.objects.hold_editor;
@@ -1700,11 +1708,12 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
r.request_focus();
ed.focus = false;
}
let lost = lost_focus_once(&r, &mut ed.had_focus);
let esc = ui.input(|i| i.key_pressed(egui::Key::Escape));
let apply = ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command);
if esc {
done = Some(false);
} else if apply || (r.lost_focus() && !outside && !hold) {
} else if apply || (lost && !outside && !hold) {
done = Some(true);
}
});
@@ -1739,6 +1748,16 @@ pub(crate) fn finished_edit(ed: LineEditor) -> Option<Edit> {
})
}
/// Whether an editor's text box `r` lost the keyboard this frame, given whether it `had` it at
/// the end of the editor's last frame (updated here). egui's `lost_focus` holds for a second
/// frame as well, and an editor that judges a click away by where the click landed must judge it
/// once, on the click's frame: on the next, a tap's position is gone (egui-winit sends
/// `PointerGone` with every touch release) and a mouse may have moved on.
pub(crate) fn lost_focus_once(r: &egui::Response, had: &mut bool) -> bool {
let has = r.has_focus();
std::mem::replace(had, has) && !has
}
/// Caret colour that stays visible on a field filled with `fill`: black on light fills, white on dark ones.
pub(crate) fn caret_color_on(fill: Color32) -> Color32 {
let luma = 299 * u32::from(fill.r()) + 587 * u32::from(fill.g()) + 114 * u32::from(fill.b());
@@ -1796,6 +1815,7 @@ mod tests {
max_width: 400.0,
size: block.size as f32,
focus: true,
had_focus: false,
look: look_of(&block),
look0: look_of(&block),
family_chosen: false,
+1
View File
@@ -1615,6 +1615,7 @@ mod revert_draft_tests {
calendar: None,
calendar_rect: None,
calendar_text: None,
had_focus: false,
}
}
+3 -4
View File
@@ -9,6 +9,7 @@ use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, Signature
use pdfcraft_render::{DocInfo, PageInfo};
use crate::canvas::{DocView, PageXform};
use crate::page_pointer::PagePointer;
use crate::theme::Tokens;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -626,6 +627,7 @@ pub(crate) fn page_input(
ui: &egui::Ui,
resp: &egui::Response,
xf: &PageXform,
ptr: &PagePointer,
page: usize,
info: &DocInfo,
tool: FillTool,
@@ -637,10 +639,7 @@ pub(crate) fn page_input(
author: &str,
date_text: &Result<String, String>,
) -> Option<FillAction> {
let pointer = ui.input(|i| i.pointer.hover_pos())?;
if !resp.contains_pointer() || !xf.rect.contains(pointer) {
return None;
}
let pointer = ptr.within(xf.rect)?;
let p = info.pages.get(page)?;
let at = to_user(xf, info, page, pointer);
let saved = match tool {
+29 -16
View File
@@ -9,11 +9,12 @@
use std::collections::HashSet;
use egui::{Color32, CornerRadius, Rect, Stroke, vec2};
use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2};
use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, field_flags, form_appearance};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
use crate::page_pointer::PagePointer;
use crate::theme::Tokens;
const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
@@ -42,6 +43,9 @@ pub struct Focus {
/// The text the calendar last saw, so it moves to a typed date once, when the typing becomes
/// a whole date, and otherwise keeps the month its arrows turned to.
pub calendar_text: Option<String>,
/// Whether the text box had the keyboard at the end of the editor's last frame
/// ([`crate::edit_text_ui::lost_focus_once`]).
pub(crate) had_focus: bool,
}
#[derive(Clone, Debug, Default)]
@@ -120,6 +124,7 @@ fn open_focus(f: &FormField, widget: usize) -> Focus {
calendar: None,
calendar_rect: None,
calendar_text: None,
had_focus: false,
}
}
@@ -165,14 +170,14 @@ pub(crate) fn page_input(
ui: &egui::Ui,
resp: &egui::Response,
xf: &PageXform,
ptr: &PagePointer,
page: usize,
info: &DocInfo,
form: &[FormField],
allowed: bool,
view: &mut DocView,
) -> FieldPointer {
let pointer = ui.input(|i| i.pointer.hover_pos());
let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return FieldPointer::Away };
let Some(p) = ptr.within(xf.rect) else { return FieldPointer::Away };
let hit = form.iter().find_map(|f| {
f.widgets
.iter()
@@ -202,7 +207,7 @@ pub(crate) fn page_input(
}
// 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() && ui.input(|i| i.pointer.primary_pressed());
let pressed = f.kind == FormFieldKind::Text && usable && resp.is_pointer_button_down_on() && ptr.pressed().is_some();
if pressed && view.forms.focus.as_ref().is_none_or(|x| x.name != f.name || x.widget != wi) {
commit(view, form);
view.forms.focus = Some(Focus { press: Some(p), ..open_focus(f, wi) });
@@ -234,7 +239,7 @@ pub(crate) fn page_input(
// The press already committed the previous field and opened this one (above), unless
// press and release came in one frame. Committing again would re-run a validation that
// refused the value on the press.
FormFieldKind::Text if !ui.input(|i| i.pointer.primary_pressed()) => {
FormFieldKind::Text if ptr.pressed().is_none() => {
// An editor opened by the press cannot be hit until a frame has laid it out: the
// release lands on the page and would take its focus away.
if let Some(x) = view.forms.focus.as_mut().filter(|x| x.name == f.name && x.widget == wi) {
@@ -475,9 +480,16 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st
picked
}
/// Paint focus and hover frames on a page.
pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, form: &[FormField], view: &DocView) {
let pointer = ui.input(|i| i.pointer.hover_pos());
/// Paint focus and hover frames on a page, the pages' `pointer` over it.
pub(crate) fn paint_page(
painter: &egui::Painter,
xf: &PageXform,
page: usize,
info: &DocInfo,
form: &[FormField],
view: &DocView,
pointer: Option<Pos2>,
) {
for f in form {
for (wi, w) in f.widgets.iter().enumerate().filter(|(_, w)| w.page == Some(page) && !w.hidden) {
let r = widget_rect(xf, info, page, w.rect);
@@ -644,6 +656,10 @@ pub(crate) fn overlay(
return None;
};
let rect = widget_rect(&xf, info, page, w.rect);
// Whether a click this frame landed outside the field. Where it landed is `interact_pos`: a
// tap's release comes with `PointerGone` (egui-winit sends one with every touch release, and
// winit reports Windows pen input as touch), which clears `hover_pos`.
let clicked_outside = || ctx.input(|i| i.pointer.any_click() && !i.pointer.interact_pos().is_some_and(|p| rect.contains(p)));
let zoom = xf.zoom();
let t = Tokens::get(ctx);
let mut edit = None;
@@ -730,6 +746,7 @@ pub(crate) fn overlay(
st.store(ui.ctx(), r.id);
}
}
let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus);
let (enter, esc, tab, shift) = ui.input(|i| {
(i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift)
});
@@ -737,7 +754,7 @@ pub(crate) fn overlay(
close = true;
} else if tab {
next = Some(!shift);
} else if r.lost_focus()
} else if lost
&& (!multiline || !enter)
&& !fx.calendar_rect.is_some_and(|c| ui.input(|i| i.pointer.interact_pos().is_some_and(|p| c.contains(p))))
{
@@ -785,9 +802,7 @@ pub(crate) fn overlay(
});
});
});
let clicked_away = ctx.input(|i| i.pointer.any_click())
&& !resp.response.contains_pointer()
&& !ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| rect.contains(p));
let clicked_away = !resp.response.contains_pointer() && clicked_outside();
let (tab, shift) = ctx.input(|i| (i.key_pressed(egui::Key::Tab), i.modifiers.shift));
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
close = true;
@@ -821,8 +836,7 @@ pub(crate) fn overlay(
edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export]) });
}
}
let clicked_away = ctx.input(|i| i.pointer.any_click()) && !ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| rect.contains(p));
if clicked_away {
if clicked_outside() {
close = true;
}
}
@@ -859,8 +873,7 @@ pub(crate) fn overlay(
None
}
});
let clicked_away = ctx.input(|i| i.pointer.any_click() && !i.pointer.hover_pos().is_some_and(|p| rect.contains(p)));
if esc || clicked_away {
if esc || clicked_outside() {
close = true;
} else if tab {
next = Some(!shift);
+1
View File
@@ -101,6 +101,7 @@ pub mod icons;
pub mod last_session;
pub mod last_view;
pub mod motion;
mod page_pointer;
mod page_selection_ui;
mod pageboxes;
mod palette;
+8 -5
View File
@@ -7,6 +7,7 @@ use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkItem, LinkStyle};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
use crate::page_pointer::PagePointer;
use crate::theme::{self, Tokens};
use crate::{PdfCraftApp, widgets};
@@ -56,21 +57,23 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] {
}
/// Link tool input on one page. Returns `true` when the gesture belongs to the tool.
#[allow(clippy::too_many_arguments)]
pub(crate) fn page_input(
ui: &egui::Ui,
resp: &egui::Response,
xf: &PageXform,
ptr: &PagePointer,
page: usize,
info: &DocInfo,
links: &[LinkItem],
view: &mut DocView,
) -> bool {
let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos()));
let pointer = ptr.pos;
let lv = &mut view.links;
if let Some((dp, start)) = lv.drag
&& dp == page
{
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
if resp.drag_stopped() || !ptr.primary_down {
lv.drag = None;
let end = pointer.unwrap_or(start);
let r = Rect::from_two_pos(start, end).intersect(xf.rect);
@@ -104,7 +107,7 @@ pub(crate) fn page_input(
return true;
}
if resp.drag_started() {
let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p);
let origin = ptr.origin.unwrap_or(p);
lv.drag = Some((page, origin));
return true;
}
@@ -112,7 +115,7 @@ pub(crate) fn page_input(
}
/// Outlines of the page's links (selected one bold) and the rectangle being drawn.
pub(crate) fn paint(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, links: &[LinkItem], view: &DocView) {
pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, links: &[LinkItem], view: &DocView, pointer: Option<Pos2>) {
for l in links.iter().filter(|l| l.page == page) {
let r = screen_rect(xf, info, page, l.rect);
let selected = view.links.selected == Some((page, l.index));
@@ -123,7 +126,7 @@ pub(crate) fn paint(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page
egui::StrokeKind::Outside,
);
}
if let (Some((dp, start)), Some(p)) = (view.links.drag, ui.input(|i| i.pointer.hover_pos()))
if let (Some((dp, start)), Some(p)) = (view.links.drag, pointer)
&& dp == page
{
let r = Rect::from_two_pos(start, p);
+1 -1
View File
@@ -196,7 +196,7 @@ pub(crate) fn page_input(
let v = info.user_to_view(p[0] as f32, p[1] as f32);
xf.norm_to_screen(v[0] / xf.pw, v[1] / xf.ph)
};
let pos = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p));
let pos = cx.ptr.within(xf.rect);
let mut hover = None;
let mut snap = None;
if let Some(pos) = pos {
+25 -8
View File
@@ -8,6 +8,7 @@ use pdfcraft_engine::{Document, Edit, EditableObject, MAX_MOVE_OBJECTS, ObjectKi
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
use crate::page_pointer::PagePointer;
const BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
@@ -156,18 +157,24 @@ fn leave_single(view: &mut DocView) {
/// Returns true while group selection owns the interaction. Ordinary clicks still reach the
/// single-object text/resize tools. A text draft is finished or discarded through its editor.
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, doc: &Document, view: &mut DocView) -> bool {
pub(crate) fn page_input(
ui: &egui::Ui,
resp: &egui::Response,
xf: &PageXform,
ptr: &PagePointer,
page: usize,
doc: &Document,
view: &mut DocView,
) -> bool {
if view.objects.input_blocked {
return true;
}
if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) {
let blocked = view.objects.blocked_page == Some(page);
let modifier_press = ui.input(|i| i.pointer.primary_pressed())
&& modified(ui)
&& ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p));
let modifier_press = ptr.pressed().is_some() && modified(ui) && ptr.pos.is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p));
if blocked || modifier_press {
view.objects.hold_editor = true;
view.objects.blocked_page = ui.input(|i| i.pointer.primary_down()).then_some(page);
view.objects.blocked_page = ptr.primary_down.then_some(page);
if modifier_press {
view.objects.notice = Some(tl!("Finish or discard the text being edited before selecting several objects.").into());
}
@@ -176,14 +183,24 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p
return false;
}
let mut selection = std::mem::take(&mut view.objects);
let result = input(&mut selection, ui, resp, xf, page, doc, view);
let result = input(&mut selection, ui, resp, xf, ptr, page, doc, view);
view.objects = selection;
result
}
#[allow(clippy::too_many_arguments)]
fn input(s: &mut ObjectSelection, ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, doc: &Document, view: &mut DocView) -> bool {
let (pointer, pressed, down) = ui.input(|i| (i.pointer.interact_pos(), i.pointer.primary_pressed(), i.pointer.primary_down()));
fn input(
s: &mut ObjectSelection,
ui: &egui::Ui,
resp: &egui::Response,
xf: &PageXform,
ptr: &PagePointer,
page: usize,
doc: &Document,
view: &mut DocView,
) -> bool {
let pointer = ptr.pos;
let (pressed, down) = (ptr.pressed().is_some(), ptr.primary_down);
let info = &doc.info;
if s.page == Some(page)
&& let Some(mut gesture) = s.gesture
+399
View File
@@ -0,0 +1,399 @@
//! The pointer as the pages see it, read once per frame from the page view's content widget.
//!
//! egui knows what is drawn over the pages (a dialog, a menu, the palette, the quick bar, the
//! chrome over a page scrolled under it) and keeps a press with the widget it began on until
//! release. egui's raw pointer state knows neither, and its hover position is gone on the frame
//! a touch or a Windows pen lifts: egui-winit sends `PointerGone` with every touch release, and
//! winit reports Windows pen input as touch. So page modules read [`PagePointer`], never egui's
//! pointer positions or button states; a test below keeps it that way.
//!
//! Dialogs are drawn after the pages, so on the frame one opens egui has not met its layer yet:
//! the reading also takes whether one is open.
use egui::{Event, PointerButton, Pos2, Rect};
/// A primary-button press or release, and where it happened.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Edge {
Press(Pos2),
Release(Pos2),
}
/// How many of a frame's presses and releases are kept. A frame holds one or two (a click, a
/// double-click in a slow frame), but one after a stall can hold any number.
const MAX_EDGES: usize = 8;
/// One frame's pointer, as the pages see it.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct PagePointer {
/// Where the pointer is, while the pages are what egui has under it, or while they hold a
/// press (until the frame of its release, past a page's edge included). With a dialog open
/// only a drag the pages already have keeps it. It is egui's `interact_pos`, which still has
/// the position on the frame a touch lifts.
pub pos: Option<Pos2>,
/// Where the press the pages hold began, also on the frame of its release when the press
/// came in that same frame (egui has forgotten it by then). `None` for a press that began on
/// something else.
pub origin: Option<Pos2>,
/// This frame's primary presses and releases of a press the pages hold, in order.
pub edges: Vec<Edge>,
/// The primary button is down, wherever it was pressed: a gesture ends once it is up.
pub primary_down: bool,
/// Some button is down, wherever it was pressed.
pub any_down: bool,
}
impl PagePointer {
/// The pointer for `resp`, the content widget that holds every page, allocated in `ui`, while
/// a dialog is open over the window or not (`modal`).
pub(crate) fn read(ui: &egui::Ui, resp: &egui::Response, modal: bool) -> Self {
let (at, origin, edges, primary_down, any_down) =
ui.input(|i| (i.pointer.interact_pos(), i.pointer.press_origin(), edges(&i.events), i.pointer.primary_down(), i.pointer.any_down()));
// egui's own answer to "do the pages hold a press?": from the press on until the frame
// of its click, release or drag stop. A drag can outlive egui's press while the
// pointer is outside the window. With a dialog open only a drag counts, up to the frame
// it stops: on the frame one opens egui still gives the pages a press that lands under
// it, and its click.
let held = resp.dragged() || resp.drag_stopped() || (!modal && resp.interact_pointer_pos().is_some());
// `contains_pointer` alone also holds a few points under the edge of an Area over the
// pages (egui's interact radius), so the top layer there must be the pages' own. A
// dialog covers them from the frame it opens, before egui knows its layer.
let layer = ui.layer_id();
let on_top = |p: &Pos2| !modal && resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer);
let pos = at.filter(|p| held || on_top(p));
if !held {
return Self { pos, origin: None, edges: Vec::new(), primary_down, any_down };
}
Self { pos, origin: origin.or_else(|| first_press(&edges)), edges, primary_down, any_down }
}
/// Where the pointer is, when it is inside `r`.
pub(crate) fn within(&self, r: Rect) -> Option<Pos2> {
self.pos.filter(|p| r.contains(*p))
}
/// Where this frame's first primary press was.
pub(crate) fn pressed(&self) -> Option<Pos2> {
first_press(&self.edges)
}
}
fn first_press(edges: &[Edge]) -> Option<Pos2> {
edges.iter().find_map(|e| match e {
Edge::Press(p) => Some(*p),
Edge::Release(_) => None,
})
}
/// The first [`MAX_EDGES`] primary presses and releases among `events`, in order.
fn edges(events: &[Event]) -> Vec<Edge> {
events
.iter()
.filter_map(|e| match e {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, .. } if pos.is_finite() => {
Some(if *pressed { Edge::Press(*pos) } else { Edge::Release(*pos) })
}
_ => None,
})
.take(MAX_EDGES)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{Context, Modifiers, Sense, TouchDeviceId, TouchId, TouchPhase, pos2, vec2};
/// The pages' widget: x 0..200, y 0..200 of a 400×400 screen.
const PAGES: Rect = Rect { min: Pos2::ZERO, max: Pos2 { x: 200.0, y: 200.0 } };
/// An Area drawn over the pages' lower right corner.
const OVER: Rect = Rect { min: Pos2 { x: 120.0, y: 120.0 }, max: Pos2 { x: 260.0, y: 260.0 } };
fn button(pos: Pos2, button: PointerButton, pressed: bool) -> Event {
Event::PointerButton { pos, button, pressed, modifiers: Modifiers::NONE }
}
fn touch(phase: TouchPhase, pos: Pos2) -> Event {
Event::Touch { device_id: TouchDeviceId(1), id: TouchId(1), phase, pos, force: None }
}
/// Runs one frame of the pages' widget (and the Area over it, with `area`) and returns what
/// the pages see.
fn frame(ctx: &Context, events: Vec<Event>, area: bool) -> PagePointer {
frame_with(ctx, events, area, false)
}
/// [`frame`], with a dialog open (`modal`) whose layer egui has not met.
fn frame_with(ctx: &Context, events: Vec<Event>, area: bool, modal: bool) -> PagePointer {
let mut seen = PagePointer::default();
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), events, ..Default::default() };
let mut output = ctx.run_ui(input, |ui| {
let (_, resp) = ui.allocate_exact_size(PAGES.size(), Sense::click_and_drag());
seen = PagePointer::read(ui, &resp, modal);
if area {
egui::Area::new(egui::Id::new("over")).fixed_pos(OVER.min).show(ui.ctx(), |ui| {
ui.allocate_exact_size(OVER.size(), Sense::hover());
});
}
});
// An input-only test has no renderer to take the font texture.
output.textures_delta.clear();
seen
}
#[test]
fn a_touch_tap_keeps_its_position_on_the_frame_it_lifts() {
let ctx = Context::default();
let p = pos2(50.0, 60.0);
frame(&ctx, vec![], false);
let down = frame(&ctx, vec![touch(TouchPhase::Start, p), Event::PointerMoved(p), button(p, PointerButton::Primary, true)], false);
assert_eq!((down.pos, down.origin, down.pressed()), (Some(p), Some(p), Some(p)));
// egui-winit ends a touch with the release and `PointerGone` in one frame.
let up = frame(&ctx, vec![touch(TouchPhase::End, p), button(p, PointerButton::Primary, false), Event::PointerGone], false);
assert_eq!(ctx.input(|i| i.pointer.hover_pos()), None, "egui's hover position is gone");
assert_eq!(up.pos, Some(p), "the pages still know where the finger lifted");
assert_eq!(up.edges, [Edge::Release(p)]);
assert_eq!(frame(&ctx, vec![], false), PagePointer::default(), "and forget it the frame after");
}
#[test]
fn a_press_and_release_in_one_frame_keep_the_press() {
let ctx = Context::default();
let p = pos2(50.0, 60.0);
frame(&ctx, vec![Event::PointerMoved(p)], false);
let click = frame(&ctx, vec![button(p, PointerButton::Primary, true), button(p, PointerButton::Primary, false)], false);
assert_eq!(ctx.input(|i| i.pointer.press_origin()), None, "egui has forgotten the press");
assert_eq!(click.origin, Some(p));
assert_eq!(click.edges, [Edge::Press(p), Edge::Release(p)]);
}
#[test]
fn something_drawn_over_the_pages_hides_the_pointer() {
let ctx = Context::default();
// The Area and the widget rects egui hit-tests against come from the frame before.
frame(&ctx, vec![], true);
frame(&ctx, vec![], true);
let beside = pos2(100.0, 100.0);
assert_eq!(frame(&ctx, vec![Event::PointerMoved(beside)], true).pos, Some(beside));
// Inside the Area, and just inside its edge where egui's interact radius still finds the
// pages' widget.
for p in [pos2(160.0, 160.0), pos2(122.0, 150.0)] {
assert_eq!(frame(&ctx, vec![Event::PointerMoved(p)], true).pos, None, "{p:?}");
}
// Off the pages altogether.
assert_eq!(frame(&ctx, vec![Event::PointerMoved(pos2(300.0, 50.0))], true).pos, None);
// A press on the Area is not the pages' press, though the button is down all the same.
let p = pos2(160.0, 160.0);
let press = frame(&ctx, vec![button(p, PointerButton::Primary, true)], true);
assert_eq!(press, PagePointer { primary_down: true, any_down: true, ..PagePointer::default() });
}
#[test]
fn a_press_on_the_pages_keeps_the_pointer_until_its_release() {
let ctx = Context::default();
frame(&ctx, vec![], true);
frame(&ctx, vec![], true);
let start = pos2(40.0, 40.0);
frame(&ctx, vec![Event::PointerMoved(start), button(start, PointerButton::Primary, true)], true);
// Dragged over the Area and off the pages: still theirs.
for p in [pos2(160.0, 160.0), pos2(300.0, 50.0)] {
let held = frame(&ctx, vec![Event::PointerMoved(p)], true);
assert_eq!((held.pos, held.origin, held.primary_down), (Some(p), Some(start), true), "{p:?}");
}
let end = pos2(300.0, 60.0);
let up = frame(&ctx, vec![Event::PointerMoved(end), button(end, PointerButton::Primary, false)], true);
assert_eq!((up.pos, up.edges.as_slice()), (Some(end), [Edge::Release(end)].as_slice()), "the release is the pages' too");
assert!(!up.primary_down && !up.any_down, "and the button is up");
assert_eq!(frame(&ctx, vec![], true).pos, None, "then off the pages, the pointer is not theirs");
}
#[test]
fn a_dialog_hides_the_pointer_and_its_clicks_but_not_a_drag_the_pages_have() {
let ctx = Context::default();
let p = pos2(50.0, 50.0);
frame(&ctx, vec![Event::PointerMoved(p)], false);
assert_eq!(frame(&ctx, vec![], false).pos, Some(p));
assert_eq!(frame_with(&ctx, vec![], false, true).pos, None, "no pointer under a dialog, before egui knows its layer too");
let click = frame_with(&ctx, vec![button(p, PointerButton::Primary, true), button(p, PointerButton::Primary, false)], false, true);
assert_eq!((click.pos, click.origin, click.edges.as_slice()), (None, None, [].as_slice()), "nor a click egui gives the pages then");
// A drag the pages already have when a dialog opens goes on until its release.
frame(&ctx, vec![button(p, PointerButton::Primary, true)], false);
frame(&ctx, vec![Event::PointerMoved(pos2(80.0, 50.0))], false);
let q = pos2(110.0, 50.0);
let dragging = frame_with(&ctx, vec![Event::PointerMoved(q)], false, true);
assert_eq!((dragging.pos, dragging.origin), (Some(q), Some(p)), "a drag the pages have keeps its pointer under a dialog");
let up = frame_with(&ctx, vec![button(q, PointerButton::Primary, false)], false, true);
assert_eq!(up.edges.as_slice(), [Edge::Release(q)].as_slice(), "and its release");
}
#[test]
fn a_secondary_press_is_some_button_down_but_no_primary_edge() {
let ctx = Context::default();
let p = pos2(50.0, 60.0);
frame(&ctx, vec![Event::PointerMoved(p)], false);
let right = frame(&ctx, vec![button(p, PointerButton::Secondary, true)], false);
assert_eq!((right.pos, right.any_down, right.primary_down, right.edges.len()), (Some(p), true, false, 0));
}
#[test]
fn edges_are_primary_finite_in_order_and_capped() {
let p = pos2(1.0, 2.0);
let q = pos2(3.0, 4.0);
let events = vec![
button(p, PointerButton::Secondary, true),
button(p, PointerButton::Primary, true),
Event::PointerMoved(q),
button(pos2(f32::NAN, 0.0), PointerButton::Primary, false),
button(q, PointerButton::Primary, false),
];
assert_eq!(edges(&events), [Edge::Press(p), Edge::Release(q)]);
let many: Vec<Event> = (0..100).map(|i| button(p, PointerButton::Primary, i % 2 == 0)).collect();
assert_eq!(edges(&many).len(), MAX_EDGES);
assert_eq!(first_press(&[Edge::Release(q), Edge::Press(p), Edge::Press(q)]), Some(p));
}
/// The page modules: the per-page chain in `canvas::document_area` and what it calls.
const PAGE_MODULES: [&str; 18] = [
"canvas.rs",
"comments.rs",
"forms_ui.rs",
"prepare.rs",
"fill_sign.rs",
"sign_ui.rs",
"crop.rs",
"zoom_snap.rs",
"camera_ui.rs",
"measure_ui.rs",
"redact_ui.rs",
"link_ui.rs",
"content_ui.rs",
"edit_text_ui.rs",
"object_ui.rs",
"image_pick.rs",
"signature_drag.rs",
"calc_order_ui.rs",
];
/// egui's raw pointer state: where the pointer is, and which buttons are down or went down.
const RAW_READS: [&str; 15] = [
"hover_pos()",
"interact_pos()",
"latest_pos()",
"press_origin()",
"pointer_hover_pos()",
"pointer_interact_pos()",
"pointer_latest_pos()",
"primary_down()",
"primary_pressed()",
"primary_released()",
"primary_clicked()",
"any_down()",
"any_pressed()",
"any_released()",
"any_click()",
];
/// Functions in the page modules that are not page input and read egui's pointer state
/// themselves: (file, function, why).
const NOT_PAGE_INPUT: [(&str, &str, &str); 11] = [
("canvas.rs", "zoom_anchor", "a pinch zooms the whole view, and is read before the pages' widget exists"),
("canvas.rs", "drawing_edge_pointer", "scrolls the view at its edges, read before the pages' widget exists"),
("canvas.rs", "glide_interrupted", "a press anywhere stops the touchpad's glide"),
("canvas.rs", "presentation_click", "full screen's click to turn the page, read before the pages' widget exists"),
("canvas.rs", "page_size_hint", "the page size hint sits in the view's corner, not on a page"),
("canvas.rs", "organize_grid", "Organize Pages shows thumbnails, not pages"),
("comments.rs", "keys", "keyboard handling after the pages: a drag cancelled with Escape ends once no button is down"),
("forms_ui.rs", "overlay", "the field editor is an Area of its own over the page"),
("content_ui.rs", "editor", "the added-text editor is an Area of its own over the page"),
("edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page"),
("fill_sign.rs", "scan_tab", "the signature dialog's Scan tab"),
];
/// The function a `fn` line declares, laid out as rustfmt does (`pub`, `const` and the like
/// before `fn`).
fn declares(line: &str) -> Option<&str> {
let mut rest = line;
loop {
let before = rest;
for prefix in ["pub(crate) ", "pub(super) ", "pub ", "const ", "async ", "unsafe "] {
rest = rest.strip_prefix(prefix).unwrap_or(rest);
}
if rest == before {
break;
}
}
let rest = rest.strip_prefix("fn ")?;
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 == '_'))
}
/// 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 = Vec::new();
let mut needed = vec![false; NOT_PAGE_INPUT.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.
let code = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default();
// The top-level function, or method of an `impl`, that each line is in.
let (mut function, mut in_impl) = (None, false);
for (n, line) in code.lines().enumerate() {
if line.starts_with("impl") {
(function, in_impl) = (None, true);
} else if line == "}" {
(function, in_impl) = (None, false);
} else if let Some(name) = declares(line).or_else(|| line.strip_prefix(" ").filter(|_| in_impl).and_then(declares)) {
function = Some(name);
}
if line.trim_start().starts_with("//") {
continue;
}
for read in RAW_READS.into_iter().filter(|read| reads(line, read)) {
match NOT_PAGE_INPUT.iter().position(|(f, name, _)| *f == file && function == Some(*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
)),
}
}
}
}
for ((file, name, _), needed) in NOT_PAGE_INPUT.iter().zip(needed) {
if !needed {
problems.push(format!("src/{file}: fn {name} 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() {
for entry in std::fs::read_dir(dir).expect("source directory") {
let path = entry.expect("source entry").path();
if path.is_dir() {
dirs.push(path);
continue;
}
if path.extension().is_none_or(|ext| ext != "rs") {
continue;
}
let file =
path.strip_prefix(&src).expect("under src").components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/");
let source = std::fs::read_to_string(&path).expect("source file");
if file != "page_pointer.rs" && source.contains("PagePointer") && !PAGE_MODULES.contains(&file.as_str()) {
problems.push(format!("src/{file} takes the page pointer: add it to PAGE_MODULES."));
}
}
}
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
}
+19 -8
View File
@@ -12,6 +12,7 @@ use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
use crate::comments;
use crate::page_pointer::PagePointer;
use crate::theme;
use crate::widgets;
@@ -242,6 +243,7 @@ pub(crate) fn page_input(
ui: &egui::Ui,
resp: &egui::Response,
xf: &PageXform,
ptr: &PagePointer,
page: usize,
info: &DocInfo,
form: &[FormField],
@@ -250,7 +252,7 @@ pub(crate) fn page_input(
view: &mut DocView,
) -> Outcome {
let mut out = Outcome { consumed: false, properties: false, placed: false, table: None };
let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos()));
let pointer = ptr.pos;
let prep = &mut view.prepare;
if prep.marquee_before.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
@@ -259,7 +261,7 @@ pub(crate) fn page_input(
return out;
}
if prep.ignore_release {
if !ui.input(|i| i.pointer.primary_down()) {
if !ptr.primary_down {
prep.ignore_release = false;
}
out.consumed = true;
@@ -271,7 +273,7 @@ pub(crate) fn page_input(
&& gp == page
{
out.consumed = true;
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
if resp.drag_stopped() || !ptr.primary_down {
prep.grab = None;
let end = pointer.unwrap_or(start);
match grab {
@@ -337,7 +339,7 @@ pub(crate) fn page_input(
if let Some(tool) = tool {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
if resp.drag_started() || resp.clicked() {
let origin = ui.input(|i| i.pointer.press_origin()).filter(|o| xf.rect.contains(*o)).unwrap_or(p);
let origin = ptr.origin.filter(|o| xf.rect.contains(*o)).unwrap_or(p);
prep.grab = Some((page, Grab::Draw(origin), origin));
out.consumed = true;
if resp.clicked() {
@@ -393,7 +395,7 @@ pub(crate) fn page_input(
ui.ctx().set_cursor_icon(egui::CursorIcon::Move);
}
if resp.drag_started() {
let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p);
let origin = ptr.origin.unwrap_or(p);
if let Some((_, left, top)) = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(origin) <= 6.0)) {
prep.grab = Some((page, Grab::Corner(left, top), origin));
out.consumed = true;
@@ -538,10 +540,19 @@ pub fn arrange(form: &[FormField], anchor: &(String, usize), others: &[(String,
}
/// Fields with their light-blue fill and name tags, the selection with its handles, and the
/// rectangle being drawn or dragged.
pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, form: &[FormField], view: &DocView) {
/// rectangle being drawn or dragged to the pages' `pointer`.
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint_page(
ui: &egui::Ui,
painter: &egui::Painter,
xf: &PageXform,
page: usize,
info: &DocInfo,
form: &[FormField],
view: &DocView,
pointer: Option<Pos2>,
) {
let prep = &view.prepare;
let pointer = ui.input(|i| i.pointer.hover_pos());
let grab = prep.grab.filter(|(gp, ..)| *gp == page);
let zoom = xf.zoom().clamp(0.5, 3.0);
for f in form {
+9 -6
View File
@@ -8,6 +8,7 @@ use pdfcraft_engine::{Edit, NewAnnotation, REDACT_PATTERNS, REDACTION_CODE_SETS,
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
use crate::page_pointer::PagePointer;
use crate::theme::Tokens;
use crate::{PdfCraftApp, widgets};
@@ -234,20 +235,22 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] {
/// Redact tool input on a page. Presses on text are left to text selection (the selection is
/// marked when it ends, see [`after_text`]); presses elsewhere draw a box. Returns `true` when
/// the gesture is a box.
#[allow(clippy::too_many_arguments)]
pub(crate) fn page_input(
ui: &egui::Ui,
resp: &egui::Response,
xf: &PageXform,
ptr: &PagePointer,
page: usize,
info: &DocInfo,
over_text: impl Fn(Pos2) -> bool,
view: &mut DocView,
) -> bool {
let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos()));
let pointer = ptr.pos;
if let Some((dp, start)) = view.redact_drag
&& dp == page
{
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
if resp.drag_stopped() || !ptr.primary_down {
view.redact_drag = None;
let end = pointer.unwrap_or(start);
let r = Rect::from_two_pos(start, end).intersect(xf.rect);
@@ -263,7 +266,7 @@ pub(crate) fn page_input(
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
}
if resp.drag_started() {
let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p);
let origin = ptr.origin.unwrap_or(p);
if !over_text(origin) {
view.redact_drag = Some((page, origin));
return true;
@@ -283,9 +286,9 @@ pub(crate) fn after_text(resp: &egui::Response, page: usize, info: &DocInfo, vie
}
}
/// The box being drawn.
pub(crate) fn paint(ui: &egui::Ui, painter: &egui::Painter, page: usize, view: &DocView) {
if let (Some((dp, start)), Some(p)) = (view.redact_drag, ui.input(|i| i.pointer.hover_pos()))
/// The box being drawn, to the pages' `pointer`.
pub(crate) fn paint(painter: &egui::Painter, page: usize, view: &DocView, pointer: Option<Pos2>) {
if let (Some((dp, start)), Some(p)) = (view.redact_drag, pointer)
&& dp == page
{
let r = Rect::from_two_pos(start, p);
+11 -3
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_pointer::PagePointer;
use crate::theme::{self, Tokens};
use crate::{PdfCraftApp, icons, widgets};
@@ -151,9 +152,16 @@ pub struct SignView {
}
/// Drag a signature rectangle on one page (Digitally sign, Certify (visible signature)).
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, info: &pdfcraft_render::DocInfo, view: &mut DocView) {
let pointer = ui.input(|i| i.pointer.hover_pos());
let origin = ui.input(|i| i.pointer.press_origin());
pub(crate) fn page_input(
ui: &egui::Ui,
resp: &egui::Response,
xf: &PageXform,
ptr: &PagePointer,
page: usize,
info: &pdfcraft_render::DocInfo,
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);
}
+2 -2
View File
@@ -186,8 +186,8 @@ impl SignatureDrag {
{
return;
}
let (pointer, shift) = painter.ctx().input(|i| (i.pointer.hover_pos(), i.modifiers.shift));
let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, pending);
let shift = painter.ctx().input(|i| i.modifiers.shift);
let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), cx.ptr.pos, shift, pending);
let painter = painter.with_clip_rect(painter.clip_rect().intersect(cx.xf.rect));
cx.xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0);
match &layers.front {
+3 -3
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_pointer::PagePointer;
use crate::{PdfCraftApp, QuickTool};
/// A finished gesture: (page, the rectangle on screen, the same in page view points
@@ -12,9 +13,8 @@ use crate::{PdfCraftApp, QuickTool};
pub type Marquee = (usize, Rect, [f32; 4]);
/// Drag a rectangle on page `page` (Marquee Zoom, Snapshot).
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, view: &mut DocView) {
let pointer = ui.input(|i| i.pointer.hover_pos());
let origin = ui.input(|i| i.pointer.press_origin());
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);
}
+6 -1
View File
@@ -2315,6 +2315,9 @@ fn markup_hover_harness(pdf: Vec<u8>, view_rotation: u16) -> Harness<'static, Pd
app.open_bytes("markup.pdf", None, pdf).expect("opens synthetic markup");
app.set_option("left", "closed").unwrap();
app.set_option("panel", "none").unwrap();
// Small enough to show the whole page in every rotation: a point hovered below the window
// is not over the page.
app.set_option("zoom", "100").unwrap();
app.views[0].rotation = view_rotation;
app
});
@@ -2336,7 +2339,9 @@ fn hover_markup_at(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) {
pw: page.width,
ph: page.height,
};
xf.user_rect(info, 0, [x, y, x, y]).center()
let p = xf.user_rect(info, 0, [x, y, x, y]).center();
assert!(v.viewport_rect().contains(p), "user point ({x}, {y}) is on screen, at {p:?}");
p
};
h.hover_at(p);
h.run_steps(3);
+347
View File
@@ -0,0 +1,347 @@
//! A finger tap, or a pen tap on Windows, on an editor open over the page acts as a mouse click
//! there. The field editors and the Edit text paragraph editor are drawn over the pages, outside
//! the page pointer, and close on a click away. On the frame egui reports a tap there is no
//! hover position (egui-winit sends `PointerGone` with every touch release), so they must judge
//! where the click landed by the click's own position.
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_ui_egui::PdfCraftApp;
use crate::support::input::touch_tap;
/// A PDF of `objects`, numbered from 1 with the catalog first.
fn pdf(objects: &[&str]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
out
}
const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>";
const HELVETICA: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
/// A 300×400 page with a text field `name`, a combo box `country` and a list box `colour`
/// (Red, Green, Blue) 70 pt tall.
fn form_pdf() -> Vec<u8> {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>",
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [40 340 260 360] /P 3 0 R /F 4 >>",
"<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (country) /Opt [[(ca) (Canada)] [(fr) (France)] [(no) (Norway)]] /Rect [40 290 260 310] /P 3 0 R /F 4 >>",
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (colour) /Opt [(Red) (Green) (Blue)] /Rect [40 150 200 220] /P 3 0 R /F 4 >>",
HELVETICA,
])
}
/// A 300×400 page with one line of 12 pt text at x 40, y 300.
fn text_pdf() -> Vec<u8> {
let line = "BT /F1 12 Tf 40 300 Td (Signed and approved by the committee.) Tj ET";
let content = format!("<< /Length {} >>\nstream\n{line}\nendstream", line.len() + 1);
pdf(&[
"<< /Type /Catalog /Pages 2 0 R >>",
PAGES,
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
content.as_str(),
HELVETICA,
])
}
/// The app with `pdf` open at 150 % and its first frames run.
fn open(pdf: Vec<u8>) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("test.pdf", None, pdf).unwrap();
app.set_option("left", "closed").unwrap();
app.set_option("zoom", "150").unwrap();
app
});
for _ in 0..30 {
h.run_steps(2);
}
h
}
/// A mouse click: move, press and release in frames of their own, and the pointer stays there.
/// (`drop_at` also sends `PointerGone` a frame later, as if the mouse left the window.)
fn mouse_click(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) {
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(4);
}
/// Where field `name`'s widget is on screen.
fn field(h: &Harness<'_, PdfCraftApp>, name: &str) -> egui::Rect {
widget(h, name, 0)
}
/// Where widget `w` of field `name` is on screen.
fn widget(h: &Harness<'_, PdfCraftApp>, name: &str, w: usize) -> egui::Rect {
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
let f = doc.form.iter().find(|f| f.name == name).unwrap();
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, w).expect("on screen")
}
fn value(h: &Harness<'_, PdfCraftApp>, name: &str) -> Vec<String> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone()
}
/// The field whose editor or list is open.
fn focus(h: &Harness<'_, PdfCraftApp>) -> Option<String> {
h.state().views[0].forms.focus.as_ref().map(|f| f.name.clone())
}
/// The Green row of the list box: the second, rows being 11.5 pt under a 2 pt inset.
fn green(h: &Harness<'_, PdfCraftApp>) -> egui::Pos2 {
let r = field(h, "colour");
egui::pos2(r.center().x, r.min.y + 19.0 * r.height() / 70.0)
}
// ---------------------------------------------------------------------------------------------
// Form fields.
#[test]
fn mouse_click_opens_a_combo_box() {
// Control for the tap below.
let mut h = open(form_pdf());
let p = field(&h, "country").center();
mouse_click(&mut h, p);
assert_eq!(focus(&h).as_deref(), Some("country"), "a click opens the list of choices");
}
#[test]
fn touch_tap_opens_a_combo_box_and_picks_a_choice() {
let mut h = open(form_pdf());
let p = field(&h, "country").center();
touch_tap(&mut h, p);
assert_eq!(focus(&h).as_deref(), Some("country"), "a tap opens the list of choices, as a click does");
let france = h.get_by_label("France").rect().center();
touch_tap(&mut h, france);
assert_eq!(value(&h, "country"), ["fr"], "and a tap on a choice picks it");
}
#[test]
fn mouse_click_in_a_list_box_keeps_it_focused() {
// Control for the tap below.
let mut h = open(form_pdf());
let p = green(&h);
mouse_click(&mut h, p);
assert_eq!((value(&h, "colour"), focus(&h).as_deref()), (vec!["Green".to_string()], Some("colour")), "a click picks the row");
h.key_press(egui::Key::ArrowDown);
h.run_steps(3);
assert_eq!(value(&h, "colour"), ["Blue"], "and the list keeps the keyboard");
}
#[test]
fn touch_tap_in_a_list_box_keeps_it_focused() {
let mut h = open(form_pdf());
let p = green(&h);
touch_tap(&mut h, p);
assert_eq!(
(value(&h, "colour"), focus(&h).as_deref()),
(vec!["Green".to_string()], Some("colour")),
"a tap picks the row and keeps the list focused, as a click does"
);
h.key_press(egui::Key::ArrowDown);
h.run_steps(3);
assert_eq!(value(&h, "colour"), ["Blue"], "so the arrow keys go on from it");
}
#[test]
fn touch_tap_inside_an_open_text_field_keeps_typing_there() {
// A guard more than a probe: the text editor closes when egui takes its focus away, and
// egui judges that from the tap's own position.
let mut h = open(form_pdf());
let r = field(&h, "name");
touch_tap(&mut h, r.center());
assert_eq!(focus(&h).as_deref(), Some("name"), "a tap opens the field for typing");
touch_tap(&mut h, r.left_center() + egui::vec2(6.0, 0.0));
assert_eq!(focus(&h).as_deref(), Some("name"), "a tap inside the open field keeps it open");
h.event(egui::Event::Text("Ada".into()));
h.run_steps(1);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(value(&h, "name"), ["Ada"], "and the typing lands in it");
}
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
/// Adds a date field `due` at x 40..160, y 240..260, its calendar under it.
fn add_date_field(h: &mut Harness<'_, PdfCraftApp>) {
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
page: 0,
rect: [40.0, 240.0, 160.0, 260.0],
kind: pdfcraft_engine::NewField::Date,
name: Some("due".into()),
});
h.run_steps(4);
}
/// Next month's calendar heading, and its 15th as the date field writes it.
fn next_month(h: &Harness<'_, PdfCraftApp>) -> (String, String) {
let (y, m, _) = h.state().session.today();
let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
(format!("{} {ny}", MONTHS[(nm - 1) as usize]), format!("{nm:02}/15/{ny}"))
}
#[test]
fn mouse_click_on_a_date_fields_calendar_keeps_it_open() {
// Control for the tap below.
let mut h = open(form_pdf());
add_date_field(&mut h);
let (heading, picked) = next_month(&h);
let p = field(&h, "due").center();
mouse_click(&mut h, p);
let next = h.get_by_label("›").rect().center();
mouse_click(&mut h, next);
assert_eq!(focus(&h).as_deref(), Some("due"), "a click on the calendar's next month leaves the field open");
h.get_by_label(&heading);
let day = h.get_by_label("15").rect().center();
mouse_click(&mut h, day);
assert_eq!(value(&h, "due"), [picked], "and a click on a day picks it");
}
#[test]
fn touch_tap_on_a_date_fields_calendar_keeps_it_open() {
let mut h = open(form_pdf());
add_date_field(&mut h);
let (heading, picked) = next_month(&h);
let p = field(&h, "due").center();
touch_tap(&mut h, p);
let next = h.get_by_label("›").rect().center();
touch_tap(&mut h, next);
assert_eq!(focus(&h).as_deref(), Some("due"), "a tap on the calendar's next month leaves the field open, as a click does");
h.get_by_label(&heading);
let day = h.get_by_label("15").rect().center();
touch_tap(&mut h, day);
assert_eq!(value(&h, "due"), [picked], "and a tap on a day picks it");
}
/// A 300×400 page with a check box `agree` and a pair of radio buttons `pick`, A and then B to
/// its right, each 20 pt square. A click focuses them, so the keys reach them (#2223).
fn toggles_pdf() -> Vec<u8> {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>",
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>",
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [40 300 60 320] /P 3 0 R /F 4 /AP << /N << /Yes 8 0 R /Off 8 0 R >> >> >>",
"<< /FT /Btn /T (pick) /Ff 32768 /V /Off /Kids [6 0 R 7 0 R] >>",
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 240 60 260] /P 3 0 R /F 4 /AP << /N << /A 8 0 R /Off 8 0 R >> >> >>",
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [100 240 120 260] /P 3 0 R /F 4 /AP << /N << /B 8 0 R /Off 8 0 R >> >> >>",
"<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 0 >>\nstream\n\nendstream",
])
}
#[test]
fn mouse_click_on_a_focused_check_box_keeps_its_focus() {
// Control for the tap below.
let mut h = open(toggles_pdf());
let p = field(&h, "agree").center();
mouse_click(&mut h, p);
assert_eq!((value(&h, "agree"), focus(&h).as_deref()), (vec!["Yes".to_string()], Some("agree")), "a click ticks and focuses the box");
mouse_click(&mut h, p);
assert_eq!(focus(&h).as_deref(), Some("agree"), "a click on the focused box keeps its focus");
assert!(value(&h, "agree").is_empty(), "and clears it");
}
#[test]
fn touch_tap_on_a_focused_check_box_keeps_its_focus() {
let mut h = open(toggles_pdf());
let p = field(&h, "agree").center();
mouse_click(&mut h, p);
assert_eq!(focus(&h).as_deref(), Some("agree"), "a click focuses the box");
touch_tap(&mut h, p);
assert_eq!(focus(&h).as_deref(), Some("agree"), "a tap on the focused box keeps its focus, as a click does");
assert!(value(&h, "agree").is_empty(), "and clears it");
}
#[test]
fn mouse_click_on_a_radio_button_keeps_the_group_focused() {
// Control for the tap below.
let mut h = open(toggles_pdf());
let (a, b) = (widget(&h, "pick", 0).center(), widget(&h, "pick", 1).center());
mouse_click(&mut h, a);
assert_eq!((value(&h, "pick"), focus(&h).as_deref()), (vec!["A".to_string()], Some("pick")), "a click selects A and focuses the group");
mouse_click(&mut h, b);
assert_eq!((value(&h, "pick"), focus(&h).as_deref()), (vec!["B".to_string()], Some("pick")), "a click on B selects it in the focused group");
h.key_press(egui::Key::ArrowLeft);
h.run_steps(3);
assert_eq!(value(&h, "pick"), ["A"], "so the arrows move within the group");
}
#[test]
fn touch_tap_on_a_radio_button_keeps_the_group_focused() {
let mut h = open(toggles_pdf());
let (a, b) = (widget(&h, "pick", 0).center(), widget(&h, "pick", 1).center());
mouse_click(&mut h, a);
assert_eq!(focus(&h).as_deref(), Some("pick"), "a click focuses the group");
touch_tap(&mut h, b);
assert_eq!(focus(&h).as_deref(), Some("pick"), "a tap on B keeps the group focused, as a click does");
assert_eq!(value(&h, "pick"), ["B"], "and selects B");
h.key_press(egui::Key::ArrowLeft);
h.run_steps(3);
assert_eq!(value(&h, "pick"), ["A"], "so the arrows move within the group");
}
// ---------------------------------------------------------------------------------------------
// Edit text: a click on the Format text panel keeps the paragraph open. These open the Line
// spacing list: a control that changes the paragraph on the click gives it the keyboard back in
// that same frame, which hides a click away.
/// Opens the line of `text_pdf` for typing with Edit text.
fn edit_paragraph(h: &mut Harness<'_, PdfCraftApp>) {
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let r = h.state().views[0].page_screen_rect(0).expect("page on screen");
let k = r.width() / 300.0;
let p = r.min + egui::vec2(60.0 * k, (400.0 - 304.0) * k);
mouse_click(h, p);
assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing");
assert!(h.ctx.memory(|m| m.has_focus(egui::Id::new("edit-text-line-input"))), "with the keyboard");
}
/// The open paragraph's line spacing, if a paragraph is open.
fn line_spacing(h: &Harness<'_, PdfCraftApp>) -> Option<f64> {
h.state().views[0].line_editor.as_ref().map(|e| e.extras.line_spacing)
}
#[test]
fn mouse_click_on_format_text_keeps_the_paragraph_open() {
// Control for the tap below.
let mut h = open(text_pdf());
edit_paragraph(&mut h);
let spacing = h.get_by_value("Auto").rect().center();
mouse_click(&mut h, spacing);
assert_eq!(line_spacing(&h), Some(0.0), "a click opens the Line spacing list and leaves the paragraph open");
let double = h.get_by_label("2").rect().center();
mouse_click(&mut h, double);
assert_eq!(line_spacing(&h), Some(2.0), "to take the spacing picked");
}
#[test]
fn touch_tap_on_format_text_keeps_the_paragraph_open() {
let mut h = open(text_pdf());
edit_paragraph(&mut h);
let spacing = h.get_by_value("Auto").rect().center();
touch_tap(&mut h, spacing);
assert_eq!(line_spacing(&h), Some(0.0), "a tap opens the Line spacing list and leaves the paragraph open, as a click does");
let double = h.get_by_label("2").rect().center();
touch_tap(&mut h, double);
assert_eq!(line_spacing(&h), Some(2.0), "to take the spacing picked");
}
+2
View File
@@ -39,6 +39,7 @@ mod date_format;
mod dialog_chrome;
mod document_links;
mod editing;
mod editor_taps;
mod export;
mod fill_sign;
mod folders;
@@ -57,6 +58,7 @@ mod pageboxes;
mod panel_snapshots;
mod pdf_links;
mod pickers;
mod pointer;
mod preferences_layout;
mod prepare;
mod print;
+565
View File
@@ -0,0 +1,565 @@
//! What the page's tools see of the pointer. A finger tap, a touch drag and a click whose press
//! and release share a frame act as a mouse click and drag do, and nothing drawn over the page (a
//! 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).
use egui_kittest::Harness;
use pdfcraft_engine::StampKind;
use pdfcraft_ui_egui::comments::CommentTool;
use pdfcraft_ui_egui::{PdfCraftApp, QuickTool, UnlockPrompt};
use crate::support::input::{coalesced_click, double_click, one_frame, touch_drag, touch_tap};
/// A PDF of `objects`, numbered from 1 with the catalog first.
fn pdf(objects: &[&str]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
out
}
fn stream(content: &str) -> String {
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1)
}
const CATALOG: &str = "<< /Type /Catalog /Pages 2 0 R >>";
const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>";
const HELVETICA: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
/// One line of 12 pt Helvetica at x 40, y 300.
const LINE: &str = "BT /F1 12 Tf 40 300 Td (Signed and approved by the committee.) Tj ET";
/// One 300×400 page with a web link at x 50..250, y 300..350.
fn link_pdf() -> Vec<u8> {
pdf(&[
CATALOG,
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
"<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>",
])
}
/// A 300×400 page with a paragraph at y≈300 and a link over its first words.
fn linked_text_pdf() -> Vec<u8> {
let text = stream("BT /F1 12 Tf 40 300 Td (Read the guide at example.org before you start.) Tj ET");
pdf(&[
CATALOG,
PAGES,
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> /Annots [4 0 R] >>",
"<< /Type /Annot /Subtype /Link /Rect [40 295 160 312] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>",
text.as_str(),
HELVETICA,
])
}
/// A 300×400 page with a line of text at y≈300 and a rectangle comment over it.
fn comment_on_text_pdf() -> Vec<u8> {
let text = stream(LINE);
pdf(&[
CATALOG,
PAGES,
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> /Annots [4 0 R] >>",
"<< /Type /Annot /Subtype /Square /Rect [30 290 200 320] /C [1 0 0] /Border [0 0 2] /F 4 /T (Reviewer) >>",
text.as_str(),
HELVETICA,
])
}
/// A 300×400 page with one line of text at x 40, y 300.
fn text_pdf() -> Vec<u8> {
let text = stream(LINE);
pdf(&[CATALOG, PAGES, "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", text.as_str(), HELVETICA])
}
/// A 300×400 page with an unchecked check box `agree` at x 100..120, y 200..220.
fn checkbox_pdf() -> Vec<u8> {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/ZaDb 0 Tf 0 g) /DR << /Font << /ZaDb 5 0 R >> >> >> >>",
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [100 200 120 220] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>",
])
}
/// The app with `pdf` open at `zoom` percent and its first frames run. The left panel stays
/// open only where a test places the page relative to the window's chrome.
fn open(pdf: Vec<u8>, zoom: &'static str, left_open: bool) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("test.pdf", None, pdf).unwrap();
if !left_open {
app.set_option("left", "closed").unwrap();
}
app.set_option("zoom", zoom).unwrap();
app
});
for _ in 0..30 {
h.run_steps(2);
}
h
}
fn stamp_tool(h: &mut Harness<'_, PdfCraftApp>) {
h.state_mut().quick_tool = QuickTool::Stamp(StampKind::DynApproved);
h.run_steps(4);
}
fn page(h: &Harness<'_, PdfCraftApp>) -> egui::Rect {
h.state().views[0].page_screen_rect(0).expect("page on screen")
}
/// The screen point at PDF (x, y) on the 300×400 page.
fn at(h: &Harness<'_, PdfCraftApp>, x: f32, y: f32) -> egui::Pos2 {
let r = page(h);
let k = r.width() / 300.0;
r.min + egui::vec2(x * k, (400.0 - y) * k)
}
fn cursor_at(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) -> egui::CursorIcon {
h.hover_at(p);
h.run_steps(2);
h.output().platform_output.cursor_icon
}
/// A mouse click: press and release in frames of their own, as `drag_at` and `drop_at` send them.
fn mouse_click(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) {
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(4);
}
/// A mouse drag from `from` by `by`, held over two frames on the way.
fn mouse_drag(h: &mut Harness<'_, PdfCraftApp>, from: egui::Pos2, by: egui::Vec2) {
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
h.hover_at(from + by);
h.run_steps(2);
h.drop_at(from + by);
for _ in 0..10 {
h.run_steps(2);
}
}
fn stamps(h: &Harness<'_, PdfCraftApp>) -> usize {
let s = h.state();
s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Stamp").count()
}
fn square(h: &Harness<'_, PdfCraftApp>) -> Option<[f32; 4]> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.subtype == "Square").map(|a| a.rect)
}
fn agree(h: &Harness<'_, PdfCraftApp>) -> Vec<String> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "agree").unwrap().value.clone()
}
/// 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 {
let mut cursor = egui::CursorIcon::Default;
for _ in 0..50 {
cursor = cursor_at(h, p);
if want(cursor) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
cursor
}
// ---------------------------------------------------------------------------------------------
// 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);
let on_link = at(&h, 150.0, 325.0);
let off_link = at(&h, 150.0, 150.0);
assert_eq!(cursor_at(&mut h, off_link), egui::CursorIcon::Crosshair, "the tool's cursor off the link");
let cursor = cursor_at(&mut h, on_link);
mouse_click(&mut h, on_link);
assert_eq!((stamps(&h), h.state().pending_link.is_some()), (1, false), "a click places a stamp and does not follow the link");
assert_eq!(cursor, egui::CursorIcon::Crosshair, "so the cursor is the stamp tool's, not the link hand");
}
#[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);
h.run_steps(4);
let off_link = at(&h, 150.0, 150.0);
let off = cursor_at(&mut h, off_link);
let on_link = at(&h, 150.0, 325.0);
let on = cursor_at(&mut h, on_link);
assert_eq!(on, off, "a drag over the link draws a rectangle, as it does off the link");
}
#[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;
for _ in 0..30 {
h.run_steps(2);
std::thread::sleep(std::time::Duration::from_millis(5));
}
// One paragraph, one line: a word outside the link, then a word inside it.
let plain = at(&h, 230.0, 303.0);
let linked = at(&h, 60.0, 303.0);
let off = wait_for_cursor(&mut h, plain, |c| c == egui::CursorIcon::Move);
let on = cursor_at(&mut h, linked);
let id = h.state().views[0].id;
let before = h.state().session.get(id).unwrap().text_blocks(0)[0].rect;
let k = page(&h).width() / 300.0;
mouse_drag(&mut h, linked, egui::vec2(20.0 * k, 0.0));
let after = h.state().session.get(id).unwrap().text_blocks(0)[0].rect;
assert_eq!(off, egui::CursorIcon::Move, "a closed paragraph offers the four-way arrow (#1717)");
assert!((after[0] - before[0] - 20.0).abs() < 1.5, "a drag from the linked word moves the paragraph: {before:?} → {after:?}");
assert_eq!(on, off, "the linked words belong to the same paragraph, and a drag there moves it too");
}
#[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.
let blank = at(&h, 120.0, 315.0);
let on_text = at(&h, 60.0, 303.0);
let over_blank = cursor_at(&mut h, blank);
let over_text = wait_for_cursor(&mut h, on_text, |c| c != egui::CursorIcon::Default);
let before = square(&h).expect("the rectangle");
let k = page(&h).width() / 300.0;
mouse_drag(&mut h, on_text, egui::vec2(20.0 * k, 0.0));
let after = square(&h).expect("the rectangle");
assert_eq!(over_blank, egui::CursorIcon::Move, "a movable comment offers the four-way arrow");
assert!((after[0] - before[0] - 20.0).abs() < 1.5, "a drag from the text moves the rectangle: {before:?} → {after:?}");
assert_eq!(over_text, egui::CursorIcon::Move, "so it offers the arrow where text lies under it too");
}
/// 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) {
let mut h = open(text_pdf(), "150", false);
assert!(h.state_mut().execute("edit.edit_text"));
for _ in 0..20 {
h.run_steps(2);
std::thread::sleep(std::time::Duration::from_millis(5));
}
let id = h.state().views[0].id;
let b = h.state().session.get(id).unwrap().text_blocks(0)[0].clone();
let k = page(&h).width() / 300.0;
let grip = at(&h, b.rect[0] as f32, (b.rect[1] + b.rect[3]) as f32 / 2.0) - egui::vec2(dx, 0.0);
let cursor = cursor_at(&mut h, grip);
h.drag_at(grip);
h.run_steps(1);
for i in 1..=4 {
h.hover_at(grip + egui::vec2(25.0 * k * i as f32, 0.0));
h.run_steps(1);
}
h.drop_at(grip + egui::vec2(100.0 * k, 0.0));
for _ in 0..10 {
h.run_steps(2);
}
(cursor, h.state().session.get(id).unwrap().text_lines(0).len())
}
#[test]
#[ignore = "step 3 of the page pointer router: one hit test"]
fn a_paragraph_edge_resizes_wherever_it_shows_the_resize_cursor() {
let (inside_cursor, inside_lines) = narrow_from_left(1.0);
assert_eq!(inside_cursor, egui::CursorIcon::ResizeHorizontal);
assert!(inside_lines > 1, "control: a drag from the edge rewraps the paragraph");
let (outside_cursor, outside_lines) = narrow_from_left(5.0);
assert_eq!(outside_cursor, egui::CursorIcon::ResizeHorizontal, "the resize cursor shows 5 pt out");
assert!(outside_lines > 1, "so a drag from there rewraps it too");
}
// ---------------------------------------------------------------------------------------------
// Things drawn over the page keep the page's cursor, tips and presses off.
#[test]
fn stamp_cursor_stays_off_the_command_palette() {
let mut h = open(link_pdf(), "300", false);
stamp_tool(&mut h);
h.state_mut().palette_open = true;
h.run_steps(4);
let palette = h.ctx.memory(|m| m.area_rect(egui::Id::new("palette"))).expect("palette shown");
// On the palette's frame margin, where none of its widgets sets a cursor, over the page.
let p = egui::pos2(palette.left() + 4.0, palette.bottom() - 4.0);
assert!(page(&h).contains(p), "the palette must lie over the page here");
assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::Default);
// The page beside the palette still shows the tool.
let beside = egui::pos2(palette.left() + 4.0, palette.bottom() + 40.0);
assert!(page(&h).contains(beside));
assert_eq!(cursor_at(&mut h, beside), egui::CursorIcon::Crosshair);
}
fn wheel(h: &mut Harness<'_, PdfCraftApp>, delta: egui::Vec2) {
h.hover_at(egui::pos2(800.0, 500.0));
h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move });
for _ in 0..10 {
h.run_steps(2);
}
}
#[test]
fn stamp_cursor_stays_off_the_quick_bar() {
let mut h = open(link_pdf(), "400", true);
stamp_tool(&mut h);
// Bring the page under the quick bar's left end.
wheel(&mut h, egui::vec2(-200.0, 0.0));
let bar = h.ctx.memory(|m| m.area_rect(egui::Id::new("quick-bar"))).expect("quick bar shown");
// Inside the bar's frame margin: no button there.
let p = egui::pos2(bar.left() + 3.0, bar.bottom() - 3.0);
assert!(page(&h).contains(p), "the quick bar must lie over the page here");
assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::Default);
let below = egui::pos2(bar.left() + 3.0, bar.bottom() + 20.0);
assert!(page(&h).contains(below));
assert_eq!(cursor_at(&mut h, below), egui::CursorIcon::Crosshair);
}
#[test]
fn stamp_cursor_stays_off_the_chrome_above_a_page_scrolled_under_it() {
let mut h = open(link_pdf(), "400", true);
stamp_tool(&mut h);
let top = page(&h).top();
wheel(&mut h, egui::vec2(0.0, -300.0));
let page = page(&h);
// Above where the page started (the viewport's top edge, give or take its margin): the
// toolbar, with the page scrolled up under it. Left of the link, which shows the hand.
let x = page.left() + 20.0;
let p = egui::pos2(x, top - 45.0);
assert!(page.contains(p), "the page must extend under the chrome here");
// The toolbar keeps its own cursor (a button's hand here); the page's is gone.
assert_ne!(cursor_at(&mut h, p), egui::CursorIcon::Crosshair);
assert_eq!(cursor_at(&mut h, egui::pos2(x, top + 40.0)), egui::CursorIcon::Crosshair);
}
/// Whether the page's hover tip was drawn in the last frame.
fn tip_shown(h: &Harness<'_, PdfCraftApp>) -> bool {
let tip = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("canvas-hover"));
h.ctx.memory(|m| m.areas().is_visible(&tip))
}
/// Opens the Unlock prompt, a dialog with a backdrop over the whole window, from the next frame.
fn open_unlock_prompt(h: &mut Harness<'_, PdfCraftApp>) {
let doc = h.state().views[0].id;
h.state_mut().unlock_prompt = Some(UnlockPrompt { doc, name: "test.pdf".into(), input: String::new(), error: None, then: None });
}
#[test]
fn link_tip_stays_under_the_unlock_prompt() {
let mut h = open(link_pdf(), "300", true);
let page = page(&h);
let k = page.width() / 300.0;
// Near the link's top left corner, clear of the prompt's card in the middle of the window.
let on_link = page.min + egui::vec2(55.0 * k, (400.0 - 345.0) * k);
assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand);
h.run_steps(2);
assert!(tip_shown(&h), "the link's tip shows over an uncovered page");
open_unlock_prompt(&mut h);
h.hover_at(on_link + egui::vec2(1.0, 0.0));
for _ in 0..4 {
h.run_steps(2);
}
let card = h.ctx.memory(|m| m.area_rect(egui::Id::new("unlock")));
assert!(card.is_none_or(|c| !c.contains(on_link)), "the point must be on the backdrop, not the card: {card:?}");
assert!(!tip_shown(&h), "the link's tip must not float over the Unlock prompt");
assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand);
}
#[test]
fn the_link_tip_lets_the_pointer_through_to_the_link() {
let mut h = open(link_pdf(), "150", false);
let p = at(&h, 100.0, 340.0);
assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::PointingHand);
let tip = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-hover"))).expect("the link's tip");
// A quick move lands on the tip, drawn beside where the pointer was, and still on the link.
let onto = tip.min + egui::vec2(6.0, 6.0);
assert!(egui::Rect::from_two_pos(at(&h, 50.0, 350.0), at(&h, 250.0, 300.0)).contains(onto), "the point must be on the link");
h.hover_at(onto);
h.run_steps(1);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the tip must not take the pointer from the link");
}
// Dialogs are drawn after the pages, so on the frame one opens egui does not know its layer yet
// and still gives the pages the pointer, and a press there.
#[test]
fn the_link_tip_stays_under_a_dialog_on_the_frame_it_opens() {
let mut h = open(link_pdf(), "150", false);
let p = at(&h, 100.0, 340.0);
assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::PointingHand);
h.run_steps(2);
assert!(tip_shown(&h), "the link's tip shows over an uncovered page");
open_unlock_prompt(&mut h);
h.run_steps(1);
assert!(!tip_shown(&h), "the link's tip must not show over the Unlock prompt on the frame it opens");
}
#[test]
fn a_click_on_the_frame_a_dialog_opens_places_no_stamp() {
let mut h = open(link_pdf(), "150", false);
stamp_tool(&mut h);
let p = at(&h, 150.0, 150.0);
assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::Crosshair);
open_unlock_prompt(&mut h);
let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
one_frame(&mut h, [button(true), button(false)]);
h.run_steps(4);
assert_eq!(stamps(&h), 0, "a click in the frame the Unlock prompt opens places no stamp under it");
}
#[test]
fn a_double_click_on_a_dialog_backdrop_leaves_page_text_alone() {
let mut h = open(text_pdf(), "150", false);
let word = at(&h, 50.0, 303.0);
// The I-beam shows the page's text layer has arrived.
assert_eq!(wait_for_cursor(&mut h, word, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page text is live");
// Control: without a dialog, a double-click there selects the word.
double_click(&mut h, word);
h.run_steps(4);
assert!(h.state().views[0].selected_text().is_some_and(|t| !t.is_empty()), "control: a double-click selects the word");
// A click on blank page clears it again.
h.run_steps(30);
let p = at(&h, 150.0, 150.0);
mouse_click(&mut h, p);
h.run_steps(30);
assert!(h.state().views[0].selected_text().is_none_or(|t| t.is_empty()), "cleared before the dialog opens");
h.state_mut().set_option("dialog", "shortcuts").unwrap();
h.run_steps(6);
assert!(h.state().dialog.is_some(), "a modal dialog is open");
// On the dialog's backdrop, where the page's first word lies underneath.
double_click(&mut h, word);
h.run_steps(4);
let selected = h.state().views[0].selected_text();
assert!(selected.as_deref().is_none_or(str::is_empty), "a double-click on a dialog selects nothing on the page under it: {selected:?}");
}
// ---------------------------------------------------------------------------------------------
// Taps, touch drags and clicks within one frame. On the frame a touch (or a Windows pen) lifts,
// egui-winit has sent `PointerGone`, so there is no hover position; egui still reports the click.
#[test]
fn mouse_click_follows_a_link() {
// Control for the tap below.
let mut h = open(link_pdf(), "150", false);
let p = at(&h, 150.0, 325.0);
mouse_click(&mut h, p);
assert!(h.state().pending_link.is_some(), "a click on a link asks to open it");
}
#[test]
fn touch_tap_follows_a_link() {
let mut h = open(link_pdf(), "150", false);
let p = at(&h, 150.0, 325.0);
touch_tap(&mut h, p);
assert!(h.state().pending_link.is_some(), "a tap on a link asks to open it, as a click does");
}
#[test]
fn coalesced_click_follows_a_link() {
let mut h = open(link_pdf(), "150", false);
let p = at(&h, 150.0, 325.0);
coalesced_click(&mut h, p);
assert!(h.state().pending_link.is_some(), "a click whose press and release share a frame follows the link");
}
#[test]
fn touch_tap_places_a_stamp() {
let mut h = open(link_pdf(), "150", false);
stamp_tool(&mut h);
let p = at(&h, 150.0, 150.0);
touch_tap(&mut h, p);
assert_eq!(stamps(&h), 1, "a tap with the stamp tool places the stamp");
}
#[test]
fn coalesced_click_places_a_stamp() {
let mut h = open(link_pdf(), "150", false);
stamp_tool(&mut h);
let p = at(&h, 150.0, 150.0);
coalesced_click(&mut h, p);
assert_eq!(stamps(&h), 1, "a click whose press and release share a frame places the stamp");
}
#[test]
fn mouse_click_ticks_a_check_box() {
// Control for the tap below.
let mut h = open(checkbox_pdf(), "150", false);
let p = at(&h, 110.0, 210.0);
mouse_click(&mut h, p);
assert_eq!(agree(&h), ["Yes"], "a click ticks the box");
}
#[test]
fn touch_tap_ticks_a_check_box() {
let mut h = open(checkbox_pdf(), "150", false);
let p = at(&h, 110.0, 210.0);
touch_tap(&mut h, p);
assert_eq!(agree(&h), ["Yes"], "a tap ticks the box, as a click does");
}
#[test]
fn touch_drag_moves_a_comment_and_leaves_no_gesture_behind() {
let mut h = open(comment_on_text_pdf(), "150", false);
let k = page(&h).width() / 300.0;
let before = square(&h);
// A finger drags the rectangle 30 pt right, from a blank spot inside it.
let from = at(&h, 120.0, 315.0);
touch_drag(&mut h, from, from + egui::vec2(30.0 * k, 0.0));
let after_touch = square(&h);
assert_ne!(after_touch, before, "the touch drag moves the rectangle");
// Later, a mouse drag on empty page far below it.
let p = at(&h, 150.0, 100.0);
mouse_drag(&mut h, p, egui::vec2(10.0 * k, 10.0 * k));
assert_eq!(square(&h), after_touch, "an unrelated drag elsewhere leaves the rectangle where the touch put it");
}
// ---------------------------------------------------------------------------------------------
// Ending a gesture.
#[test]
#[ignore = "step 4 of the page pointer router: press-time capture and cancel"]
fn escape_cancels_a_signature_rectangle() {
let mut h = open(link_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::SignArea { certify: false };
h.run_steps(4);
let (a, b) = (at(&h, 60.0, 200.0), at(&h, 200.0, 120.0));
h.hover_at(a);
h.run_steps(1);
h.drag_at(a);
h.run_steps(1);
h.hover_at(b);
h.run_steps(2);
// Escape while the button is still held.
h.key_press(egui::Key::Escape);
h.run_steps(4);
let s = h.state();
assert_eq!(s.quick_tool, QuickTool::SignArea { certify: false }, "Escape abandons the rectangle; the tool stays");
assert!(s.dialog.is_none(), "and no signing dialog opens: {:?}", s.dialog);
}
+65
View File
@@ -0,0 +1,65 @@
//! Pointer input in the frames the platform delivers it in.
//!
//! `Harness::step` runs one frame for every queued event, each 0.25 s after the last, so input
//! sent through `Harness::event` (or `drag_at` and `drop_at`) never shares a frame. Real input
//! does: egui-winit turns each touch (and on Windows each pen stroke, which winit reports as
//! touch) into a `Touch` event plus pointer events in one frame, and sends `PointerGone` with
//! every release, so `hover_pos()` is `None` on the frame egui reports the tap. A slow frame
//! can also hold a whole click. These helpers push each frame's events into
//! `Harness::input_mut` and step once.
use egui::{Event, Modifiers, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase};
use egui_kittest::Harness;
/// Runs one frame with all of `events` in it, in order.
pub fn one_frame<S>(h: &mut Harness<'_, S>, events: impl IntoIterator<Item = Event>) {
h.input_mut().events.extend(events);
h.step();
}
fn button(pos: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
}
fn touch(phase: TouchPhase, pos: Pos2) -> Event {
Event::Touch { device_id: TouchDeviceId(1), id: TouchId(1), phase, pos, force: None }
}
/// A finger tap at `p`, or a pen tap on Windows: the touch starts (touch, move, press in one
/// frame), and a frame later it ends (touch, release, `PointerGone` in one frame).
pub fn touch_tap<S>(h: &mut Harness<'_, S>, p: Pos2) {
one_frame(h, [touch(TouchPhase::Start, p), Event::PointerMoved(p), button(p, true)]);
h.run_steps(1);
one_frame(h, [touch(TouchPhase::End, p), button(p, false), Event::PointerGone]);
h.run_steps(4);
}
/// A finger drag from `a` to `b` in six moves, one frame each, ending as a touch does: release
/// and `PointerGone` in one frame.
pub fn touch_drag<S>(h: &mut Harness<'_, S>, a: Pos2, b: Pos2) {
one_frame(h, [touch(TouchPhase::Start, a), Event::PointerMoved(a), button(a, true)]);
for i in 1..=6 {
let p = a + (b - a) * (i as f32 / 6.0);
one_frame(h, [touch(TouchPhase::Move, p), Event::PointerMoved(p)]);
}
one_frame(h, [touch(TouchPhase::End, b), button(b, false), Event::PointerGone]);
h.run_steps(4);
}
/// A mouse click at `p` whose press and release land in one slow frame (#2100).
pub fn coalesced_click<S>(h: &mut Harness<'_, S>, p: Pos2) {
h.hover_at(p);
h.run_steps(2);
one_frame(h, [button(p, true), button(p, false)]);
h.run_steps(4);
}
/// A double-click at `p`: two clicks, each a press and release in one frame, in consecutive
/// frames (0.25 s apart, within egui's 0.3 s double-click delay).
pub fn double_click<S>(h: &mut Harness<'_, S>, p: Pos2) {
h.hover_at(p);
h.run_steps(1);
for _ in 0..2 {
one_frame(h, [button(p, true), button(p, false)]);
}
h.run_steps(2);
}
+4 -1
View File
@@ -1,7 +1,10 @@
//! State-based waits for native preparation, which completes between UI frames.
//! State-based waits for native preparation, which completes between UI frames, and pointer
//! input in the frames the platform delivers it in ([`input`]).
use egui_kittest::Harness;
use pdfcraft_ui_egui::PdfCraftApp;
pub mod input;
/// Step frames until `ready` holds or 30 seconds pass, and say whether it held. For background work
/// (a worker-thread open, page text arriving) that a test then asserts on itself: a frame count is
/// not a time, and a busy runner may not have finished the work within one. Sleeps briefly between