mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 18:47:22 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1615,6 +1615,7 @@ mod revert_draft_tests {
|
||||
calendar: None,
|
||||
calendar_rect: None,
|
||||
calendar_text: None,
|
||||
had_focus: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user