Merge remote-tracking branch 'upstream/main' into HEAD

This commit is contained in:
mtlcl410 committed 2026-11-08 01:32:46 +08:00
commit 7fa6c82b9e
23 files changed
+2474 -745

No files matched your search

+1 -1
View File
@@ -173,7 +173,7 @@ PdfCraft opens files from strangers, and people trust it with their work. A malf
- **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd. `cos`, `filters`, `crypt` and `arlington` stay standalone.
- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state. `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it.
- **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim`, `crates/ui-egui/src/canvas/page_cursor.rs`) and never set it themselves. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it.
- **Page presses.** A page module decides what is under a point in one hit test (`Hit` in `crates/ui-egui/src/canvas/page_router.rs`) and acts on a press only through that router. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it.
- **Page presses.** A page module decides what is under a point in one hit test (`Hit` in `crates/ui-egui/src/canvas/page_router.rs`) and acts on a press only through that router. Modules never read the router; they are told. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it.
- **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it.
- **Fonts** are the optional build input `CRAFT_FONTS_DIR`. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty.
- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check. New workspace crates include `workspace = true` under `[lints]`. Scope `cargo clippy -p <crate> --all-targets -- -D warnings` and `cargo test -p <crate>` to the crates you touched; the workspace sweep is CI's job.
+5
View File
@@ -175,6 +175,11 @@ impl PageText {
.collect()
}
/// Index of the first glyph whose box holds a view-space point: the text under the pointer.
pub fn glyph_at(&self, x: f32, y: f32) -> Option<usize> {
self.glyphs.iter().position(|g| x >= g.rect[0] && x <= g.rect[2] && y >= g.rect[1] && y <= g.rect[3])
}
/// Index of the glyph nearest to a view-space point (for selection anchors).
///
/// A glyph whose drawn em box (`quad`) holds the point wins. Skewed or turned text has bounding
+15
View File
@@ -1965,3 +1965,18 @@ fn text_outside_the_crop_box_is_clipped() {
assert!(text.is_empty(), "expected no glyphs on {crop:?}, got {:?}", text.plain_text());
}
}
/// The glyph under a point is the first whose box holds it, edges included; between glyphs, none.
#[test]
fn the_glyph_at_a_point_is_the_first_whose_box_holds_it() {
let text = PageText {
glyphs: vec![em_box("a", 0.0, 0.0, 10.0, 10.0), em_box("b", 8.0, 0.0, 20.0, 10.0)],
line_of: vec![0, 0],
space_before: vec![false, false],
};
assert_eq!(text.glyph_at(5.0, 5.0), Some(0));
assert_eq!(text.glyph_at(9.0, 5.0), Some(0), "the first of overlapping boxes");
assert_eq!(text.glyph_at(20.0, 10.0), Some(1), "an edge is in");
assert_eq!(text.glyph_at(25.0, 5.0), None);
assert_eq!(PageText::default().glyph_at(0.0, 0.0), None);
}
+8 -2
View File
@@ -608,9 +608,15 @@ impl eframe::App for PdfCraftApp {
// opening a modal/palette, or returning to a window that lost focus.
let blocked = self.modal_open() || self.palette_open || !ctx.input(|i| i.focused);
for (index, view) in self.views.iter_mut().enumerate() {
view.objects.set_input_blocked(blocked || self.active != Some(index));
if blocked || self.active != Some(index) {
let shut = blocked || self.active != Some(index);
view.objects.set_input_blocked(shut);
if shut {
view.auto_scroll.cancel();
// Edit text and Add text's drags end while their input is blocked rather than wait, to
// land wherever the pointer is once it opens again.
if matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) {
view.cancel_edit_text_gestures();
}
}
}
// A field that refused its value this frame keeps the shortcut from saving or printing
+71 -112
View File
@@ -255,22 +255,6 @@ impl ImageObb {
}
}
/// Whether pointer `p` is on an image being cropped, its crop box or its handles (#5835).
pub(crate) fn crop_contains_point(
img: &pdfcraft_engine::PageImage,
crop: &crate::edit_text_ui::CropMode,
xf: &PageXform,
info: &DocInfo,
page: usize,
p: Pos2,
) -> bool {
let obb = ImageObb::from_image(img, xf, info, page);
let full_obb = ImageObb::from_matrix(img.matrix, xf, info, page);
crate::edit_text_ui::quad_contains(obb.corners(), p)
|| crate::edit_text_ui::quad_contains(full_obb.corners(), p)
|| crate::edit_text_ui::crop_controls_contain(img, crop, xf, info, page, p)
}
/// The page display as it was before full screen, put back on leaving it
/// ([`DocView::enter_presentation`]).
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -676,8 +660,13 @@ pub struct DocView {
pub(crate) signature_drag: crate::signature_drag::SignatureDrag,
/// What the page modules claim for the cursor this frame; `document_area` sets it once.
pub(crate) cursor: crate::page_cursor::PageCursor,
/// Who the press the pages hold belongs to.
pub(crate) router: crate::page_router::PageRouter,
/// Who the press the pages hold belongs to. Only the page view reads it: it tells each page
/// module what the press is to its page (`PagePress`).
pub(in crate::canvas) router: crate::page_router::PageRouter,
/// What the last right-click on a page went to, and on which page (`None` off the pages, and for
/// Add text, which takes every right-click): the page's menu shows its entries when it has a menu
/// and is the one selected, or else the page's own.
pub(in crate::canvas) menu_for: Option<(usize, crate::page_router::Owner)>,
/// Escape cancelled the press the pages hold: the page ignores it until the button is up, so
/// its release neither clicks nor ends a drag.
pub(crate) press_cancelled: bool,
@@ -906,13 +895,20 @@ impl DocView {
if let Some(q) = &mut self.camera {
q.grab = None;
}
self.block_drag = None;
self.org_drag = None;
self.panel_drag = None;
self.selection_drag = false;
self.router.cancel();
self.auto_scroll.cancel();
self.prepare.cancel_pointer_gesture();
self.cancel_edit_text_gestures();
}
/// Abandon Edit text's pointer gestures (a paragraph moved or rewrapped, a picture moved,
/// resized, turned or cropped, added content moved, the objects' area selection or move), as
/// [`Self::cancel_pointer_gestures`] does: what they were previewing is not applied.
pub(crate) fn cancel_edit_text_gestures(&mut self) {
self.block_drag = None;
self.content.cancel_pointer_gesture();
self.objects.cancel_pointer_gesture();
if let Some(image) = &mut self.image_selection {
@@ -1060,6 +1056,7 @@ impl DocView {
signature_drag: Default::default(),
cursor: Default::default(),
router: Default::default(),
menu_for: None,
press_cancelled: false,
}
}
@@ -1272,11 +1269,10 @@ struct PageFrame<'a> {
}
impl PageFrame<'_> {
/// Where this frame's right-click is, on the page at `rect`, for the first page it is on: pages
/// that touch (the gaps hidden) can share a row of pixels, and a right-click there is one
/// page's, as a press is.
fn right_click_on(&mut self, rect: Rect) -> Option<Pos2> {
let p = self.pointer.filter(|p| !self.right_clicked && self.resp.secondary_clicked() && rect.contains(*p))?;
/// Where this frame's right-click is, for the first page it is `on`: pages that touch (the gaps
/// hidden) can share a row of pixels, and a right-click there is one page's, as a press is.
fn right_click_on(&mut self, on: impl FnOnce(Pos2) -> bool) -> Option<Pos2> {
let p = self.pointer.filter(|p| !self.right_clicked && self.resp.secondary_clicked() && on(*p))?;
self.right_clicked = true;
Some(p)
}
@@ -1575,10 +1571,17 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i
// A cancelled press takes nothing until the button is up, though the page's menu still opens
// where a right-click is.
let input = if view.press_cancelled {
if let Some(p) = frame.right_click_on(xf.rect) {
if let Some(p) = frame.right_click_on(|p| xf.rect.contains(p)) {
comments::anchor(&pcx, p, view);
}
PageInput { consumed: true, area_tool: false, hover: None, released_over: false }
PageInput {
press: crate::page_router::PagePress::Elsewhere,
text_drag: false,
consumed: true,
area_tool: false,
hover: None,
released_over: false,
}
} else {
page_tool_input(ui, view, frame, &slot)
};
@@ -1638,8 +1641,6 @@ fn draw_pages(
prefs,
preparing,
selection_wheel,
ppp,
rects,
single_paging,
tool,
unobstructed,
@@ -1708,54 +1709,10 @@ fn draw_pages(
view.links.context = None;
// Set again below by the page right-clicked: a right-click off the pages (between them,
// beside one) anchors no page's menu, which then acts on the current page. It deselects the
// comment too (below), as one on a page's empty part does. A selected Prepare field keeps
// its menu, off the pages as on them.
// comment, and Edit text's picture, too (below), as one on a page's empty part does. A
// selected Prepare field stays selected, and the menu is the page's there too.
view.comments.context_at = None;
let mut over_image = false;
if tool == QuickTool::EditText {
let pointer_pos = ptr.pos;
if let Some(p) = pointer_pos {
for &page_idx in visible_pages {
let r = snap_to_pixels(rects[page_idx].translate(origin.to_vec2()), ppp);
if !r.intersects(visible) {
continue;
}
let xf =
PageXform { rect: r, rot: view.rotation, pw: info.pages[page_idx].width.max(1.0), ph: info.pages[page_idx].height.max(1.0) };
let images = cached_edit_images(view, doc, page_idx).to_vec();
// Crop controls own the click even when another image overlaps them.
if view.image_selection.as_ref().is_some_and(|s| {
s.page == page_idx
&& s.crop
.as_ref()
.zip(images.get(s.index))
.is_some_and(|(crop, img)| crop_contains_point(img, crop, &xf, info, page_idx, p))
}) {
over_image = true;
break;
}
for (idx, img) in images.iter().enumerate().rev() {
let obb = ImageObb::from_image(img, &xf, info, page_idx);
let b = image_screen_rect(img, &xf, info, page_idx);
let selected_crop = view.image_selection.as_ref().is_some_and(|s| s.page == page_idx && s.index == idx && s.crop.is_some());
let hit = if selected_crop { crate::edit_text_ui::quad_contains(obb.corners(), p) } else { obb.contains(b, p) };
if hit {
over_image = true;
if view.image_selection.as_ref().is_none_or(|s| s.page != page_idx || s.index != idx) {
view.image_selection = Some(crate::edit_text_ui::ImageSelection::at(page_idx, idx));
}
break;
}
}
if over_image {
break;
}
}
}
}
if !over_image && tool == QuickTool::EditText {
view.image_selection = None;
}
view.menu_for = None;
}
let mut frame = PageFrame {
env: *env,
@@ -1793,9 +1750,13 @@ fn draw_pages(
right_clicked,
..
} = frame;
// A right-click no page took deselects the comment, so the page's menu opens, not the comment's.
// A right-click no page took deselects the comment, so the page's menu opens, not the comment's,
// and with Edit text the picture (one on a page went to that page, `page_input`).
if resp.secondary_clicked() && !right_clicked {
view.comments.selected = None;
if tool == QuickTool::EditText {
view.image_selection = None;
}
}
let PageOut {
mut canvas_action,
@@ -1867,28 +1828,34 @@ fn draw_pages(
if view.continuous {
view.current = current;
}
// The menu of what the right-click went to (`menu_for`), or else the page's.
use crate::page_router::{Owner, PicturePart};
let menu_for = view.menu_for.map(|(_, owner)| owner);
// A right-click while a press is held leaves the selections alone, so the menu is its owner's only
// when that owner is the one selected.
let on_selected_field = match menu_for {
Some(Owner::PrepareField { field, widget }) => {
form.get(field).is_some_and(|f| view.prepare.selected.as_ref().is_some_and(|(name, w)| *name == f.name && *w == widget))
}
Some(Owner::PrepareCorner { .. }) => true,
_ => false,
};
// The row as it is now: in continuous view the current page may have changed above.
let row_on_screen =
shown_row(view, env.rects).iter().filter_map(|&i| env.rects.get(i)).map(|r| r.translate(origin.to_vec2())).reduce(Rect::union);
resp.context_menu(|ui| {
let is_crop_over_image = tool == QuickTool::EditText
&& view.image_selection.clone().is_some_and(|sel| {
let Some(ref crop) = sel.crop else { return false };
let pointer_pos = ptr.pos;
let Some(p) = pointer_pos else { return false };
let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page).cloned() else { return false };
let images = cached_edit_images(view, doc, sel.page).to_vec();
let Some(img) = images.get(sel.index) else { return false };
crop_contains_point(img, crop, &xf, info, sel.page, p)
});
if is_crop_over_image {
ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false));
// Crop mode has no menu: a right-click on the picture being cropped, its box or its grips is
// the crop's (#5835).
if matches!(menu_for, Some(Owner::Picture { part: PicturePart::Crop(_), .. })) {
egui::Popup::close_all(ui.ctx());
ui.close();
return;
}
// Preparing a form: the selected field's menu.
if preparing && let Some((name, wi)) = view.prepare.selected.clone() {
// Preparing a form: the selected field's menu, on a field.
if preparing
&& on_selected_field
&& let Some((name, wi)) = view.prepare.selected.clone()
{
// Several fields: Align, Center, Distribute, Set Fields to Same Size.
if !view.prepare.also.is_empty() {
use crate::prepare::Arrange as A;
@@ -1935,7 +1902,9 @@ fn draw_pages(
}
return;
}
if let Some((page, index)) = view.links.context {
if matches!(menu_for, Some(Owner::Link { .. }))
&& let Some((page, index)) = view.links.context
{
if ui.button(tl!("Properties…")).clicked() {
view.links.open_existing = Some((page, index));
ui.close();
@@ -1956,31 +1925,24 @@ fn draw_pages(
return;
}
let can_paste_image = env.can_paste_image;
let is_image_menu = tool == QuickTool::EditText
&& (ui.ctx().data_mut(|d| d.get_temp::<bool>(crate::edit_text_ui::image_menu_id())).unwrap_or(false)
|| view.image_selection.clone().is_some_and(|sel| {
let pointer_pos = ptr.pos;
let Some(p) = pointer_pos else { return false };
let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page).cloned() else { return false };
let images = cached_edit_images(view, doc, sel.page).to_vec();
let Some(img) = images.get(sel.index) else { return false };
let obb = ImageObb::from_image(img, &xf, info, sel.page);
let b = image_screen_rect(img, &xf, info, sel.page);
obb.contains(b, p)
}));
if is_image_menu && let Some(sel) = view.image_selection.clone() {
if tool == QuickTool::EditText
&& let Some((page, Owner::Picture { index, .. })) = view.menu_for
&& let Some(sel) = view.image_selection.clone().filter(|s| (s.page, s.index) == (page, index))
{
let images = cached_edit_images(view, doc, sel.page).to_vec();
// Paste lands where the menu opened only on the picture's page: where pages touch, the
// first page takes the right-click, and its point means nothing on the second.
// Paste lands where the menu opened, on the picture's page: the right-click that opened
// it went to that page (`page_input`), and another page's point means nothing there.
let at = view.comments.context_at.filter(|(page, _)| *page == sel.page).map(|(_, at)| at);
let rotation = (view.rotation % 360 + info.pages.get(sel.page).map_or(0, |p| p.rotation % 360)) % 360;
crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action, at, rotation);
return;
}
// A selected comment, or connected lines being drawn (Complete, Undo last point): the
// comment menu.
// The comment the right-click went to, when it is the one selected, or connected lines being
// drawn (Complete, Undo last point): the comment menu.
let on_selected_comment =
matches!(view.menu_for, Some((page, Owner::Comment { index, .. })) if view.comments.selected == Some((page, index)));
let drawing_points = matches!(&view.comments.gesture, Some(comments::Gesture::Draw { tool, .. }) if tool.clicks_points());
if view.comments.selected.is_some() || drawing_points {
if on_selected_comment || drawing_points {
canvas_action = comments::context_menu(
ui,
view,
@@ -2037,9 +1999,6 @@ fn draw_pages(
};
default_page_context_menu(ui, page, at, perms, &mut page_action);
});
if !resp.context_menu_opened() {
ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false));
}
// Copy comments / Paste comments from the page's menu or a Comments panel card.
let copy_comments = std::mem::take(&mut view.comments.copy_request);
let paste_comments_at = view.comments.paste_request.take();
+10 -7
View File
@@ -3,7 +3,7 @@
//! other image as a lossless PNG (with its transparency). The clipboard gets the file, as when a
//! file is copied in Finder or Explorer; Copy Image copies the pixels instead.
use egui::{Color32, CornerRadius, Pos2, Stroke};
use egui::{Color32, CornerRadius, Stroke};
use crate::PdfCraftApp;
use crate::canvas::{DocView, PageXform};
@@ -22,23 +22,26 @@ pub struct PickedImage {
const BLUE: Color32 = Color32::from_rgb(0x3A, 0x7B, 0xF0);
/// A click on page `page` (Select tool, not taken by anything else) selects the picture under
/// it, even with text drawn over it (scans with an OCR layer, captions); dragging or a double
/// click still selects text, and clears the picture.
/// it, even with text drawn over it (scans with an OCR layer, captions), with `pick` (a press
/// nobody holds: a link's click follows the link only); dragging or a double click still selects
/// text, and clears the picture.
#[allow(clippy::too_many_arguments)]
pub(crate) fn page_input(
resp: &egui::Response,
xf: &PageXform,
page: usize,
info: &DocInfo,
doc: &pdfcraft_engine::Document,
pointer: Option<Pos2>,
ptr: &crate::page_pointer::PagePointer,
pick: bool,
view: &mut DocView,
) {
let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return };
if resp.drag_started() || resp.double_clicked() || resp.triple_clicked() {
let Some(p) = ptr.pos.filter(|p| xf.rect.contains(*p)) else { return };
if ptr.drag_started || resp.double_clicked() || resp.triple_clicked() {
view.picked_image = None;
return;
}
if !resp.clicked() {
if !resp.clicked() || !pick {
return;
}
let Some(info_page) = info.pages.get(page) else { return };
+190 -99
View File
@@ -12,7 +12,13 @@ use crate::{QuickTool, comments, icons};
/// What the page's tools did with the pointer this frame.
pub(super) struct PageInput {
/// A tool, a field or a comment took the pointer: text selection must leave it alone.
/// What the press the pages hold is to this page.
pub(super) press: PagePress,
/// The press's drag is the page's text's: nobody holds it, or its owner lets its drag go (text
/// markup: a click selects it, a drag selects the text under it).
pub(super) text_drag: bool,
/// A tool, a field or a comment took the pointer this frame (its own press, or a gesture of the
/// active tool's): text selection and image pick leave it alone.
pub(super) consumed: bool,
/// Redact or Highlight draws boxes off text.
pub(super) area_tool: bool,
@@ -37,8 +43,9 @@ pub(crate) struct PageToolContext<'a> {
/// The tools' input on one page. The hits at the press point decide who holds the press, and the
/// modules are told what the press is to this page (`PagePress`): their own, nobody's (the active
/// tool's: `tool_input`, and the tools still called here), or another page's. The Edit text group
/// still asks the router, until it is told too. Text selection runs after, on what nobody took.
/// tool's: `tool_input`, and the tools still called here), or another page's. Edit text's group
/// paints from what lies under the pointer too (`hovered`). Text selection runs after, on what
/// nobody took.
pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput {
let PageEnv {
added,
@@ -91,16 +98,47 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
images: &images,
clip: ui.clip_rect(),
};
if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) {
let at = hits.at(view, o);
view.router.press(i, at, tool);
if let Some(o) = ptr.pressed() {
// A press with Shift or ⌘ in Edit text is the objects', whatever lies under it
// (`object_ui::modified_hit`): the page's objects are read for it alone.
let adds = tool == QuickTool::EditText
&& can_modify
&& xf.rect.contains(o)
&& crate::object_ui::adds(ptr.modifiers)
&& !crate::object_ui::menu_open(ui, resp);
let objects = adds.then(|| view.objects.inventory(doc, i));
let at = hits.at(view, o, objects.as_ref());
// A press is the page's it lies on, or past its edge the one whose selected picture has its
// rotate knob or a grip there (crop mode's grips too).
let past_edge = |h: &crate::page_router::Hit| {
use crate::page_router::PicturePart;
matches!(h.owner, Owner::Picture { part: PicturePart::Knob | PicturePart::Corner(_) | PicturePart::Crop(_), .. })
};
if xf.rect.contains(o) || at.iter().any(past_edge) {
view.router.press(i, at, tool);
}
}
// A drag that the press's owner declines is nobody's once it starts.
if ptr.drag_started {
view.router.drag_began();
}
// What this page has to do with the press: its owner's, the active tool's, or another page's.
let press = view.router.press_for(i);
// What lies under the pointer before the tools act, on the frames that need it: for a click's
// release, a right-click's tool checks, and Fill & Sign's preview.
let wants_under = resp.clicked() || resp.secondary_clicked() || matches!(tool, QuickTool::Fill(_)) && !ptr.any_down;
let under = pointer.filter(|p| wants_under && xf.rect.contains(*p)).map(|p| hits.at(view, p)).unwrap_or_default();
// release, a right-click's tool checks, Fill & Sign's preview, and Edit text's hover outlines.
let editing = matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify;
let wants_under = editing || resp.clicked() || resp.secondary_clicked() || matches!(tool, QuickTool::Fill(_)) && !ptr.any_down;
let under = pointer.filter(|p| wants_under && xf.rect.contains(*p)).map(|p| hits.at(view, p, None)).unwrap_or_default();
let hovered = top(under.iter().cloned(), tool);
// A double-click on the objects selected together is the object's its press was on: the
// double-click leaves the group (`object_ui`), and added text opens for typing, a picture or a
// paragraph is the one taken (the user's ruling).
let press = match press {
PagePress::Mine(Owner::Objects { part: crate::page_router::ObjectsPart::Selection }) if resp.double_clicked() => {
view.router.under_objects().map_or(PagePress::Free, PagePress::Mine)
}
press => press,
};
let released_over = match press {
PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner),
_ => false,
@@ -110,12 +148,27 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
// released, the right-click's own frame included, so the button is what tells. A touch's long
// press, which egui reports as a right-click with the finger still down, opens its menu.
let press_held = ptr.primary_down && !resp.long_touched();
let context = frame.right_click_on(xf.rect).and_then(|p| right_click(&hits, &under, view, p, press_held));
// A right-click is the page's it lies on, or past its edge the one whose crop grip lies there,
// as a press is (#5835): what a right-click there would go to is asked only off the page.
let right = frame.right_click_on(|p| {
use crate::page_router::PicturePart;
xf.rect.contains(p) || matches!(hits.context_at(view, p, &[]), Some(Owner::Picture { part: PicturePart::Crop(_), .. }))
});
let context = right.and_then(|p| right_click(&hits, &under, view, p, press_held));
// Edit text: a right-click on a picture selects it for its menu, and one on anything else on the
// page unselects it.
if right.is_some() && tool == QuickTool::EditText && can_modify && !press_held {
let picture = match context {
Some(Owner::Picture { index, .. }) => Some(index),
_ => None,
};
crate::edit_text_ui::picture_context(i, picture, view);
}
// A mark already placed takes its own press (comments, below), as does one being moved on this
// page until its move ends; with no button down the Fill & Sign tool's preview hides where a
// press would be taken (a mark, its handles, a Prepare field).
let over_mark = matches!(tool, QuickTool::Fill(_))
&& (matches!(press, PagePress::Mine(_)) || comments::moving_on(view, i) || !ptr.any_down && top(under.iter().cloned(), tool).is_some());
&& (matches!(press, PagePress::Mine(_)) || comments::moving_on(view, i) || !ptr.any_down && hovered.is_some());
tool_input(ui, view, frame, slot, &pcx, press, over_mark);
let on_field = if preparing && !placing {
let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, press, view);
@@ -132,61 +185,45 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
let text = view.page_text(i);
let over_text = |p: Pos2| {
let (vx, vy) = xf.screen_to_view(p);
text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3]))
text.as_ref().is_some_and(|t| t.glyph_at(vx, vy).is_some())
};
crate::redact_ui::page_input(cx, over_text, press == PagePress::Free, view)
};
let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view);
// Add text acts on the page's own paragraphs over them, and on the one its press was on until
// the button is up: a drag that leaves the box still ends, and no new box takes it over.
let over_source = tool == QuickTool::AddText
&& (matches!(view.router.owner(i), Some(crate::page_router::Owner::Paragraph { .. }))
|| pointer.is_some_and(|p| {
!crate::content_ui::hits_added(&xf, info, i, added, p) && crate::edit_text_ui::over_paragraph(&xf, info, i, &lines, p)
}));
let source_blocked = over_source && view.content.hold_blocked();
if over_source && resp.clicked() && !source_blocked {
if let Some(edit) = view.content.finish_draft(added) {
view.pending_edit = Some(edit);
}
view.content.selected = None;
// Edit text's group: the objects selected together, added content, the paragraphs and the
// pictures, each told what the press is to this page. Edit text ignores input while a dialog,
// the palette, another window or another tab has it (`gate`), as before.
let gate = !(tool == QuickTool::EditText && view.objects.input_blocked());
if tool == QuickTool::EditText && can_modify && gate {
crate::edit_text_ui::picture_keys(ui.ctx(), view, i, &images, ptr.any_down);
}
let on_content = !on_objects && !placing && editing_content && can_modify && {
source_blocked
|| crate::content_ui::page_input(
cx,
added,
tool == QuickTool::AddText && !over_source,
matches!(tool, QuickTool::AddText | QuickTool::EditText),
text_style,
view,
)
|| tool == QuickTool::AddText && !over_source
let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, press, view);
let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText);
let ccx = crate::content_ui::ContentCx {
added,
press,
gate,
picks_up,
adding_text: tool == QuickTool::AddText,
edit_on_click: matches!(tool, QuickTool::AddText | QuickTool::EditText),
style: text_style,
};
// Added content owns its gesture; its source text must not queue a second edit.
// Edit text & images boxes every paragraph and image whenever the tool is on. Only the
// input waits while selected objects or added content own the pointer, or while page
// input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn
// instead of vanishing until the pointer comes back.
let others_own_pointer = on_objects || on_content;
let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && {
// Editable text takes precedence over background/overlapping artwork. Both
// inventories are painted every frame, even when text owns the pointer.
let text_used = crate::edit_text_ui::page_input(cx, &lines, view, !others_own_pointer);
let image_cx = crate::edit_text_ui::ImageCx {
ui,
resp,
xf: &xf,
ptr,
page: i,
info,
allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used),
};
let on_content = !placing && editing_content && can_modify && crate::content_ui::page_input(cx, ccx, view);
// Add text acts on the page's own paragraphs: the one its press was on, until the button is up,
// and the one under the pointer. Edit text boxes every paragraph and picture.
let paragraph = |o: Option<Owner>| matches!(o, Some(Owner::Paragraph { .. }));
let source = tool == QuickTool::AddText && (paragraph(press.owner()) || paragraph(hovered.as_ref().map(|h| h.owner)));
let on_edit_text = (tool == QuickTool::EditText || source) && can_modify && {
let adding = (tool == QuickTool::AddText).then_some(added.as_slice());
let pcx = crate::edit_text_ui::ParagraphCx { press, gate, hover: hovered.as_ref(), adding };
let text_used = crate::edit_text_ui::page_input(cx, &lines, pcx, view);
let image_cx = crate::edit_text_ui::ImageCx { ui, resp, xf: &xf, ptr, page: i, info, press, gate };
let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view);
!others_own_pointer && (text_used || image_used)
text_used || image_used
};
if tool == QuickTool::EditText {
if others_own_pointer {
// A press the objects or added content took leaves the picture unselected; so does one
// while Edit text's input is blocked. Otherwise added content follows the picture selected.
if on_objects || on_content {
if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() {
view.image_selection = None;
}
@@ -204,20 +241,19 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
}
// Comments, every frame: their own presses, a commenting tool's, and a click elsewhere that
// leaves them unselected. A press nobody holds that another tool took this frame (Prepare's
// selection rectangle, added content, which still decide for themselves) is not also a
// commenting tool's, as before.
// selection rectangle, added content) is not also a commenting tool's, as before.
let taken = on_objects || on_edit_text || on_link || on_content || boxing || on_field;
let on_comment = comments::page_input(ui, resp, &pcx, press, taken, view);
let consumed = taken || on_comment;
let hover = pointer.and_then(|p| claim_under(&hits, view, p, frame.env.unobstructed));
PageInput { consumed, area_tool, hover, released_over }
PageInput { press, text_drag: !view.router.keeps_drag(), consumed, area_tool, hover, released_over }
}
/// What lies under the pointer at `p`, after the tools act: its top, what a press there would take,
/// for hover painting. With `claim` (nothing drawn over the pages, such as the palette), its cursor
/// and tip too, after the tools claimed theirs (the last claim on a layer stands).
fn claim_under(hits: &PageHits<'_>, view: &mut DocView, p: Pos2, claim: bool) -> Option<crate::page_router::Hit> {
let under = hits.at(view, p);
let under = hits.at(view, p, None);
if claim {
for h in &under {
view.cursor.claim_with_tip(h.layer, h.icon, h.tip.clone());
@@ -334,7 +370,8 @@ fn tool_input(
/// A right-click at `p`: the page's menu opens there, and it goes to what lies on top there
/// ([`PageHits::context_at`]), which the menu then acts on. A comment there is selected, and none
/// elsewhere; a Prepare field is selected; links take theirs from what this returns.
/// elsewhere; a Prepare field is selected; links and Edit text's pictures take theirs from what this
/// returns.
/// `under`: the hits at `p` before the tools act. `press_held`: the primary button is held, and the
/// comment and Prepare selections its press acts on are left alone until its release; links still
/// take theirs, as before.
@@ -342,16 +379,16 @@ fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mu
let context = hits.context_at(view, p, under);
// Delete page, Paste and Copy Image act where the menu opened, whoever takes the right-click.
comments::anchor(hits.pcx, p, view);
// The menu is what the right-click went to (Add text's is the page's), when that is selected:
// under a held press, the selections stay as they are.
view.menu_for = context.filter(|_| !(hits.tool == QuickTool::AddText && hits.can_modify)).map(|o| (hits.page, o));
if press_held {
return context;
}
// Add text takes every right-click on a page it can change, Edit text those on its own
// paragraphs and pictures (the objects selected together, and crop mode's picture, among them),
// the Link tool those on a link, and a Prepare field tool the whole page, as before: comments
// see none of them.
// Add text takes every right-click on a page it can change, the Link tool those on a link, and a
// Prepare field tool the whole page, as before: comments see none of them.
let tool_takes_it = match hits.tool {
QuickTool::AddText => hits.can_modify,
QuickTool::EditText => under.iter().any(|h| matches!(h.owner, Owner::Paragraph { .. } | Owner::Picture { .. } | Owner::Objects { .. })),
QuickTool::Link => hits.can_modify && matches!(context, Some(Owner::Link { .. })),
_ => hits.field_tool.is_some(),
};
@@ -413,14 +450,26 @@ struct PageHits<'a> {
}
impl PageHits<'_> {
/// The hits at `p`, bottom up ([`Self::hits`]).
fn at(&self, view: &DocView, p: Pos2) -> Vec<crate::page_router::Hit> {
self.hits(view, p, false)
/// Edit text's crop mode is on for a picture on this page.
fn cropping(&self, view: &DocView) -> bool {
self.tool == QuickTool::EditText && self.can_modify && view.image_selection.as_ref().is_some_and(|s| s.page == self.page && s.crop.is_some())
}
/// The hits at `p`, bottom up ([`Self::hits`]). For a press with Shift or ⌘ in Edit text, the
/// page's `objects` ([`crate::object_ui::ObjectSelection::inventory`]), which take it whole.
fn at(&self, view: &DocView, p: Pos2, objects: Option<&Result<Vec<pdfcraft_engine::EditableObject>, String>>) -> Vec<crate::page_router::Hit> {
self.hits(view, p, false, objects)
}
/// What a right-click at `p` is on: what lies on top there, as for a press, but with every
/// tool reaching comments and links, as a comment's menu shows whatever the tool. A highlight
/// over a link is the highlight's ([`comments::context_hit`]).
/// tool reaching comments, as a comment's menu shows whatever the tool, and a Prepare field
/// whatever the field tool. Links count for the tools that reach them (Select; the Link tool,
/// below). Edit text's paragraphs have no menu, and give way to the picture under them (the
/// user's ruling): with Edit text, a picture under a link or a paragraph takes the right-click.
/// Nor have added content or an open paragraph's frame: those give way to a picture under them,
/// and to nothing else. (Where objects are selected together, the page view unselects the picture
/// a right-click selects, and the menu is the page's.)
/// A highlight over a link is the highlight's ([`comments::context_hit`]).
/// The Link tool edits links and reaches no comment: on a document it can change, its
/// right-click is what its press there would take (`under`, the hits at `p`), when that is
/// anything.
@@ -431,17 +480,36 @@ impl PageHits<'_> {
{
return Some(press.owner);
}
top(self.hits(view, p, true), QuickTool::Select).map(|h| h.owner)
let reached: Vec<_> = self
.hits(view, p, true, None)
.into_iter()
.filter(|h| (h.layer != Layer::Link || h.layer.reached_by(self.tool)) && !matches!(h.owner, Owner::Paragraph { .. }))
.collect();
let on_top = top(reached.iter().cloned(), QuickTool::Select)?.owner;
if self.tool == QuickTool::EditText && matches!(on_top, Owner::Added { .. } | Owner::Objects { part: crate::page_router::ObjectsPart::Frame })
{
let pictures = reached.into_iter().filter(|h| matches!(h.owner, Owner::Picture { .. }));
return Some(top(pictures, QuickTool::Select).map_or(on_top, |h| h.owner));
}
Some(on_top)
}
/// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text
/// on top of artwork takes the press), added content, the objects selected together and the
/// open paragraph's frame, the selected picture's grips over them all; the camera tool's corners;
/// then form fields (Prepare's, being moved, over the comments; filled ones under added content
/// and the comments that move). In crop mode, only the picture being cropped.
/// and the comments that move). For a press with Shift or ⌘ in Edit text, only the objects
/// (`objects`, the page's); in crop mode, only the picture being cropped, which a right-click
/// reaches only on the crop.
/// For a right-click (`context`), comments by [`comments::context_hit`], and no camera corner (it
/// has no menu).
fn hits(&self, view: &DocView, p: Pos2, context: bool) -> Vec<crate::page_router::Hit> {
fn hits(
&self,
view: &DocView,
p: Pos2,
context: bool,
objects: Option<&Result<Vec<pdfcraft_engine::EditableObject>, String>>,
) -> Vec<crate::page_router::Hit> {
let PageHits {
page,
xf,
@@ -461,13 +529,24 @@ impl PageHits<'_> {
images,
clip,
} = *self;
// Crop mode owns every press on its page (`edit_text_ui::crop_hit`).
if tool == QuickTool::EditText
&& can_modify
&& let Some(crop) = crate::edit_text_ui::crop_hit(view, page)
{
return vec![crop];
// A press with Shift or ⌘ is the objects', crop mode's page included (`object_ui::modified_hit`).
if let Some(objects) = objects {
return crate::object_ui::modified_hit(view, xf, info, page, objects, clip, p).into_iter().collect();
}
// Crop mode holds every press on its page (`edit_text_ui::crop_hit`). A right-click is the
// crop's only on the picture being cropped, its box or its grips (#5835), where it keeps crop
// mode and opens no menu; elsewhere it goes to what lies there, as without crop mode.
if self.cropping(view) {
use crate::page_router::{CropPart, PicturePart};
let crop = crate::edit_text_ui::crop_hit(xf, page, info, images, view, p);
let on_crop = crop.as_ref().is_some_and(|h| !matches!(h.owner, Owner::Picture { part: PicturePart::Crop(CropPart::Page), .. }));
if !context || on_crop {
return crop.into_iter().collect();
}
}
// A right-click reaches a Prepare field whatever the field tool, and while a table is set up,
// on a document that can be changed.
let (field_tool, prepare_allowed) = if context { (None, can_modify) } else { (field_tool, prepare_allowed) };
let fields = if preparing {
crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p)
} else if tool == QuickTool::Select {
@@ -484,9 +563,9 @@ impl PageHits<'_> {
.filter(|e| !e.has_changes())
.and_then(|e| lines.get(e.block))
.map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0));
// Add text acts on the page's paragraphs only in their boxes, edges included (it
// finishes the box being typed there, `over_source`): not on pictures, nor beside a
// paragraph, where a click starts a new box.
// Add text acts on the page's paragraphs only in their boxes, edges included (its click
// there finishes the box being typed and opens the paragraph, `ParagraphCx::adding`):
// not on pictures, nor beside a paragraph, where a click starts a new box.
let edit_text = tool == QuickTool::EditText;
let on_paragraphs = edit_text || crate::edit_text_ui::over_paragraph(xf, info, page, lines, p);
(
@@ -511,7 +590,7 @@ impl PageHits<'_> {
/// Text layer: find matches, selection, I-beam and drag-to-select.
pub(super) fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) {
let PageEnv { info, selects_text, tool, .. } = frame.env;
let PageEnv { selects_text, tool, .. } = frame.env;
let PageSlot { i, r, xf } = *slot;
let PageFrame { resp, ptr, pointer, .. } = *frame;
if let Some(text) = view.texts.get(&i).cloned() {
@@ -520,7 +599,11 @@ pub(super) fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut Page
&& !input.consumed
&& let Some(p) = pointer.filter(|p| r.contains(*p))
{
text_pointer_input(ui, view, &text, TextPointer { page: i, xf, rect: r, info, tool, resp, ptr: ptr.clone(), pointer: p });
// The I-beam drops where a press would go to a link (ruling E: the tools that select a
// link's text show it there).
let over_link = input.hover.as_ref().is_some_and(|h| matches!(h.owner, Owner::Link { .. }));
let pointer = TextPointer { page: i, xf, rect: r, tool, resp, ptr: ptr.clone(), pointer: p, over_link, text_drag: input.text_drag };
text_pointer_input(ui, view, &text, pointer);
}
}
}
@@ -565,11 +648,14 @@ struct TextPointer<'a> {
page: usize,
xf: PageXform,
rect: Rect,
info: &'a DocInfo,
tool: QuickTool,
resp: &'a egui::Response,
ptr: PagePointer,
pointer: Pos2,
/// A press under the pointer would go to a link.
over_link: bool,
/// The press's drag is the page's text's ([`PageInput::text_drag`]).
text_drag: bool,
}
struct TextGesture<'a> {
@@ -584,16 +670,16 @@ struct TextGesture<'a> {
vy: f32,
over_text: bool,
over_link: bool,
text_drag: bool,
column: bool,
}
fn text_pointer_input(ui: &mut egui::Ui, view: &mut DocView, text: &PageText, pointer: TextPointer<'_>) {
let TextPointer { page, xf, rect, info, tool, resp, ptr, pointer: screen_pointer } = pointer;
let TextPointer { page, xf, rect, tool, resp, ptr, pointer: screen_pointer, over_link, text_drag } = pointer;
let (vx, vy) = xf.screen_to_view(screen_pointer);
let over_text = text.glyphs.iter().any(|glyph| vx >= glyph.rect[0] && vx <= glyph.rect[2] && vy >= glyph.rect[1] && vy <= glyph.rect[3]);
let over_link = info.links.iter().any(|link| link.page == page && xf.user_rect(info, page, link.rect).contains(screen_pointer));
let over_text = text.glyph_at(vx, vy).is_some();
let column = tool == QuickTool::ColumnSelect || ui.input(|input| input.modifiers.alt);
let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, column };
let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, text_drag, column };
update_text_cursor(view, &gesture);
update_selection_on_press(ui, view, text, &gesture);
update_dragged_selection(view, text, &gesture);
@@ -613,7 +699,7 @@ fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText,
// A press held by what keeps its drag (a link, a comment that moves) selects no text; one on
// text markup lets its drag select the text underneath.
let press_here = gesture.ptr.origin.is_some_and(|origin| gesture.rect.contains(origin));
if gesture.ptr.pressed().is_some() && press_here && !view.router.keeps_drag() {
if gesture.ptr.pressed().is_some() && press_here && gesture.text_drag {
let now = ui.input(|input| input.time);
let (max_delay, max_dist) = ui.ctx().options(|options| (options.input_options.max_double_click_delay, options.input_options.max_click_dist));
let consecutive = now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(gesture.pointer) <= max_dist);
@@ -628,7 +714,7 @@ fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText,
view.selection = Some(Selection::run(gesture.page, first, last));
}
}
if gesture.resp.drag_started() && press_here && !view.router.keeps_drag() {
if gesture.ptr.drag_started && press_here && gesture.text_drag {
view.actions_dismissed = None;
view.selection_drag = true;
let origin = gesture.ptr.origin.unwrap_or(gesture.pointer);
@@ -649,7 +735,7 @@ fn selection_click_count(clicks: u32, tool: QuickTool) -> u32 {
fn update_dragged_selection(view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) {
let clicks = selection_click_count(view.clicks, gesture.tool);
if gesture.resp.dragged()
if gesture.resp.dragged_by(egui::PointerButton::Primary)
&& let Some(selection) = view.selection.as_mut().filter(|selection| selection.page == gesture.page)
{
match &mut selection.span {
@@ -700,7 +786,7 @@ fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageTex
&& let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor))
{
view.selection = Some(Selection::run(gesture.page, first, last));
} else if gesture.resp.clicked() && view.clicks < 2 && !view.router.keeps_drag() {
} else if gesture.resp.clicked() && view.clicks < 2 && gesture.text_drag {
let shift = ui.input(|input| input.modifiers.shift);
match (
view.selection.as_mut().filter(|selection| shift && selection.page == gesture.page).map(|selection| &mut selection.span),
@@ -740,8 +826,13 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P
let painter = ui.painter();
let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info };
let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
if tool == QuickTool::Select && !input.consumed {
crate::image_pick::page_input(resp, &xf, i, info, doc, pointer, view);
// A press nobody holds picks the picture under it: a link's click follows the link only (ruling
// 5), though its drag or double-click still lets the picked picture go, as does a drag the page
// it is over first sees, whichever page it began on.
let pick_sees =
matches!(input.press, PagePress::Free | PagePress::Mine(Owner::Link { .. })) || input.press == PagePress::Elsewhere && ptr.drag_started;
if tool == QuickTool::Select && pick_sees && !input.consumed {
crate::image_pick::page_input(resp, &xf, i, info, doc, ptr, input.press == PagePress::Free, view);
}
crate::image_pick::drop_stale(doc, view);
crate::image_pick::paint(painter, &xf, i, info, view);
@@ -797,7 +888,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P
&& resp.clicked()
&& tool == QuickTool::Select
&& !input.consumed
&& let Some(Owner::Link { index }) = view.router.owner(i)
&& let PagePress::Mine(Owner::Link { index }) = input.press
&& input.released_over
&& let Some(l) = info.links.iter().find(|l| l.page == i && l.index == index)
{
+20 -4
View File
@@ -11,7 +11,7 @@
//! 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};
use egui::{Event, Modifiers, PointerButton, Pos2, Rect};
/// A primary-button press or release, and where it happened.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -46,13 +46,19 @@ pub(crate) struct PagePointer {
pub primary_down: bool,
/// Some button is down, wherever it was pressed.
pub any_down: bool,
/// The modifier keys held at this frame's first primary press, or else now: a press with Shift
/// or ⌘ adds to a selection.
pub modifiers: Modifiers,
/// A primary press the pages hold turned into a drag this frame. egui's own `drag_started()`
/// counts every button, so a right-button drag would start what a left-button drag does.
pub drag_started: 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, released, primary_down, any_down) = ui.input(|i| {
let (at, origin, edges, released, primary_down, any_down, modifiers) = ui.input(|i| {
(
i.pointer.interact_pos(),
i.pointer.press_origin(),
@@ -60,6 +66,7 @@ impl PagePointer {
last_release(&i.events),
i.pointer.primary_down(),
i.pointer.any_down(),
press_modifiers(&i.events).unwrap_or(i.modifiers),
)
});
// egui's own answer to "do the pages hold a press?": from the press on until the frame
@@ -75,9 +82,10 @@ impl PagePointer {
resp.dragged() || resp.drag_stopped() || (!modal && (resp.interact_pointer_pos().is_some() || press_origin.is_some_and(|p| on_top(&p))));
let pos = at.filter(|p| held || on_top(p));
if !held {
return Self { pos, origin: None, edges: Vec::new(), released: None, primary_down, any_down };
return Self { pos, origin: None, edges: Vec::new(), released: None, primary_down, any_down, modifiers, drag_started: false };
}
Self { pos, origin: press_origin, edges, released, primary_down, any_down }
let drag_started = resp.drag_started_by(PointerButton::Primary);
Self { pos, origin: press_origin, edges, released, primary_down, any_down, modifiers, drag_started }
}
/// Where the pointer is, when it is inside `r`.
@@ -108,6 +116,14 @@ fn first_press(edges: &[Edge]) -> Option<Pos2> {
})
}
/// The modifier keys held at the first primary press among `events`.
fn press_modifiers(events: &[Event]) -> Option<Modifiers> {
events.iter().find_map(|e| match e {
Event::PointerButton { button: PointerButton::Primary, pressed: true, modifiers, .. } => Some(*modifiers),
_ => None,
})
}
/// Where the last primary release among `events` was, however many came before it.
fn last_release(events: &[Event]) -> Option<Pos2> {
events.iter().rev().find_map(|e| match e {
+84 -88
View File
@@ -207,7 +207,7 @@ const PAGE_MODULES: [&str; 37] = [
/// What a file that acts on the pages uses: the pointer, a tool's or the comments' page context,
/// what a press is to a page, the router's hits and owners, or the router itself. Such a file is a
/// page module. One that acts on the pages through the page view's response and a bare point alone
/// uses none of them (`canvas/image_pick.rs`): PAGE_MODULES lists it by hand.
/// would use none of them, and PAGE_MODULES would list it by hand.
const PAGE_MODULE_MARKS: [&str; 6] = ["PagePointer", "PageToolContext", "PageCx", "PagePress", "page_router::", ".router."];
/// The router's own files, which define what page modules use.
@@ -260,7 +260,11 @@ 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 "] {
// `pub(crate)`, `pub(super)`, `pub(in crate::canvas)` and the like.
if let Some(scoped) = rest.strip_prefix("pub(") {
rest = scoped.split_once(") ").map_or(rest, |(_, after)| after);
}
for prefix in ["pub ", "const ", "async ", "unsafe "] {
rest = rest.strip_prefix(prefix).unwrap_or(rest);
}
if rest == before {
@@ -271,6 +275,19 @@ fn declares(line: &str) -> Option<&str> {
rest.find(|c: char| !(c.is_alphanumeric() || c == '_')).map(|end| &rest[..end])
}
/// egui's drag start, which counts every button: a right-button drag would start what a left one
/// does. Page modules read `PagePointer::drag_started`, the primary button's, or ask for that button
/// (`drag_started_by`).
const ANY_BUTTON_DRAG: [&str; 1] = [".drag_started()"];
/// Functions in the page modules that start a drag of a widget of their own, not of the pages:
/// (file, function, why).
const NOT_PAGE_DRAGS: [(&str, &str, &str); 3] = [
("canvas/organize.rs", "drag_pages", "Organize Pages drags thumbnails, not pages"),
("tools/fill_sign.rs", "signature_pad", "the signature dialog's drawing pad"),
("tools/fill_sign/scan.rs", "scan_tab", "the signature dialog's Scan tab"),
];
/// Ways to set egui's cursor.
const CURSOR_WRITES: [&str; 4] = ["set_cursor_icon(", ".cursor_icon = ", "on_hover_cursor(", "on_hover_and_drag_cursor("];
@@ -462,6 +479,14 @@ fn page_modules_read_the_pointer_only_through_page_pointer() {
}
}
}
problems.extend(misuses(
&ANY_BUTTON_DRAG,
&NOT_PAGE_DRAGS,
"starts a drag with any button: a right-button drag would start what a left one does. Read \
`PagePointer::drag_started` (the primary button's) instead. A widget of its own, not the pages? Add the \
function to NOT_PAGE_DRAGS in src/canvas/page_pointer/tests.rs, with why.",
"starts no drag of its own any more: remove it from NOT_PAGE_DRAGS.",
));
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
@@ -483,20 +508,22 @@ fn page_modules_claim_the_cursor_and_never_set_it() {
}
/// What decides, keeps or drops the press the pages hold.
const ROUTER_WRITES: [&str; 8] = [
const ROUTER_WRITES: [&str; 10] = [
"router.begin(",
"router.press(",
"router.drag_began(",
"router.settle(",
"router.cancel(",
"PageRouter::begin(",
"PageRouter::press(",
"PageRouter::drag_began(",
"PageRouter::settle(",
"PageRouter::cancel(",
];
/// The functions that decide, keep or drop the press the pages hold: (file, function, why).
const ROUTER_WRITERS: [(&str, &str, &str); 3] = [
("canvas/page_input.rs", "page_tool_input", "the hits at the press point decide its owner, once per press"),
("canvas/page_input.rs", "page_tool_input", "the hits at the press point decide its owner, once per press, and a declined drag lets it go"),
("canvas.rs", "draw_pages", "the press is over once the button is up and its release seen, and a new one drops the last owner"),
("canvas.rs", "cancel_pointer_gestures", "every pointer gesture's cancel: Escape, automation's cancel"),
];
@@ -523,11 +550,12 @@ fn hit_test_calls(line: &str) -> Vec<String> {
/// The functions that call a hit test: (file, function, why). Hit tests are found by name (a
/// function named `hit` or `…_hit`, called by path, bare or as a method). The comments' point test
/// has a list of its own (`COMMENT_POINT_TESTERS`); the others by other names (added content's,
/// Edit text's paragraphs', Redact's text test, the text layer's links) are not listed yet, and
/// most are PR 4b's. `PageHits::hits` is the one: the hits
/// at the press point decide its owner, and those under the pointer give its cursor, its tip, hover
/// painting and a right-click.
/// has a list of its own (`COMMENT_POINT_TESTERS`). Point tests by other names are not seen: what is
/// left of them paints (a picture's hover outline), keeps a behaviour no hit stands for (added
/// text's click with the tools that don't pick it up, crop mode's controls) or tells a tool that a
/// press nobody holds began on its page (the text layer's `press_here`, redaction's). `PageHits::hits`
/// is the one: the hits at the press point decide its owner, and those under the pointer give its
/// cursor, its tip, hover painting and a right-click.
const HIT_TESTERS: [(&str, &str, &str); 1] = [(
"canvas/page_input.rs",
"hits",
@@ -566,27 +594,38 @@ const COMMENT_POINT_TESTERS: [(&str, &str, &str); 3] = [
];
/// What reads who holds the press the pages hold.
const ROUTER_READS: [&str; 6] =
["router.press_for(", "router.owner(", "router.keeps_drag(", "PageRouter::press_for(", "PageRouter::owner(", "PageRouter::keeps_drag("];
const ROUTER_READS: [&str; 4] = ["router.press_for(", "router.keeps_drag(", "PageRouter::press_for(", "PageRouter::keeps_drag("];
/// The functions that read who holds the press: (file, function, why). The page view asks once per
/// page and tells each module what the press is to its page (`PagePress`); the Edit text group, the
/// text layer and link following still ask, until they are told too (PR 4b), and an unused entry
/// fails the test.
const ROUTER_READERS: [(&str, &str, &str); 8] = [
(
"canvas/page_input.rs",
"page_tool_input",
"the page view: what the press is to each page (`press_for`); Add text's paragraph and the pictures until 4b",
),
("canvas/page_input.rs", "update_selection_on_press", "the text layer selects no text under a press held by what keeps its drag (4b)"),
("canvas/page_input.rs", "finish_text_pointer_gesture", "a click held by what keeps its drag leaves the text selection alone (4b)"),
("canvas/page_input.rs", "page_overlays", "a click follows the link that holds its press (4b)"),
("tools/content_ui.rs", "page_input", "added text and pictures, until they are told (4b)"),
("tools/edit_text_ui.rs", "page_input", "Edit text's paragraphs, until they are told (4b)"),
("tools/edit_text_ui/image.rs", "image_input", "Edit text's pictures, until they are told (4b)"),
("tools/object_ui.rs", "input", "the objects selected together, until they are told (4b)"),
];
/// page and tells each module what the press is to its page (`PagePress`): modules never read the
/// router, and outside `canvas` the compiler refuses them (its methods are `pub(in crate::canvas)`).
/// An unused entry fails the test.
const ROUTER_READERS: [(&str, &str, &str); 1] = [(
"canvas/page_input.rs",
"page_tool_input",
"the page view: what the press is to each page (`press_for`), and whether its drag is the text's (`keeps_drag`)",
)];
/// Words a page module's functions are not named with: a function that grabs or owns a press decides
/// for itself whether the press is its own, which the page view tells it (`PagePress`).
const PRESS_CLAIMING_WORDS: [&str; 2] = ["grabs", "owns"];
/// The function `line` declares, when one of the words of its name is one of
/// [`PRESS_CLAIMING_WORDS`].
fn press_claiming_fn(line: &str) -> Vec<String> {
declares(line.trim_start())
.filter(|name| name.split('_').any(|word| PRESS_CLAIMING_WORDS.contains(&word)))
.map(str::to_owned)
.into_iter()
.collect()
}
/// Whether `line` takes the router whole (`let r = &view.router;`), which the checks by method
/// name would not see through: anything but a method call on it.
fn takes_the_router(line: &str) -> bool {
line.match_indices(".router")
.any(|(at, name)| line[at + name.len()..].chars().next().is_none_or(|c| c != '.' && !(c.is_alphanumeric() || c == '_')))
}
/// What ignores the press the pages hold until the button is up.
const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"];
@@ -595,34 +634,6 @@ const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"];
const PRESS_IGNORERS: [(&str, &str, &str); 1] =
[("canvas/page_input.rs", "cancel_on_escape", "Escape abandons the gesture the press drives, whichever tool's")];
/// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who
/// may).
const PAGE_HIT_CALLS: [&str; 12] = [
"link_ui::hit(",
"prepare::hit(",
"forms_ui::hit(",
"comments::hit(",
"comments::context_hit(",
"camera_ui::hit(",
"edit_text_ui::hit(",
"edit_text_ui::picture_hit(",
"edit_text_ui::picture_handle_hit(",
"edit_text_ui::crop_hit(",
"object_ui::hit(",
"content_ui::hit(",
];
/// Whether a press handler's `code` acts on the owner the router captured, and calls no hit test
/// of its own to choose one.
fn routed_press_handler(code: &str) -> bool {
let code = code.lines().filter(|line| !line.trim_start().starts_with("//")).collect::<Vec<_>>().join("\n");
code.contains("router.owner(")
&& !PAGE_HIT_CALLS.iter().any(|call| code.contains(call))
&& !uses(&code, "hit(")
&& !uses(&code, "body_hit(")
&& !uses(&code, "handle_hit(")
}
/// Page modules act on a press only when the router says it is theirs. The hits at the press
/// point decide its owner once, in one place, and a `Hit` comes only from a module's hit test,
/// so what the cursor promised is what the press does. Where each module tested the press
@@ -653,7 +664,7 @@ fn page_modules_act_on_a_press_only_through_the_router() {
// functions use it.
let known: Vec<&str> = ROUTER_WRITES.iter().chain(ROUTER_READS.iter()).copied().collect();
let unlisted = page_module_matches(|line| {
let router = uses(line, ".router.") || uses(line, "PageRouter::");
let router = uses(line, ".router.") || uses(line, "PageRouter::") || takes_the_router(line);
if router && !known.iter().any(|name| uses(line, name)) { vec!["router".to_owned()] } else { Vec::new() }
});
problems.extend(
@@ -687,6 +698,13 @@ fn page_modules_act_on_a_press_only_through_the_router() {
place that must ask? Add the function to PAGE_HITS_ASKERS in src/canvas/page_pointer/tests.rs, with why.",
"no longer asks the page view's hits: remove it from PAGE_HITS_ASKERS.",
));
// A module is told what the press is to its page; none decides it for itself.
problems.extend(page_module_matches(press_claiming_fn).into_iter().map(|(file, _, n, name)| {
format!(
"src/{file}:{n}: `fn {name}` decides whether a press is its module's. The page view tells each module what the \
press is to its page (`PagePress`): act on that, in a function named for what it does."
)
}));
problems.extend(misuses(
&PRESS_IGNORES,
&PRESS_IGNORERS,
@@ -694,19 +712,6 @@ fn page_modules_act_on_a_press_only_through_the_router() {
`DocView::cancel_pointer_gestures` instead.",
"no longer ignores a press: remove it from PRESS_IGNORERS.",
));
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
for (file, function) in [
("tools/content_ui.rs", "page_input"),
("tools/edit_text_ui.rs", "page_input"),
("tools/edit_text_ui/image.rs", "image_input"),
("tools/object_ui.rs", "input"),
] {
let source = std::fs::read_to_string(src.join(file)).expect("page input module");
let body = source.lines().skip_while(|line| declares(line) != Some(function)).take_while(|line| *line != "}").collect::<Vec<_>>().join("\n");
if !routed_press_handler(&body) {
problems.push(format!("src/{file}: {function} must consume the captured router owner without repeating a hit test"));
}
}
// A hit test is a function named `hit` or `…_hit`: one answer to "what of mine is here?",
// which gives the module's cursor, its tip and its press.
problems.extend(misuses_in(
@@ -720,14 +725,6 @@ fn page_modules_act_on_a_press_only_through_the_router() {
"calls no hit test any more: remove it from HIT_TESTERS.",
));
problems.extend(hit_test_aliases());
// An entry no page module calls any more no longer guards anything.
let called = page_module_uses(&PAGE_HIT_CALLS);
problems.extend(
PAGE_HIT_CALLS
.iter()
.filter(|call| !called.iter().any(|(_, _, _, name)| name == *call))
.map(|call| format!("no page module calls `{call}` any more: remove it from PAGE_HIT_CALLS.")),
);
let builds = page_module_uses(&HIT_BUILDS);
assert!(!builds.is_empty(), "no page module makes a `Hit` any more: update HIT_BUILDS");
problems.extend(builds.into_iter().filter(|(_, function, _, _)| !function.as_deref().is_some_and(is_hit_test)).map(|(file, _, n, name)| {
@@ -758,6 +755,15 @@ fn the_page_guards_read_hit_tests_split_calls_and_aliases() {
assert!(hit_test_calls("let all = self.hits(view, p, false);").is_empty());
assert_eq!(declares("pub(crate) const fn crop_hit<'a>(view: &DocView) {"), Some("crop_hit"));
assert_eq!(declares("let hit = 1;"), None);
assert_eq!(press_claiming_fn(" pub(crate) fn fill_grabs(view: &DocView) -> bool {"), ["fill_grabs"]);
assert_eq!(press_claiming_fn("fn owns_press() {"), ["owns_press"]);
assert_eq!(press_claiming_fn(" pub(in crate::canvas) fn link_grabs(view: &DocView) {"), ["link_grabs"]);
assert!(press_claiming_fn("fn dropdowns() {").is_empty(), "a word that ends in one");
assert_eq!(declares("pub(in crate::canvas) fn drag_began(&mut self) {"), Some("drag_began"));
assert!(takes_the_router("let r = &view.router;") && takes_the_router("keep(view.router, 1)"));
assert!(!takes_the_router("view.router.press_for(i)") && !takes_the_router("view.routers.len()"));
assert!(press_claiming_fn("fn holds(&self, page: usize) -> bool {").is_empty());
assert!(press_claiming_fn("let grabs = 1;").is_empty());
let source = "impl PageHits<'_> {\n fn at(&self) {\n self\n .hits(view, p, false)\n }\n}\nfn after() {\n // view.router.owner(0)\n let _ = view\n .router\n .owner(0);\n}\n";
assert_eq!(
code_lines(source),
@@ -777,13 +783,3 @@ fn the_page_guards_read_hit_tests_split_calls_and_aliases() {
let aliases: Vec<(String, String)> = [("hit", "corner_under"), ("picture_hit", "under")].map(|(a, b)| (a.to_owned(), b.to_owned())).to_vec();
assert_eq!(aliased_hit_tests(source), aliases);
}
#[test]
fn the_page_press_guard_rejects_missing_owners_and_second_hit_tests() {
assert!(routed_press_handler("let owner = view.router.owner(page);"));
assert!(!routed_press_handler("// view.router.owner(page)\nlet owner = None;"));
assert!(!routed_press_handler("let owner = view.router.owner(page);\nlet again = cx.hit(origin);"));
for call in PAGE_HIT_CALLS {
assert!(!routed_press_handler(&format!("let owner = view.router.owner(page);\n{call}origin);")), "{call}");
}
}
+91 -26
View File
@@ -6,10 +6,13 @@
//! press point decide who captures it ([`PageRouter::press`]), by the same table: the highest layer
//! the active tool reaches. Until the button is up only the owner acts on the press, its drag and
//! its release, so what the cursor promised is what the press does, and nothing takes a gesture
//! back halfway. Each page is told what it has to do with the press ([`PagePress`]): its owner's,
//! the active tool's, or none of its business.
//! back halfway; an owner that declines the drag (text markup, a link with the Link tool) lets it
//! go as it starts, and the drag is nobody's. Each page is told what it has to do with the press
//! ([`PagePress`]): its owner's, the active tool's, or none of its business. Only the page view
//! reads the router: its methods are `canvas`'s alone.
use egui::CursorIcon;
use pdfcraft_engine::ObjectTarget;
use crate::QuickTool;
use crate::page_cursor::Layer;
@@ -45,10 +48,31 @@ pub(crate) enum Owner {
pub(crate) enum PicturePart {
/// The picture: a click selects it, a drag moves the selected one.
Body,
/// A picture lying over the selected one, within the selected one's frame: a click selects the
/// one on top, a drag moves the selected one.
OverSelected,
/// A resize grip in `ImageObb::handles` order (four corners and four edge midpoints).
Corner(usize),
/// The selected picture's rotate knob: a drag turns it.
Knob,
/// The picture being cropped, which holds every press on its page.
Crop(CropPart),
}
/// What of a picture being cropped a hit is on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CropPart {
/// A grip of the box kept, in `crop_handles` order (four corners, then the top, right, bottom
/// and left edge midpoints): a drag moves that side or corner.
Grip(usize),
/// The box kept: a drag moves it over the picture.
Keep,
/// The picture outside the box kept, shown or cut away: a press there moves nothing, and a
/// right-click is the crop's, as on the box and its grips (#5835).
Cut,
/// The rest of the page, its Done and Cancel controls among it: a press there is crop mode's,
/// and moves nothing.
Page,
}
/// What of the objects a hit is on.
@@ -58,6 +82,15 @@ pub(crate) enum ObjectsPart {
Selection,
/// The frame drawn around the paragraph open for typing: a click selects the box, a drag moves it.
Frame,
/// A press with Shift or ⌘ (Ctrl): it adds the object under it to the objects selected
/// together, or takes it out of them, or on none starts an area selection that adds to them.
Toggle(Option<ObjectTarget>),
/// A press with Shift or ⌘ while text being typed is open (a new box, or a paragraph with
/// changes), which must be finished or discarded first: it is held until the button is up, and
/// selects nothing.
Held,
/// A press with Shift or ⌘ on a page whose objects can't be selected together: it says so.
Refused,
}
/// The part of a paragraph a hit is on.
@@ -102,7 +135,7 @@ impl Hit {
/// The hit that wins among `hits` with `tool`: the highest layer the tool reaches, the last of
/// equals (modules are asked bottom up, in paint order).
pub(crate) fn top(hits: impl IntoIterator<Item = Hit>, tool: QuickTool) -> Option<Hit> {
pub(in crate::canvas) fn top(hits: impl IntoIterator<Item = Hit>, tool: QuickTool) -> Option<Hit> {
hits.into_iter().filter(|h| h.layer.reached_by(tool)).fold(None, |best: Option<Hit>, h| match best {
Some(b) if b.layer > h.layer => Some(b),
_ => Some(h),
@@ -115,13 +148,15 @@ pub(crate) enum PagePress {
/// The press began on this page, and the owner's hit won it: its module acts on it. Others
/// only let go of their own state (a click on anything but the selected comment unselects it).
Mine(Owner),
/// No button is down, or the press began on this page and nobody's hit won it: the active
/// tool's. Also every page's while a press the page view does not hold is down (one that began
/// No button is down, or the press began on this page and nobody's hit won it, or its owner let
/// its drag go ([`PageRouter::drag_began`]): the active tool's. Also every page's while a
/// press the page view does not hold is down (one that began
/// over a panel), and after the press was dropped ([`PageRouter::cancel`]) until the button is
/// up (`Elsewhere` instead when Escape came in the press's own frame; page input skips a
/// cancelled press either way).
Free,
/// The press began on another page, or off the pages (between them, or beside one).
/// The press began on another page, or off the pages (between them, or beside one) where no
/// page has anything under it.
Elsewhere,
}
@@ -137,9 +172,12 @@ impl PagePress {
/// A press the pages hold, and who it belongs to.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Capture {
struct Capture {
pub owner: Owner,
pub declines_drag: bool,
/// What lay on top at the press with the objects selected together aside: a double-click on
/// them reaches it.
under_objects: Option<Owner>,
}
/// The press the pages hold, from its frame until the button is up.
@@ -153,25 +191,28 @@ pub(crate) struct PageRouter {
impl PageRouter {
/// A new press on the pages: the last one's owner goes, and until a page claims it the press is
/// between the pages.
pub(crate) fn begin(&mut self) {
pub(in crate::canvas) fn begin(&mut self) {
self.capture = None;
self.began = Some(None);
}
/// A press on `page`: `hits` at the press point decide who it belongs to. A press nobody's
/// hit claims is held by nobody (the page's text, or a tool that acts anywhere on the page).
/// A press on `page`, or past its edge where `hits` are under it (the selected picture's rotate
/// knob): `hits` at the press point decide who it belongs to. A press nobody's hit claims is held
/// by nobody (the page's text, or a tool that acts anywhere on the page).
/// Only a press just begun ([`Self::begin`]) is claimed, by the first page that does: pages
/// that touch (the gaps hidden) can share a row of pixels, and a press there is one page's.
pub(crate) fn press(&mut self, page: usize, hits: impl IntoIterator<Item = Hit>, tool: QuickTool) {
pub(in crate::canvas) fn press(&mut self, page: usize, hits: impl IntoIterator<Item = Hit>, tool: QuickTool) {
if self.began != Some(None) {
return;
}
self.capture = top(hits, tool).map(|h| Capture { owner: h.owner, declines_drag: h.declines_drag });
let hits: Vec<Hit> = hits.into_iter().collect();
let under_objects = top(hits.iter().filter(|h| !matches!(h.owner, Owner::Objects { .. })).cloned(), tool).map(|h| h.owner);
self.capture = top(hits, tool).map(|h| Capture { owner: h.owner, declines_drag: h.declines_drag, under_objects });
self.began = Some(Some(page));
}
/// What `page` has to do with the press the pages hold.
pub(crate) fn press_for(&self, page: usize) -> PagePress {
pub(in crate::canvas) fn press_for(&self, page: usize) -> PagePress {
match (self.began, &self.capture) {
(None, _) => PagePress::Free,
(Some(Some(p)), Some(capture)) if p == page => PagePress::Mine(capture.owner),
@@ -180,19 +221,28 @@ impl PageRouter {
}
}
/// Who holds the press on `page`.
pub(crate) fn owner(&self, page: usize) -> Option<Owner> {
self.press_for(page).owner()
/// What lay on top at the press with the objects selected together aside.
pub(in crate::canvas) fn under_objects(&self) -> Option<Owner> {
self.capture.as_ref().and_then(|c| c.under_objects)
}
/// Whether the press is held by someone who keeps its drag: then the page's text is not
/// selected by it.
pub(crate) fn keeps_drag(&self) -> bool {
pub(in crate::canvas) fn keeps_drag(&self) -> bool {
self.capture.as_ref().is_some_and(|c| !c.declines_drag)
}
/// The press turned into a drag: an owner that declines its drag lets it go, and from then on
/// the press is nobody's (a drag across a highlight selects the text under it; with the Link
/// tool, a drag from a link draws a new one). Its click stayed the owner's.
pub(in crate::canvas) fn drag_began(&mut self) {
if self.capture.as_ref().is_some_and(|c| c.declines_drag) {
self.capture = None;
}
}
/// The press is over once the button is up and its release has been seen.
pub(crate) fn settle(&mut self, any_down: bool, edges: bool) {
pub(in crate::canvas) fn settle(&mut self, any_down: bool, edges: bool) {
if !any_down && !edges {
self.capture = None;
self.began = None;
@@ -201,7 +251,7 @@ impl PageRouter {
/// Drops the press (Escape, automation's cancel): every page is the active tool's again
/// ([`PagePress::Free`]). Page input skips a press Escape cancelled until the button is up.
pub(crate) fn cancel(&mut self) {
pub(in crate::canvas) fn cancel(&mut self) {
self.capture = None;
self.began = None;
}
@@ -230,16 +280,16 @@ mod tests {
let mut r = PageRouter::default();
r.begin();
r.press(2, [hit(Layer::Comment, 7)], QuickTool::Select);
assert_eq!(r.owner(2), Some(hit(Layer::Comment, 7).owner));
assert_eq!(r.owner(1), None, "only on its page");
assert_eq!(r.press_for(2).owner(), Some(hit(Layer::Comment, 7).owner));
assert_eq!(r.press_for(1).owner(), None, "only on its page");
assert!(r.keeps_drag());
r.settle(false, true);
assert!(r.owner(2).is_some(), "the release frame still belongs to the press");
assert!(r.press_for(2).owner().is_some(), "the release frame still belongs to the press");
r.settle(false, false);
assert_eq!(r.owner(2), None);
assert_eq!(r.press_for(2).owner(), None);
r.begin();
r.press(0, [], QuickTool::Select);
assert_eq!(r.owner(0), None, "a press nobody claims is held by nobody");
assert_eq!(r.press_for(0).owner(), None, "a press nobody claims is held by nobody");
assert!(!r.keeps_drag());
}
@@ -281,8 +331,23 @@ mod tests {
let mut r = PageRouter::default();
r.begin();
r.press(0, [Hit { declines_drag: true, ..hit(Layer::Comment, 0) }], QuickTool::Select);
assert!(r.owner(0).is_some() && !r.keeps_drag());
assert!(r.press_for(0).owner().is_some() && !r.keeps_drag());
r.cancel();
assert_eq!(r.owner(0), None);
assert_eq!(r.press_for(0).owner(), None);
}
/// A drag the owner declines is nobody's once it starts, on the page it began on; a drag the
/// owner keeps stays its own.
#[test]
fn a_declined_drag_is_nobodys_once_it_starts() {
let mut r = PageRouter::default();
r.begin();
r.press(1, [Hit { declines_drag: true, ..hit(Layer::Comment, 0) }], QuickTool::Select);
r.drag_began();
assert_eq!((r.press_for(1), r.press_for(0)), (PagePress::Free, PagePress::Elsewhere));
r.begin();
r.press(1, [hit(Layer::Comment, 2)], QuickTool::Select);
r.drag_began();
assert_eq!(r.press_for(1), PagePress::Mine(hit(Layer::Comment, 2).owner));
}
}
+2 -2
View File
@@ -102,14 +102,14 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe
// began, a click where it came up (egui has cleared the press's origin by its release, and the
// pointer may have moved on in the same frame).
let at = if resp.clicked() { ptr.released } else { origin };
if (resp.drag_started() || resp.clicked())
if (ptr.drag_started || resp.clicked())
&& press == PagePress::Free
&& at.is_some_and(|p| xf.rect.contains(p))
&& view.camera.is_none_or(|q| q.page != page)
{
view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph));
}
if resp.drag_started()
if ptr.drag_started
&& let PagePress::Mine(Owner::CameraCorner { corner }) = press
&& let Some(q) = view.camera.as_mut().filter(|q| q.page == page)
{
+18 -9
View File
@@ -12,8 +12,8 @@ use crate::page_router::{CommentPart, Hit, Owner, PagePress};
/// the press the pages hold: a press on a comment where the tool reaches it ([`hit`]: the Select
/// tool, Fill & Sign over its marks, a shape tool over the selected shape's handles) is that
/// comment's, and one nobody holds is a commenting tool's. When another tool took this frame
/// (`taken`: Prepare, added content, which still decide for themselves), the commenting tools leave
/// it to that tool, as before; the Select tool's click there still unselects. Returns `true` when
/// (`taken`: Prepare, added content), the commenting tools leave it to that tool, as before; the
/// Select tool's click there still unselects. Returns `true` when
/// the pointer gesture belongs to commenting (text selection must ignore it).
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, press: PagePress, taken: bool, view: &mut DocView) -> bool {
let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin);
@@ -227,7 +227,7 @@ fn select_input(
};
let cv = &mut view.comments;
let mut consumed = false;
if resp.drag_started()
if cx.ptr.drag_started
&& let (Some((a, part)), Some(o)) = (owner, origin)
{
match part {
@@ -272,9 +272,17 @@ fn select_input(
CommentPart::Body => {}
}
}
if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page) {
let live =
matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page);
if live {
consumed = true;
}
// A move, resize or reshape ends where its drag stops. One whose release the pages missed (the
// button is up, and no drag stopped here) ends with nothing applied: where it came up is not
// known, and the pointer may be anywhere by now.
if live && !resp.drag_stopped() && !cx.ptr.primary_down {
cv.gesture = None;
}
if resp.drag_stopped()
&& let Some(p) = pointer
{
@@ -374,7 +382,8 @@ fn finish_select_gesture(ui: &egui::Ui, cx: &PageCx<'_>, view: &mut DocView, p:
/// Text asks for the replacement.
pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) {
let QuickTool::Comment(tool) = cx.tool else { return };
if tool == CommentTool::ReplaceText && cx.allowed && (resp.drag_stopped() || resp.double_clicked()) {
let finished = resp.drag_stopped_by(egui::PointerButton::Primary) || resp.double_clicked();
if tool == CommentTool::ReplaceText && cx.allowed && finished {
if let Some((page, quads)) = view.selection_quads(cx.info).filter(|(p, _)| *p == cx.page) {
view.clear_selection();
let last = quads.last().copied().unwrap_or_default();
@@ -384,7 +393,7 @@ pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut
}
return;
}
if tool.markup().is_none() || !cx.allowed || !(resp.drag_stopped() || resp.double_clicked()) {
if tool.markup().is_none() || !cx.allowed || !finished {
return;
}
if let Some(kind) = tool.markup()
@@ -429,7 +438,7 @@ impl Gestures<'_> {
if over_page {
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
}
if resp.drag_started()
if cx.ptr.drag_started
&& pressed_here
&& let Some(o) = origin
{
@@ -567,7 +576,7 @@ impl Gestures<'_> {
if over_page {
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
}
if resp.drag_started()
if cx.ptr.drag_started
&& pressed_here
&& let Some(o) = origin
{
@@ -648,7 +657,7 @@ impl Gestures<'_> {
// A text box also takes a drag, as in Acrobat: the box is drawn at that size and its
// text wraps inside it (#2669).
if tool == CommentTool::TextBox {
if resp.drag_started()
if cx.ptr.drag_started
&& pressed_here
&& let Some(o) = origin
{
+86 -38
View File
@@ -11,7 +11,7 @@ use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::page_router::{Hit, Owner};
use crate::page_router::{Hit, Owner, PagePress};
use crate::theme::Tokens;
use crate::widgets;
@@ -108,6 +108,10 @@ pub struct ContentView {
/// Select the item an add creates (the page's item count before it).
pub select_added: Option<(usize, usize)>,
grab: Option<(usize, Grab, Pos2)>,
/// Where the press nobody holds began, on which page: Add text's click starts its new box there.
pressed_at: Option<(usize, Pos2)>,
/// A button is down on the pages this frame (for [`keys`], which runs after them).
held: bool,
undrawable: UndrawableMemo,
layout: Option<(AddedText, f32, f64, [f64; 4])>,
}
@@ -116,6 +120,12 @@ impl ContentView {
/// Forget an in-progress pointer grab for content editing.
pub(crate) fn cancel_pointer_gesture(&mut self) {
self.grab = None;
self.pressed_at = None;
}
/// An added item is being moved or resized on `page`.
pub(crate) fn moving_on(&self, page: usize) -> bool {
self.grab.is_some_and(|(p, ..)| p == page)
}
pub(crate) fn finish_draft(&mut self, added: &[Added]) -> Option<Edit> {
@@ -372,17 +382,34 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, added: &[Added],
Some(Hit::new(Owner::Added { index: *index, corner: None }, Layer::Placed, egui::CursorIcon::Move))
}
/// Pointer input on one page in Edit mode (`adding_text` with the Text tool). `true` when it
/// used the input.
pub(crate) fn page_input(
cx: PageToolContext<'_>,
added: &[Added],
adding_text: bool,
edit_on_click: bool,
style: &AddedText,
view: &mut DocView,
) -> bool {
/// What added content is told on one page.
#[derive(Clone, Copy)]
pub(crate) struct ContentCx<'a> {
pub added: &'a [Added],
/// What the press the pages hold is to this page. With the Edit panel open, a press on added
/// text is its own with the tools that pick it up ([`hit`]).
pub press: PagePress,
/// Edit text's input is open: no dialog, palette, other window or other tab has it.
pub gate: bool,
/// The tool picks added content up (Select, Add text, Edit text): a drag moves it or resizes it.
pub picks_up: bool,
/// The Add text tool: a click nobody holds starts a new box where it was pressed.
pub adding_text: bool,
/// A click on added text opens it for typing (Add text, Edit text); otherwise a double-click does.
pub edit_on_click: bool,
/// The style of new text.
pub style: &'a AddedText,
}
/// Pointer input on one page in Edit mode, on the presses that are added content's ([`hit`]) and,
/// with Add text, the clicks nobody holds. `true` when it used the input.
pub(crate) fn page_input(cx: PageToolContext<'_>, ccx: ContentCx<'_>, view: &mut DocView) -> bool {
let PageToolContext { resp, xf, ptr, page, info, .. } = cx;
let ContentCx { added, press, gate, picks_up, adding_text, edit_on_click, style } = ccx;
view.content.held = ptr.any_down;
if !gate {
return false;
}
let mut consumed = false;
let pointer = ptr.pos;
let cv = &mut view.content;
@@ -412,19 +439,28 @@ pub(crate) fn page_input(
}
return consumed;
}
// Where a press nobody holds begins on this page: Add text's click starts its box there.
if let Some(o) = ptr.pressed().filter(|_| press != PagePress::Elsewhere) {
cv.pressed_at = (press == PagePress::Free).then_some((page, o));
}
let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return consumed };
let items: Vec<(usize, &Added)> = on_page(added, page).into_iter().filter(|(_, a)| matches!(a.content, AddedContent::Text(_))).collect();
let hit = items.iter().rev().find(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(p)).map(|(i, a)| (*i, *a));
let selected_rect = cv
.selected
.filter(|s| s.0 == page)
.and_then(|(_, si)| items.iter().find(|(i, _)| *i == si))
.map(|(_, a)| screen_rect(xf, info, page, a.content.rect()));
let corner = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(p) <= 6.0));
if adding_text && hit.is_none() {
// What the press was on ([`hit`]): an item, or a corner of the selected one.
let owned = match press {
PagePress::Mine(Owner::Added { index, corner }) => Some((index, corner)),
_ => None,
};
// The tools that don't pick added content up have no hit on it: with the Edit panel open their
// clicks on added text still select it and their presses there are its, by this test at the
// pointer, as before.
let under = (!picks_up)
.then(|| items.iter().rev().find(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(p)).map(|(i, _)| *i))
.flatten();
let target = if picks_up { owned } else { under.map(|i| (i, None)) };
if adding_text {
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Text);
// A new box where nothing drawn over the page holds the press.
if resp.clicked() && view.router.owner(page).is_none() {
if resp.clicked() && press == PagePress::Free {
// Text that can't be drawn stays where it is being typed (#125).
if cv.hold_blocked() {
return true;
@@ -434,7 +470,9 @@ pub(crate) fn page_input(
let typed = cv.draft.take().and_then(|d| finish(cv, d, added));
// The Format panel styles the new text, not the box just kept.
(cv.selected, cv.select_added) = (None, None);
let at = to_display(xf, info, page, p);
// Where the click was pressed, as every owner acts where its press was.
let start = cv.pressed_at.take().filter(|(pg, _)| *pg == page).map_or(p, |(_, o)| o);
let at = to_display(xf, info, page, start);
let mut style = style.clone();
style.text.clear();
let page_width = info.pages.get(page).map_or(f64::INFINITY, |p| p.width as f64);
@@ -445,29 +483,31 @@ pub(crate) fn page_input(
if typed.is_some() {
view.pending_edit = typed;
}
return consumed;
}
return consumed;
}
// A drag on what the press was on ([`hit`]): a corner resizes the selected item, the item
// itself moves.
if resp.drag_started() {
// A drag on what the press was on: a corner resizes the selected item, the item itself moves,
// with the tools that pick added content up.
if ptr.drag_started {
let origin = ptr.origin.unwrap_or(p);
match view.router.owner(page) {
Some(Owner::Added { corner: Some((left, top)), .. }) => {
match owned.filter(|_| picks_up) {
Some((_, Some((left, top)))) => {
cv.grab = Some((page, Grab::Corner(left, top), origin));
consumed = true;
}
Some(Owner::Added { index, corner: None }) => {
Some((index, None)) => {
cv.selected = Some((page, index));
cv.grab = Some((page, Grab::Move, origin));
consumed = true;
}
_ => {}
None => {}
}
return consumed;
return consumed || owned.is_some();
}
if (resp.double_clicked() || edit_on_click && resp.clicked() && corner.is_none())
&& let Some((i, a)) = hit
// A double-click, or with `edit_on_click` a click, opens the text the press was on for typing.
if (resp.double_clicked() || edit_on_click && resp.clicked())
&& let Some((i, None)) = target
&& let Some((_, a)) = items.iter().find(|(index, _)| *index == i)
&& let AddedContent::Text(t) = &a.content
{
if cv.hold_blocked() {
@@ -493,17 +533,21 @@ pub(crate) fn page_input(
consumed = true;
return consumed;
}
// A click selects the item its press was on, keeps the selected one when on a corner of it, and
// on anything else on this page (but the objects selected together, which take the selection
// themselves) leaves it unselected.
if resp.clicked() {
match hit {
Some((i, _)) => {
match (target, press) {
(Some((i, None)), _) => {
cv.selected = Some((page, i));
consumed = true;
}
None if corner.is_none() => cv.selected = None,
None => consumed = true,
(Some((_, Some(_))), _) => consumed = true,
(None, PagePress::Elsewhere | PagePress::Mine(Owner::Objects { .. })) => {}
(None, _) => cv.selected = None,
}
}
consumed || hit.is_some() && resp.is_pointer_button_down_on()
consumed || owned.is_some() || under.is_some() && resp.is_pointer_button_down_on()
}
/// Outlines of added items (hover and selection with handles) and the box being dragged, the
@@ -593,7 +637,11 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, image_controller_act
return;
}
let (del, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace), i.key_pressed(egui::Key::Escape)));
if del && let Some((page, index)) = view.content.selected.take() {
// Not while a button is down: the press held would then name another item.
if del
&& !view.content.held
&& let Some((page, index)) = view.content.selected.take()
{
view.pending_edit = Some(Edit::DeleteContent { page, index });
} else if esc {
view.content.selected = None;
+57 -21
View File
@@ -14,17 +14,17 @@ use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::page_pointer::PagePointer;
use crate::page_router::{Hit, Owner, ParagraphPart};
use crate::page_router::{Hit, Owner, PagePress, ParagraphPart};
mod background;
mod image;
mod layout;
pub(crate) use background::TextBackground;
pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection, image_menu_id};
pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection};
pub(crate) use image::{
ImageCx, Matrix, body_hit as picture_hit, crop_controls_contain, crop_hit, handle_hit as picture_handle_hit, image_context_menu, image_input,
quad_contains, user_to_screen,
ImageCx, Matrix, body_hit as picture_hit, crop_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, picture_context,
picture_keys, quad_contains, user_to_screen,
};
#[cfg(test)]
use image::{rotate_knob, turn_degrees, turn_degrees_delta};
@@ -659,10 +659,26 @@ fn open_editor(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::TextBlock], bo
});
}
/// Lines and their screen boxes for a page; hover outlines, click opens the editor. Returns
/// `true` when the pointer was used.
pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::TextBlock], view: &mut DocView, allow_interaction: bool) -> bool {
/// What Edit text's paragraphs are told on one page.
#[derive(Clone, Copy)]
pub(crate) struct ParagraphCx<'a> {
/// What the press the pages hold is to this page.
pub press: PagePress,
/// Edit text's input is open: no dialog, palette, other window or other tab has it.
pub gate: bool,
/// What a press under the pointer would take (the top hit there): the paragraph outlined.
pub hover: Option<&'a Hit>,
/// With Add text, the page's added content: a click on a paragraph finishes the text being
/// typed before it opens the paragraph.
pub adding: Option<&'a [pdfcraft_engine::Added]>,
}
/// Lines and their screen boxes for a page: the paragraph under the pointer is outlined, and the
/// press that is a paragraph's (`press`) moves it, rewraps it or, a click, opens it in the editor.
/// Returns `true` when the pointer was used.
pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::TextBlock], pcx: ParagraphCx<'_>, view: &mut DocView) -> bool {
let PageToolContext { ui, resp, xf, ptr, page, doc, .. } = cx;
let ParagraphCx { press, gate, hover, adding } = pcx;
let info = &doc.info;
if view.block_drag.is_some_and(|d| d.generation != doc.edit_generation()) {
view.block_drag = None;
@@ -679,7 +695,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text
}
// A box a switching click asked for opens now, once the edit queued with that click has
// applied and `lines`/`boxes` are the document's new inventory (#2022).
if allow_interaction
if gate
&& active.is_none()
&& let Some((open_page, key)) = view.pending_text_open.clone()
&& open_page == page
@@ -691,7 +707,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text
None => view.pending_text_open = None,
}
}
if !allow_interaction {
if !gate {
return false;
}
let upright = has_edges(info, page);
@@ -750,12 +766,15 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text
// What it grabs is what the press was on ([`hit`]), not what is under the pointer now: egui
// only calls it a drag once the pointer has moved a few pixels (or been held a moment), and a
// quick flick has left the edge by then.
let owner = match view.router.owner(page) {
Some(Owner::Paragraph { block, part }) => Some((block, part)),
let owner = match press {
PagePress::Mine(Owner::Paragraph { block, part }) => Some((block, part)),
_ => None,
};
if resp.drag_started()
// Add text holds text that can't be drawn where it is being typed (#125).
let held_draft = adding.is_some() && view.content.blocked().is_some();
if ptr.drag_started
&& active.is_none()
&& !held_draft
&& let Some(o) = ptr.origin
&& let Some((block, part)) = owner
{
@@ -767,18 +786,35 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text
view.block_drag = Some(BlockDrag { page, block, generation: doc.edit_generation(), start: o, grip });
return true;
}
let Some(p) = ptr.within(xf.rect) else { return false };
// The paragraph under the pointer: outlined, with its width handles when closed.
let Some((hovered, _)) = grab_at(&boxes, p, upright) else { return false };
if active != Some(hovered) {
painter.rect_stroke(boxes[hovered], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
}
if upright && active.is_none() {
draw_handles(boxes[hovered]);
// The paragraph under the pointer, where a press would take it: outlined, with its width
// handles when closed. Not while the objects selected together or added content being moved
// hold the page.
let hovered = match hover.map(|h| h.owner) {
Some(Owner::Paragraph { block, .. }) => Some(block),
_ => None,
};
if let Some(b) = hovered.filter(|_| !view.objects.holds(page) && !view.content.moving_on(page)).and_then(|h| boxes.get(h).map(|b| (h, *b))) {
if active != Some(b.0) {
painter.rect_stroke(b.1, CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
}
if upright && active.is_none() {
draw_handles(b.1);
}
}
// A click opens the paragraph the press was on.
let Some((hit, _)) = owner else { return true };
let Some((hit, _)) = owner else { return hovered.is_some() };
if resp.clicked() {
// Add text: text that can't be drawn stays where it is being typed (#125), and text being
// typed is finished first.
if let Some(added) = adding {
if view.content.hold_blocked() {
return true;
}
if let Some(edit) = view.content.finish_draft(added) {
view.pending_edit = Some(edit);
}
view.content.selected = None;
}
view.image_selection = None;
// A click that moves the editor to another paragraph applies the open one first, so its
// typed text is not dropped with the switched-away editor (#2022). A click on the open
+199 -197
View File
@@ -2,7 +2,7 @@ use super::{ACCENT, Color32, CornerRadius, DocInfo, DocView, Edit, MAX_OUTLINES,
use crate::canvas::{ImageObb, PainterExt, image_screen_rect};
use crate::image_engine::{CROP_ACCENT, apply_crop, apply_resize, apply_resize_proportional, draw_handle, draw_rule_of_thirds};
use crate::page_cursor::Layer;
use crate::page_router::{Hit, Owner, PicturePart};
use crate::page_router::{CropPart, Hit, Owner, PagePress, PicturePart};
/// What the pointer and the page are, for the image tools on one page.
#[derive(Clone, Copy)]
@@ -13,7 +13,10 @@ pub(crate) struct ImageCx<'a> {
pub ptr: &'a PagePointer,
pub page: usize,
pub info: &'a DocInfo,
pub allow_interaction: bool,
/// What the press the pages hold is to this page.
pub press: PagePress,
/// Edit text's input is open: no dialog, palette, other window or other tab has it.
pub gate: bool,
}
/// The 8 resize handles on an image (4 corners + 4 edge centers).
@@ -116,14 +119,29 @@ pub struct CropMode {
pub keep: [f64; 4],
/// A drag in progress: the grip (0–7 handles, 8 inside), where it started and the box then.
pub(crate) drag: Option<(usize, Pos2, [f64; 4])>,
/// Where its Done, Cancel and Remove Crop controls were drawn last (screen): the hand shows
/// over them ([`crop_hit`]).
pub(crate) controls: Vec<Rect>,
}
impl CropMode {
pub fn new(keep: [f64; 4]) -> Self {
Self { keep, drag: None }
Self { keep, drag: None, controls: Vec::new() }
}
}
/// How far from a crop grip a press still takes it, and the pointer shows its cursor (screen).
const CROP_GRIP_REACH: f32 = 10.0;
/// The picture `image` being cropped to `keep`, on screen: its whole frame, and the box kept
/// within it. `None` when either has no area.
fn crop_frames(xf: &PageXform, info: &DocInfo, page: usize, image: &pdfcraft_engine::PageImage, keep: [f64; 4]) -> Option<(Rect, Rect)> {
let full = xf.user_rect(info, page, image.rect.map(|v| v as f32));
let b = if full.is_positive() { full } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) };
let k = xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b);
(b.is_finite() && b.is_positive() && b.size().is_finite() && k.is_finite() && k.is_positive()).then_some((b, k))
}
pub(crate) use pdfcraft_content::Matrix;
/// The displayed crop frame, expressed in page user space, rather than the full image placement.
@@ -246,50 +264,31 @@ fn same_rect(a: [f64; 4], b: [f64; 4]) -> bool {
a.into_iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3)
}
fn crop_bounds(image: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect {
let full = xf.user_rect(info, page, image.rect.map(|v| v as f32));
if full.is_positive() { full } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) }
}
/// Whether `p` is on the crop box or one of its handles (#5835). The Done, Cancel and Remove
/// Crop pills are widgets of their own, so they take their clicks before the canvas does.
pub(crate) fn crop_controls_contain(
image: &pdfcraft_engine::PageImage,
mode: &CropMode,
xf: &PageXform,
info: &DocInfo,
page: usize,
p: Pos2,
) -> bool {
let b = crop_bounds(image, xf, info, page);
let k = xf.user_rect(info, page, mode.keep.map(|v| v as f32)).intersect(b);
if !b.is_finite() || !b.is_positive() || !b.size().is_finite() || !k.is_finite() || !k.is_positive() {
return false;
}
k.contains(p) || crop_handles(k).iter().any(|h| h.distance(p) < 10.0)
}
/// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true`
/// (the pointer and keys are the crop's while it lasts).
/// (the pointer is the crop's while it lasts; its keys are read by [`picture_keys`]).
#[allow(clippy::cognitive_complexity)]
fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_engine::PageImage) -> bool {
let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx;
let ImageCx { ui, resp, xf, ptr, page, info, press, gate } = *cx;
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 = ptr.pos;
let b = crop_bounds(image, xf, info, page);
let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b);
let k = keep_screen(mode.keep);
if !b.is_finite() || !b.is_positive() || !b.size().is_finite() || !k.is_finite() || !k.is_positive() {
let Some((b, k)) = crop_frames(xf, info, page, image, mode.keep) else {
mode.drag = None;
return true;
}
};
let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b);
if allow_interaction
&& resp.drag_started()
// A drag takes what its press was on ([`crop_hit`]).
if gate
&& ptr.drag_started
&& let Some(o) = ptr.origin
&& let PagePress::Mine(Owner::Picture { part: PicturePart::Crop(part), .. }) = press
{
let grip = crop_handles(k).iter().position(|h| h.distance(o) < 10.0).or_else(|| k.contains(o).then_some(8));
let grip = match part {
CropPart::Grip(g) => Some(g),
CropPart::Keep => Some(8),
CropPart::Cut | CropPart::Page => None,
};
if let Some(g) = grip {
mode.drag = Some((g, o, mode.keep));
}
@@ -352,7 +351,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e
// Crop handles
for (idx, h) in crop_handles(k).iter().enumerate() {
let is_active = mode.drag.as_ref().is_some_and(|d| d.0 == idx);
let is_hovered = hover_pos.is_some_and(|p| p.distance(*h) < 10.0);
let is_hovered = hover_pos.is_some_and(|p| p.distance(*h) < CROP_GRIP_REACH);
draw_handle(painter, *h, is_active, is_hovered, true, CROP_ACCENT);
}
@@ -385,6 +384,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e
let done = Rect::from_min_size(Pos2::new(right - done_w, y), egui::vec2(done_w, 22.0));
let cancel = Rect::from_min_size(Pos2::new(done.min.x - spacing - cancel_w, y), egui::vec2(cancel_w, 22.0));
let reset = reset_w.map(|w| Rect::from_min_size(Pos2::new(cancel.min.x - spacing - w, y), egui::vec2(w, 22.0)));
mode.controls = [Some(done), Some(cancel), reset].into_iter().flatten().collect();
let done_id = ui.scope_id().with("crop_pill_done").with(page).with(i);
let cancel_id = ui.scope_id().with("crop_pill_cancel").with(page).with(i);
@@ -393,10 +393,10 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e
let done_resp = ui.interact(done, done_id, egui::Sense::click());
let cancel_resp = ui.interact(cancel, cancel_id, egui::Sense::click());
let reset_resp = reset.map(|r| ui.interact(r, reset_id, egui::Sense::click()));
done_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, allow_interaction, tl!("Done")));
cancel_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, allow_interaction, tl!("Cancel")));
done_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, gate, tl!("Done")));
cancel_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, gate, tl!("Cancel")));
if let Some(response) = &reset_resp {
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, allow_interaction, tl!("Remove Crop")));
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, gate, tl!("Remove Crop")));
}
let t = crate::theme::Tokens::get(ui.ctx());
@@ -430,56 +430,83 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e
neutral_pill(r, response, tl!("Remove Crop"));
}
if let Some(p) = hover_pos {
let icon = if done_resp.hovered()
|| cancel_resp.hovered()
|| reset_resp.as_ref().is_some_and(|r| r.hovered())
|| done.contains(p)
|| cancel.contains(p)
|| reset.is_some_and(|r| r.contains(p))
{
egui::CursorIcon::PointingHand
} else if let Some(&g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0).and_then(|g| GRIPS.get(g)) {
g.cursor_icon()
} else if k.contains(p) {
if mode.drag.is_some() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }
} else {
egui::CursorIcon::Default
};
view.cursor.claim(Layer::Editor, icon);
// The cursor is the hit's ([`crop_hit`]), and a grip's or the box's being dragged.
if let Some((grip, ..)) = mode.drag {
view.cursor.claim(Layer::Drag, GRIPS.get(grip).map_or(egui::CursorIcon::Grabbing, |g| g.cursor_icon()));
}
let keys = |key: egui::Key| allow_interaction && ui.input(|inp| inp.key_pressed(key)) && !ui.ctx().egui_wants_keyboard_input();
let done_clicked = allow_interaction && (done_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| done.contains(p))));
let cancel_clicked = allow_interaction && (cancel_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| cancel.contains(p))));
let reset_clicked = allow_interaction
let done_clicked = gate && (done_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| done.contains(p))));
let cancel_clicked = gate && (cancel_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| cancel.contains(p))));
let reset_clicked = gate
&& reset
.is_some_and(|r| reset_resp.as_ref().is_some_and(|res| res.clicked()) || (resp.clicked() && hover_pos.is_some_and(|p| r.contains(p))));
if reset_clicked {
mode.keep = if image.has_editor_crop() { image.rect } else { initial_keep };
mode.drag = None;
} else if done_clicked || keys(egui::Key::Enter) {
let keep = mode.keep;
if let Some(s) = view.image_selection.as_mut() {
s.crop = None;
}
let is_full = same_rect(keep, image.rect);
if is_full {
if image.has_editor_crop() {
view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RemoveCrop });
}
} else if image.visible_rect != Some(keep) {
view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Crop(keep) });
}
} else if (cancel_clicked || keys(egui::Key::Escape))
&& let Some(s) = view.image_selection.as_mut()
{
} else if done_clicked {
apply_crop_mode(view, image);
} else if cancel_clicked && let Some(s) = view.image_selection.as_mut() {
s.crop = None;
}
true
}
/// Done in crop mode: it ends, and the picture `image` of the selection keeps the box kept (or
/// all of itself again).
fn apply_crop_mode(view: &mut DocView, image: &pdfcraft_engine::PageImage) {
let Some(s) = view.image_selection.as_mut() else { return };
let Some(keep) = s.crop.take().map(|mode| mode.keep) else { return };
let (page, index) = (s.page, s.index);
if same_rect(keep, image.rect) {
if image.has_editor_crop() {
view.pending_edit = Some(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::RemoveCrop });
}
} else if image.visible_rect != Some(keep) {
view.pending_edit = Some(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Crop(keep) });
}
}
/// The keys of the picture selected on `page`, a page being drawn, so the page grid and the Pages
/// panel have had theirs; nothing while the page is off the screen, as pages near it are drawn too.
/// Delete or Backspace removes it, wherever on the pages the pointer is, unless it is being cropped or
/// a button is down: the press held would then name another picture. In crop mode
/// Enter applies the crop and Escape leaves it, though not while a button is down (`held`): Escape
/// then cancels the press the pages hold, if any (`canvas::cancel_on_escape`), and crop mode stays.
/// Nothing while a text field has the keyboard.
pub(crate) fn picture_keys(ctx: &egui::Context, view: &mut DocView, page: usize, images: &[pdfcraft_engine::PageImage], held: bool) {
if ctx.egui_wants_keyboard_input() || view.page_screen_rect(page).is_none() {
return;
}
let Some(s) = view.image_selection.as_ref().filter(|s| s.page == page) else { return };
let index = s.index;
let key = |k: egui::Key| ctx.input(|i| i.key_pressed(k));
let image = images.get(index);
if s.crop.is_some() {
if key(egui::Key::Enter) {
if let Some(image) = image {
apply_crop_mode(view, image);
}
} else if !held
&& key(egui::Key::Escape)
&& let Some(s) = view.image_selection.as_mut()
{
s.crop = None;
}
} else if !held && !s.dragging() && !s.turning && (key(egui::Key::Delete) || key(egui::Key::Backspace)) && image.is_some() {
// The key is the picture's: added content's own Delete runs later this frame.
ctx.input_mut(|i| {
i.consume_key(egui::Modifiers::NONE, egui::Key::Delete);
i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace);
});
view.image_selection = None;
// Added content's selection follows the picture's (`content_ui::sync_image_selection`), and
// goes with it: once the picture is gone it would be the next added item's.
view.content.selected = None;
view.pending_edit = Some(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Delete });
}
}
/// The page raster's picture of the box `from`, stretched into `to`, over blanks at `blank` (where
/// the screen may still show the picture).
fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::TextureId, blank: &[Rect], from: Rect, to: Rect) {
@@ -541,29 +568,63 @@ pub(crate) fn handle_hit(
Some(Hit::new(picture(PicturePart::Corner(grip)), Layer::Handle, obb.handle_cursor(handle)))
}
/// The picture being cropped on `page`: crop mode owns every press there, the controls beside its
/// frame included, so the paragraphs under it and the empty page's marquee never take a crop
/// button's press before the picture does. `None` when no picture on `page` is being cropped.
pub(crate) fn crop_hit(view: &DocView, page: usize) -> Option<Hit> {
let selection = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_some())?;
Some(Hit::new(Owner::Picture { index: selection.index, part: PicturePart::Body }, Layer::Handle, egui::CursorIcon::Default))
/// The picture being cropped on `page`, under `p`: crop mode holds every press on its page, the
/// controls beside its frame included, so the paragraphs under it and the empty page's marquee
/// never take a crop button's press before the picture does. A grip of the box kept (within
/// [`CROP_GRIP_REACH`], past the page's edge too), the box itself, the picture outside it (its frame
/// shown or its whole frame), or the rest of the page; and the controls past the page's edge, where
/// they show the hand. `None` when no picture on `page` is being cropped, or `p` is off the page,
/// its grips and its controls: the box and the picture stop at the page's edge, as a clipped
/// artwork's whole frame can reach over the next pages.
pub(crate) fn crop_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pdfcraft_engine::PageImage], view: &DocView, p: Pos2) -> Option<Hit> {
let s = view.image_selection.as_ref().filter(|s| s.page == page)?;
let mode = s.crop.as_ref()?;
let crop = |part, icon| Some(Hit::new(Owner::Picture { index: s.index, part: PicturePart::Crop(part) }, Layer::Editor, icon));
if mode.controls.iter().any(|r| r.contains(p)) {
return crop(CropPart::Page, egui::CursorIcon::PointingHand);
}
let image = images.get(s.index);
let kept = image.and_then(|image| crop_frames(xf, info, page, image, mode.keep)).map(|(_, k)| k);
// The grips reach past the page's edge, where those of a picture filling its page lie half.
if let Some(g) = kept.and_then(|k| crop_handles(k).iter().position(|h| h.distance(p) < CROP_GRIP_REACH)) {
return crop(CropPart::Grip(g), GRIPS.get(g).map_or(egui::CursorIcon::Default, |grip| grip.cursor_icon()));
}
if !xf.rect.contains(p) {
return None;
}
if kept.is_some_and(|k| k.contains(p)) {
return crop(CropPart::Keep, egui::CursorIcon::Grab);
}
let on_picture = image.is_some_and(|image| {
let shown = ImageObb::from_image(image, xf, info, page);
let whole = ImageObb::from_matrix(image.matrix, xf, info, page);
quad_contains(shown.corners(), p) || quad_contains(whole.corners(), p)
});
crop(if on_picture { CropPart::Cut } else { CropPart::Page }, egui::CursorIcon::Default)
}
/// The picture under `p` (its shape, not only its box): a click selects it, a drag moves the
/// selected one. On the text layer, asked before the paragraphs: editable text on top of a
/// picture takes the press.
/// The picture under `p` (its shape, not only its box; for the selected one, its frame, a hole in it
/// included): a click selects it, a drag moves the selected one. On the text layer, asked before
/// the paragraphs: editable text on top of a picture takes the press.
pub(crate) fn body_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pdfcraft_engine::PageImage], view: &DocView, p: Pos2) -> Option<Hit> {
if !xf.rect.contains(p) {
return None;
}
let (x, y) = xf.screen_to_view(p);
let user = info.pages.get(page)?.view_to_user(x, y).map(f64::from);
let index = images
.iter()
.rposition(|im| im.visible_rect.is_some_and(|r| xf.user_rect(info, page, r.map(|v| v as f32)).contains(p)) && im.contains(user))?;
let on_picture = |im: &pdfcraft_engine::PageImage| {
im.visible_rect.is_some_and(|r| xf.user_rect(info, page, r.map(|v| v as f32)).contains(p)) && im.contains(user)
};
// The selected picture's frame is its own, a hole in it and a picture over it included.
let framed =
view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_none()).map(|s| s.index).filter(|i| {
images.get(*i).is_some_and(|image| on_picture(image) || quad_contains(ImageObb::from_image(image, xf, info, page).corners(), p))
});
let index = images.iter().rposition(on_picture).or(framed)?;
let selected = view.image_selection.as_ref().is_some_and(|s| s.page == page && s.index == index);
let icon = if selected { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand };
Some(Hit::new(Owner::Picture { index, part: PicturePart::Body }, Layer::Text, icon))
let part = if framed.is_some_and(|i| i != index) { PicturePart::OverSelected } else { PicturePart::Body };
Some(Hit::new(Owner::Picture { index, part }, Layer::Text, icon))
}
/// What a right-click on a selected image asks for.
@@ -620,15 +681,12 @@ pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 {
turn_degrees_delta(centre, centre - egui::vec2(0.0, 10.0), pointer, snap)
}
#[doc(hidden)]
pub fn image_menu_id() -> egui::Id {
egui::Id::unique("image-menu")
}
/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used.
/// Images on a page: the press that is a picture's (`press`, [`body_hit`] and [`handle_hit`])
/// selects it, a drag of the selected one moves, resizes or turns it. Returns `true` when the
/// pointer was used.
#[allow(clippy::cognitive_complexity)]
pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage], view: &mut DocView) -> bool {
let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx;
let ImageCx { ui, resp, xf, ptr, page, info, press, gate } = *cx;
let boxes: Vec<Rect> = images.iter().map(|im| crate::canvas::image_screen_rect(im, xf, info, page)).collect();
let painter = ui.painter();
let hover_pos = ptr.pos;
@@ -724,56 +782,25 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
}
}
let mut handle_drag = None;
for (h, h_pos) in obb.handles() {
let handle_rect = Rect::from_center_size(h_pos, egui::vec2(18.0, 18.0));
let handle_resp = ui.interact(handle_rect, ui.scope_id().with("img_handle").with(h), egui::Sense::drag());
if allow_interaction && handle_resp.drag_started() {
handle_drag = Some(h);
}
}
let rot_id = ui.scope_id().with("img_handle").with(8);
let rot_rect = Rect::from_center_size(shown_knob, egui::vec2(12.0, 12.0));
let rot_resp = ui.interact(rot_rect, rot_id, egui::Sense::drag());
let rot_drag = allow_interaction && rot_resp.drag_started();
if allow_interaction && let Some(p) = hover_pos {
if shown_knob.distance(p) < 8.0 {
view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab);
} else if let Some((h, _)) = obb.handles().iter().find(|(_, pos)| pos.distance(p) < 8.0) {
let icon = obb.handle_cursor(*h);
view.cursor.claim(Layer::Handle, icon);
}
}
let origin = ptr.origin;
if allow_interaction {
if let Some(h) = handle_drag {
if let Some(s) = view.image_selection.as_mut() {
let o = origin.unwrap_or_else(|| obb.handle_pos(h));
s.drag = Some(ImageDrag {
start: o,
target: DragTarget::Handle(h),
initial_rect: b,
initial_matrix: resize_frame(&obb, xf, info, page).0,
});
}
} else if rot_drag {
if let Some(s) = view.image_selection.as_mut() {
let o = origin.unwrap_or(shown_knob);
// A drag of the selected picture takes what its press was on ([`body_hit`], [`handle_hit`]):
// the rotate knob turns it, a grip resizes it, the picture itself moves. The knob and the
// grips take it from the press, so a move too short to be a drag of the page counts; the
// picture waits for that, as a click selects.
let at_press = |part| matches!(part, PicturePart::Knob | PicturePart::Corner(_));
if gate
&& let Some(o) = ptr.origin
&& let PagePress::Mine(Owner::Picture { index, part }) = press
&& (index == i || part == PicturePart::OverSelected)
&& (ptr.drag_started || at_press(part) && ptr.pressed().is_some())
&& let Some(s) = view.image_selection.as_mut().filter(|s| s.drag.is_none())
{
match part {
PicturePart::Knob => {
s.drag = Some(ImageDrag { start: o, target: DragTarget::Rotate, initial_rect: b, initial_matrix: image.matrix });
s.turning = true;
}
} else if resp.drag_started()
&& let Some(o) = origin
{
if shown_knob.distance(o) < 8.0 {
if let Some(s) = view.image_selection.as_mut() {
s.drag = Some(ImageDrag { start: o, target: DragTarget::Rotate, initial_rect: b, initial_matrix: image.matrix });
s.turning = true;
}
} else if let Some((h, _)) = obb.handles().iter().find(|(_, pos)| pos.distance(o) < 8.0) {
if let Some(s) = view.image_selection.as_mut() {
PicturePart::Corner(grip) => {
if let Some((h, _)) = obb.handles().get(grip) {
s.drag = Some(ImageDrag {
start: o,
target: DragTarget::Handle(*h),
@@ -781,11 +808,11 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
initial_matrix: resize_frame(&obb, xf, info, page).0,
});
}
} else if (image_contains(i, o) || quad_contains(obb.corners(), o))
&& let Some(s) = view.image_selection.as_mut()
{
}
PicturePart::Body | PicturePart::OverSelected => {
s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: b, initial_matrix: image.matrix });
}
PicturePart::Crop(_) => {}
}
}
@@ -949,59 +976,35 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
view.cursor.claim(Layer::Drag, cursor_icon);
return true;
}
if allow_interaction
&& ui.input(|inp| inp.key_pressed(egui::Key::Delete) || inp.key_pressed(egui::Key::Backspace))
&& !ui.ctx().egui_wants_keyboard_input()
{
view.image_selection = None;
view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Delete });
return true;
}
// A click selects the picture its press was on; its cursor is the hit's. One nobody holds on this
// page leaves it unselected (where the page's objects can be selected together, the area
// selection the press starts has done so already).
let owner = match press {
PagePress::Mine(Owner::Picture { index, .. }) => Some(index),
_ => None,
};
if gate && resp.clicked() {
match owner {
Some(index) => view.image_selection = Some(ImageSelection::at(page, index)),
None if press == PagePress::Free && ptr.released.is_some_and(|p| xf.rect.contains(p)) => view.image_selection = None,
None => {}
}
}
if !allow_interaction {
return false;
}
let menu_key = image_menu_id();
let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false };
let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| {
b.is_positive() && images.get(i).is_some_and(|img| ImageObb::from_image(img, xf, info, page).contains(*b, p)) && image_contains(i, p)
}) else {
if resp.secondary_clicked() || resp.clicked() {
view.image_selection = None;
ui.ctx().data_mut(|d| d.insert_temp(menu_key, false));
}
return false;
};
let Some(hit_img) = images.get(hit) else { return false };
let hit_obb = ImageObb::from_image(hit_img, xf, info, page);
if selected != Some(hit) {
if hit_obb.is_rotated {
let pts = hit_obb.corners();
for k in 0..4 {
painter.line_segment([pts[k], pts[(k + 1) % 4]], Stroke::new(1.5, ACCENT.gamma_multiply(0.7)));
}
} else if let Some(b) = boxes.get(hit).copied() {
painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside);
owner.is_some()
}
/// A right-click on `page` that went to its picture `index` (on it or a grip of it) selects it for
/// its menu, keeping it as it is if it was selected (crop mode included); one that went to anything
/// else (`None`) unselects the picture selected.
pub(crate) fn picture_context(page: usize, index: Option<usize>, view: &mut DocView) {
match index {
Some(index) if view.image_selection.as_ref().is_none_or(|s| (s.page, s.index) != (page, index)) => {
view.image_selection = Some(ImageSelection::at(page, index));
}
Some(_) => {}
None => view.image_selection = None,
}
let grip = hit_obb.handles().into_iter().find(|(_, point)| point.distance(p) < 10.0);
let cursor = if selected == Some(hit) {
grip.map_or(egui::CursorIcon::Grab, |(handle, _)| hit_obb.handle_cursor(handle))
} else {
egui::CursorIcon::PointingHand
};
view.cursor.claim(Layer::Text, cursor);
let clicked = match view.router.owner(page) {
Some(Owner::Picture { index, .. }) if resp.clicked() => Some(index),
_ => (resp.clicked() || resp.secondary_clicked()).then_some(hit),
};
if let Some(index) = clicked {
view.image_selection = Some(ImageSelection::at(page, index));
}
if resp.secondary_clicked() {
ui.ctx().data_mut(|d| d.insert_temp(menu_key, true));
}
true
}
/// The right-click context menu for a selected image.
@@ -1025,7 +1028,6 @@ pub(crate) fn image_context_menu(
let picked = |ui: &mut egui::Ui, entry: egui::Response| {
let clicked = entry.clicked();
if clicked {
ui.ctx().data_mut(|d| d.insert_temp(image_menu_id(), false));
egui::Popup::close_all(ui.ctx());
ui.close();
}
+7 -6
View File
@@ -95,7 +95,8 @@ fn target_label(info: &DocInfo, target: &LinkTarget) -> String {
/// Link tool input on one page: `free` when nobody holds the press and it began on this page,
/// `owner` the link that holds it (by its `/Annots` index), and `context` the link a right-click is
/// on. A drag from either draws a new link. Returns `true` when the gesture belongs to the tool.
/// on. A drag nobody holds draws a new link: a link lets its drag go. Returns `true` when the
/// gesture belongs to the tool.
pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, owner: Option<usize>, context: Option<usize>, view: &mut DocView) -> bool {
let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx;
let pointer = ptr.pos;
@@ -114,11 +115,11 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, owner: Option<usiz
}
return true;
}
// A drag that nobody holds, or from a link (whose hit lets the drag go), draws a new link from
// where its press began on this page. egui may see the drag only once the pointer is past the
// page's edge.
if resp.drag_started()
&& (free || owner.is_some())
// A drag that nobody holds (a link's hit lets its drag go, `PageRouter::drag_began`) draws a
// new link from where its press began on this page. egui may see the drag only once the pointer
// is past the page's edge.
if ptr.drag_started
&& free
&& let Some(origin) = ptr.origin.filter(|o| xf.rect.contains(*o))
{
lv.drag = Some((page, origin));
+145 -92
View File
@@ -9,7 +9,7 @@ use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::page_router::{Hit, ObjectsPart, Owner};
use crate::page_router::{Hit, ObjectsPart, Owner, PagePress};
const BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
@@ -24,7 +24,6 @@ pub(crate) struct ObjectSelection {
pending: Option<Vec<EditableObject>>,
pub notice: Option<String>,
pub hold_editor: bool,
blocked_page: Option<usize>,
input_blocked: bool,
/// A paragraph (page, block) to select once the document is at hand: Esc in the inline
/// editor asks for it, and the next `prepare` selects it.
@@ -40,23 +39,32 @@ struct Gesture {
}
impl ObjectSelection {
/// Clear any object-selection gesture and the page it was blocking.
/// Clear any object-selection gesture.
pub(crate) fn cancel_pointer_gesture(&mut self) {
self.gesture = None;
self.blocked_page = None;
}
pub fn count(&self) -> usize {
self.selected.len()
}
/// Edit text's input is blocked: a dialog, the palette, another window or another tab has it.
pub(crate) fn input_blocked(&self) -> bool {
self.input_blocked
}
/// The objects selected together are on `page`, or a gesture of theirs runs there: the hover
/// outlines of what lies under them wait.
pub(crate) fn holds(&self, page: usize) -> bool {
self.page == Some(page) && (!self.selected.is_empty() || self.gesture.is_some())
}
/// A preview belongs to the active, focused document. Returning from another tab or a
/// modal must not turn a release that happened elsewhere into a document edit.
pub fn set_input_blocked(&mut self, blocked: bool) {
self.input_blocked = blocked;
if blocked {
self.gesture = None;
self.blocked_page = None;
}
}
@@ -65,7 +73,6 @@ impl ObjectSelection {
self.selected.clear();
self.gesture = None;
self.pending = None;
self.blocked_page = None;
self.select_paragraph = None;
}
@@ -139,7 +146,9 @@ impl ObjectSelection {
Edit::MoveObjects { page, objects: self.selected.iter().map(|o| o.target).collect(), offset }
}
fn inventory(&mut self, doc: &Document, page: usize) -> Result<Vec<EditableObject>, String> {
/// The objects of `page` that can be selected together, as `doc.editable_objects` lists them, or
/// why they can't be.
pub(crate) fn inventory(&mut self, doc: &Document, page: usize) -> Result<Vec<EditableObject>, String> {
if self.cache.len() >= 8 && !self.cache.contains_key(&page) {
self.cache.clear();
}
@@ -151,18 +160,15 @@ fn screen(xf: &PageXform, info: &DocInfo, page: usize, object: &EditableObject)
xf.user_rect(info, page, object.rect.map(|v| v as f32))
}
fn modified(ui: &egui::Ui) -> bool {
ui.input(|i| {
let modifiers = i
.events
.iter()
.find_map(|e| match e {
egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: true, modifiers, .. } => Some(*modifiers),
_ => None,
})
.unwrap_or(i.modifiers);
modifiers.shift || modifiers.ctrl || modifiers.command
})
/// A menu is open (the page's, or another popup): the objects selected together leave the pointer
/// alone, and a press with Shift or ⌘ is not theirs.
pub(crate) fn menu_open(ui: &egui::Ui, resp: &egui::Response) -> bool {
resp.context_menu_opened() || egui::Popup::is_any_open(ui.ctx())
}
/// Whether `modifiers` make a press add to the objects selected together, or take from them.
pub(crate) fn adds(modifiers: egui::Modifiers) -> bool {
modifiers.shift || modifiers.ctrl || modifiers.command
}
fn single(view: &DocView, page: usize) -> Option<ObjectTarget> {
@@ -187,6 +193,10 @@ fn leave_single(view: &mut DocView) {
/// takes hold of the box as in Acrobat: a click selects it, a drag moves it, its text keeps the
/// caret.
pub(crate) fn hit(view: &DocView, xf: &PageXform, info: &DocInfo, page: usize, open: Option<Rect>, p: Pos2) -> Option<Hit> {
// Past the page's edge is no object's: the next page lies there.
if !xf.rect.contains(p) {
return None;
}
let s = &view.objects;
if s.page == Some(page) && !s.selected.is_empty() && s.selected.iter().fold(Rect::NOTHING, |r, o| r.union(screen(xf, info, page, o))).contains(p)
{
@@ -195,27 +205,75 @@ pub(crate) fn hit(view: &DocView, xf: &PageXform, info: &DocInfo, page: usize, o
open.filter(|r| on_frame(r.shrink(2.0), p)).map(|_| Hit::new(Owner::Objects { part: ObjectsPart::Frame }, Layer::Handle, egui::CursorIcon::Move))
}
/// 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(cx: PageToolContext<'_>, view: &mut DocView) -> bool {
let PageToolContext { ui, resp, xf, ptr, page, .. } = cx;
/// The object under `p` among the page's `objects`: added content over text over pictures, so a
/// page-sized background picture does not hide editable labels or added notes, and within each
/// kind the last, as the single-item tools take it.
fn object_at(xf: &PageXform, info: &DocInfo, page: usize, objects: &[EditableObject], p: Pos2) -> Option<ObjectTarget> {
[ObjectKind::Added, ObjectKind::Text, ObjectKind::Image]
.into_iter()
.find_map(|kind| objects.iter().rev().find(|o| o.target.kind == kind && screen(xf, info, page, o).expand(2.0).contains(p)))
.map(|o| o.target)
}
/// What a press with Shift or ⌘ at `p` takes on `page` in Edit text, whatever lies there: the
/// object under it among the page's `objects` ([`ObjectSelection::inventory`]), which it adds to
/// the objects selected together or takes out of them, or none, where it starts an area selection
/// that adds to them. Held while text being typed is open, and refused on a page whose objects
/// can't be selected together. `None` off the page and outside the pages' visible area (`clip`).
pub(crate) fn modified_hit(
view: &DocView,
xf: &PageXform,
info: &DocInfo,
page: usize,
objects: &Result<Vec<EditableObject>, String>,
clip: Rect,
p: Pos2,
) -> Option<Hit> {
if !xf.rect.contains(p) || !clip.contains(p) {
return None;
}
let part = if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) {
ObjectsPart::Held
} else {
match objects {
Ok(objects) => ObjectsPart::Toggle(object_at(xf, info, page, objects, p)),
Err(_) => ObjectsPart::Refused,
}
};
Some(Hit::new(Owner::Objects { part }, Layer::Editor, egui::CursorIcon::Default))
}
/// The objects selected together, on one page, told what the press is to it (`press`). Returns
/// true while they own the interaction: while input is blocked, while a press with Shift or ⌘ is
/// held ([`modified_hit`]), and while objects on this page are selected or a gesture of theirs
/// runs. Ordinary presses on a paragraph, a picture or added content still reach their own tools.
/// A text draft is finished or discarded through its editor.
pub(crate) fn page_input(cx: PageToolContext<'_>, press: PagePress, view: &mut DocView) -> bool {
let PageToolContext { ui, resp, ptr, page, .. } = cx;
if view.objects.input_blocked {
return true;
}
if resp.context_menu_opened() || egui::Popup::is_any_open(ui.ctx()) {
if menu_open(ui, resp) {
return false;
}
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 = 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 {
match press {
// Text being typed keeps the keyboard, and its editor stays open, until the button is up.
PagePress::Mine(Owner::Objects { part: ObjectsPart::Held }) => {
view.objects.hold_editor = true;
view.objects.blocked_page = ptr.primary_down.then_some(page);
if modifier_press {
if ptr.pressed().is_some() {
view.objects.notice = Some(tl!("Finish or discard the text being edited before selecting several objects.").into());
}
return true;
}
PagePress::Mine(Owner::Objects { part: ObjectsPart::Refused }) => {
if ptr.pressed().is_some() {
view.objects.notice = Some(tl!("This page contains objects that cannot be selected together.").into());
}
return true;
}
_ => {}
}
if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) {
return false;
}
// Delete or Backspace removes the selected objects on this page as one edit,
@@ -235,12 +293,12 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, view: &mut DocView) -> bool {
}
}
let mut selection = std::mem::take(&mut view.objects);
let result = input(&mut selection, cx, view);
let result = input(&mut selection, cx, press, view);
view.objects = selection;
result
}
fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) -> bool {
fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, press: PagePress, view: &mut DocView) -> bool {
let PageToolContext { ui, resp, xf, ptr, page, doc, .. } = cx;
let pointer = ptr.pos;
let (pressed, down) = (ptr.pressed().is_some(), ptr.primary_down);
@@ -298,73 +356,68 @@ fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) -
let Some(p) = pointer.filter(|p| xf.rect.contains(*p) && ui.clip_rect().contains(*p) && resp.rect.contains(*p)) else {
return s.page == Some(page) && !s.selected.is_empty();
};
if !pressed {
// Nor a press another page holds, on the row of pixels pages share with the gaps hidden: it may
// have just selected something there.
if !pressed || press == PagePress::Elsewhere {
return s.page == Some(page) && !s.selected.is_empty();
}
let objects = match s.inventory(doc, page) {
Ok(objects) => objects,
Err(_) => {
if modified(ui) {
s.notice = Some(tl!("This page contains objects that cannot be selected together.").into());
let Ok(objects) = s.inventory(doc, page) else { return false };
// What the press was on ([`hit`], [`modified_hit`]).
match press {
// With Shift or ⌘: the object it was on joins the selection or leaves it, and on none an
// area selection adds to it.
PagePress::Mine(Owner::Objects { part: ObjectsPart::Toggle(target) }) => {
if s.page != Some(page) {
s.clear();
s.page = Some(page);
if let Some(single) = single(view, page)
&& let Some(object) = objects.iter().find(|o| o.target == single)
{
s.selected.push(object.clone());
}
}
match target.and_then(|t| objects.iter().find(|o| o.target == t)) {
Some(object) => {
if let Some(index) = s.selected.iter().position(|o| o.target == object.target) {
s.selected.remove(index);
} else if s.selected.len() < MAX_MOVE_OBJECTS {
s.selected.push(object.clone());
} else {
s.notice = Some(tl!("Select at most 1000 objects at a time.").into());
}
}
None => s.gesture = Some(Gesture { start: p, now: p, marquee: true, additive: true }),
}
leave_single(view);
view.current = page;
return true;
}
// The selection moves.
PagePress::Mine(Owner::Objects { part: ObjectsPart::Selection }) => {
s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false });
return true;
}
// The frame of the paragraph open for typing selects its box (for the arrow keys) and
// moves it.
PagePress::Mine(Owner::Objects { part: ObjectsPart::Frame }) => {
if let Some(block) = view.line_editor.as_ref().filter(|e| e.page == page && !e.has_changes()).map(|e| e.block)
&& let Some(object) = objects.iter().find(|o| o.target == ObjectTarget { kind: ObjectKind::Text, index: block })
{
s.clear();
s.page = Some(page);
s.selected.push(object.clone());
s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false });
leave_single(view);
view.current = page;
return true;
}
return false;
}
};
let additive = modified(ui);
// A page-sized background image must not hide editable labels or added notes.
// Within each kind, prefer the last object as the ordinary single-item tools do.
let hit = [ObjectKind::Added, ObjectKind::Text, ObjectKind::Image]
.into_iter()
.find_map(|kind| objects.iter().rev().find(|o| o.target.kind == kind && screen(xf, info, page, o).expand(2.0).contains(p)));
if additive {
if s.page != Some(page) {
s.clear();
s.page = Some(page);
if let Some(target) = single(view, page)
&& let Some(object) = objects.iter().find(|o| o.target == target)
{
s.selected.push(object.clone());
}
}
if let Some(object) = hit {
if let Some(index) = s.selected.iter().position(|o| o.target == object.target) {
s.selected.remove(index);
} else if s.selected.len() < MAX_MOVE_OBJECTS {
s.selected.push(object.clone());
} else {
s.notice = Some(tl!("Select at most 1000 objects at a time.").into());
}
} else {
s.gesture = Some(Gesture { start: p, now: p, marquee: true, additive: true });
}
leave_single(view);
view.current = page;
return true;
}
// What the press was on ([`hit`]): the selection moves; the frame of the paragraph open for
// typing selects its box (for the arrow keys) and moves it.
let owner = view.router.owner(page);
if owner == Some(Owner::Objects { part: ObjectsPart::Selection }) && !resp.double_clicked() {
s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false });
return true;
}
if owner == Some(Owner::Objects { part: ObjectsPart::Frame })
&& let Some(block) = view.line_editor.as_ref().filter(|e| e.page == page && !e.has_changes()).map(|e| e.block)
&& let Some(object) = objects.iter().find(|o| o.target == ObjectTarget { kind: ObjectKind::Text, index: block })
{
s.clear();
s.page = Some(page);
s.selected.push(object.clone());
s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false });
leave_single(view);
view.current = page;
return true;
_ => {}
}
s.clear();
// A press on something (a paragraph, its edge reaching past its box #2276, a picture, added
// content) is its own; only one on the empty page starts an area selection.
if hit.is_some() || view.router.owner(page).is_some() {
// content) is its own; only one nobody holds starts an area selection.
if press != PagePress::Free {
return false;
}
leave_single(view);
@@ -589,7 +642,7 @@ mod tests {
},
|ui| {
let resp = ui.interact(ui.available_rect_before_wrap(), egui::Id::unique("page"), egui::Sense::click());
page_input(PageToolContext { ui, resp: &resp, xf: &xf, ptr: &ptr, page: 0, doc, info: &info }, &mut view);
page_input(PageToolContext { ui, resp: &resp, xf: &xf, ptr: &ptr, page: 0, doc, info: &info }, PagePress::Free, &mut view);
},
);
output.textures_delta.clear();
+3 -2
View File
@@ -380,7 +380,8 @@ pub(crate) fn page_input(
// The Select tool: what the press is on ([`hit`]), a corner handle or a field; a press nobody
// holds draws a selection rectangle, or a field with a field tool. A press held by something
// drawn over the fields (a comment) is not Prepare's.
// drawn over the fields (a comment) is not Prepare's, though its drag is once that something
// lets it go (a highlight's, `PageRouter::drag_began`).
let free = press == PagePress::Free;
let held = matches!(press, PagePress::Mine(Owner::PrepareField { .. } | Owner::PrepareCorner { .. }));
let hit = match press {
@@ -390,7 +391,7 @@ pub(crate) fn page_input(
// A drag starts from where its press began on this page: egui may see it only once the pointer
// is past the page's edge.
if allowed
&& resp.drag_started()
&& ptr.drag_started
&& (held || free)
&& let Some(origin) = ptr.origin.filter(|o| xf.rect.contains(*o))
{
+1 -1
View File
@@ -269,7 +269,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bo
/// A finished text selection with the Redact tool becomes a mark.
pub(crate) fn after_text(resp: &egui::Response, page: usize, info: &DocInfo, view: &mut DocView) {
if !(resp.drag_stopped() || resp.double_clicked()) || view.redact_drag.is_some() {
if !(resp.drag_stopped_by(egui::PointerButton::Primary) || resp.double_clicked()) || view.redact_drag.is_some() {
return;
}
if let Some((p, quads)) = view.selection_quads(info).filter(|(p, _)| *p == page) {
+9 -1
View File
@@ -717,14 +717,22 @@ fn form_widget_keeps_priority_over_highlighted_link() {
assert_eq!(h.state().last_opened_url, None);
}
/// A comment's gesture keeps the page's input until its button is up: a press held through it and
/// released on a highlighted link follows no link. (A gesture left over once the button is up
/// ends: `a_comment_move_whose_release_the_pages_missed_ends_once_the_button_is_up`.)
#[test]
fn active_comment_gesture_keeps_highlighted_link_click() {
let _gpu = gpu();
let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false));
let r = h.state().views[0].page_screen_rect(0).unwrap();
let at = egui::pos2(r.min.x + r.width() * 0.5, r.min.y + r.height() * (1.0 - 325.0 / 400.0));
h.hover_at(at);
h.run_steps(2);
h.drag_at(at);
h.run_steps(1);
h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 1, from: at });
click_at(&mut h, at);
h.drop_at(at);
h.run_steps(4);
assert_eq!(h.state().pending_link, None);
assert_eq!(h.state().last_opened_url, None);
}
+881 -7
View File
@@ -2588,7 +2588,7 @@ fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, p
}
/// `bytes` rewritten encrypted.
fn encrypted(bytes: Vec<u8>, algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec<u8> {
pub(crate) fn encrypted(bytes: Vec<u8>, algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec<u8> {
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm,
@@ -6503,6 +6503,15 @@ fn clipped_artwork_with_labels() -> Vec<u8> {
/// `own_clip` draws the image alone in its clip (`q re W n cm Do Q`), which moves with it (#1049).
fn clipped_artwork_with_labels_drawn(own_clip: bool) -> Vec<u8> {
artwork_drawn(if own_clip {
&b"q 30 170 130 80 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..]
} else {
&b"q 30 170 130 80 re W* n q 800 0 0 900 -300 -400 cm /Im0 Do Q Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..]
})
}
/// [`fixture`]'s two pages, each drawn by `content`, with a one-pixel picture as `/Im0`.
fn artwork_drawn(content: &[u8]) -> Vec<u8> {
use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental};
let mut doc = Document::open(std::sync::Arc::new(fixture(2))).unwrap();
let mut image = Dict::new();
@@ -6514,12 +6523,7 @@ fn clipped_artwork_with_labels_drawn(own_clip: bool) -> Vec<u8> {
image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
let image = doc.add(Object::Stream(Stream::flate(image, &[220, 235, 245])));
for page in pdfcraft_model::pages(&doc) {
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(),
if own_clip {
&b"q 30 170 130 80 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..]
} else {
&b"q 30 170 130 80 re W* n q 800 0 0 900 -300 -400 cm /Im0 Do Q Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..]
})));
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), content)));
let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
let mut xo = Dict::new();
xo.set(b"Im0".to_vec(), Object::Ref(image));
@@ -7230,6 +7234,848 @@ fn cropping_a_picture_with_its_handles() {
assert!(h.state().views[0].image_selection.as_ref().is_none_or(|s| s.crop.is_none()));
}
/// Both pages of [`clipped_artwork_with_labels`] at 100% in Edit text, the picture on page 0
/// selected (and being cropped, with `crop`).
fn picture_pages(crop: bool) -> Harness<'static, PdfCraftApp> {
picture_pages_of(clipped_artwork_with_labels(), crop)
}
/// [`picture_pages`], of `pdf`.
fn picture_pages_of(pdf: Vec<u8>, crop: bool) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("clipped.pdf", None, pdf).unwrap();
app.set_option("left", "closed").unwrap();
app.set_option("zoom", "100").unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(3);
let id = h.state().views[0].id;
let keep = h.state().session.get(id).unwrap().page_images(0)[0].visible_rect.unwrap();
let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0);
selection.crop = crop.then(|| pdfcraft_ui_egui::CropMode::new(keep));
h.state_mut().views[0].image_selection = Some(selection);
h.run_steps(3);
h
}
/// The screen point at PDF (x, y) of `page` of [`picture_pages`].
fn page_at(h: &Harness<'static, PdfCraftApp>, page: usize, x: f32, y: f32) -> egui::Pos2 {
let r = h.state().views[0].page_screen_rect(page).expect("both pages on screen");
egui::pos2(r.left() + x / 200.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height())
}
fn cursor_over(h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2) -> egui::CursorIcon {
h.hover_at(p);
h.run_steps(2);
h.output().platform_output.cursor_icon
}
/// Crop mode holds every press on its page, and its cursor stays there: the grips show theirs and
/// the box kept the hand that drags it, and on the next page the pointer shows what a press there
/// takes (a paragraph's move arrow). Crop mode's cursor used to win on every page.
#[test]
fn crop_modes_cursor_stays_on_its_page() {
let mut h = picture_pages(true);
// The visible picture is x 30..160, y 170..250.
for (page, x, y, cursor, what) in [
(0, 160.0, 170.0, egui::CursorIcon::ResizeNwSe, "a corner grip"),
(0, 95.0, 210.0, egui::CursorIcon::Grab, "the box kept"),
(0, 65.0, 134.0, egui::CursorIcon::Default, "crop mode holds its page's paragraphs"),
(1, 65.0, 134.0, egui::CursorIcon::Move, "the next page's paragraph"),
] {
let p = page_at(&h, page, x, y);
assert_eq!(cursor_over(&mut h, p), cursor, "{what}");
}
}
/// A press on a crop grip a little outside the picture drags that grip, and Escape in the middle
/// of the drag cancels it: the box goes back and crop mode stays. Escape once the button is up
/// leaves crop mode.
#[test]
fn escape_during_a_crop_drag_keeps_crop_mode() {
let mut h = picture_pages(true);
let before = h.state().views[0].image_selection.as_ref().unwrap().crop.as_ref().unwrap().keep;
let grip = page_at(&h, 0, 160.0, 170.0) + egui::vec2(5.0, 5.0);
h.hover_at(grip);
h.run_steps(1);
h.drag_at(grip);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(grip - egui::vec2(10.0, 10.0) * k as f32);
h.run_steps(1);
}
let keep = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].image_selection.as_ref().and_then(|s| s.crop.as_ref()).map(|c| c.keep);
assert!(keep(&h).is_some_and(|k| k[2] < before[2] - 5.0 && k[1] > before[1] + 5.0), "the grip outside the picture is dragged");
h.key_press(Key::Escape);
h.run_steps(2);
assert_eq!(keep(&h), Some(before), "Escape cancels the drag, and crop mode stays");
h.drop_at(grip - egui::vec2(40.0, 40.0));
h.run_steps(2);
assert_eq!(keep(&h), Some(before), "the cancelled drag's release crops nothing");
h.key_press(Key::Escape);
h.run_steps(2);
assert_eq!(keep(&h), None, "Escape leaves crop mode");
assert!(!dirty(&h));
}
/// With Edit text, a right-click on text lying over a picture is the picture's, which is selected
/// for its menu: paragraphs have no menu of their own (the user's ruling).
#[test]
fn a_right_click_on_text_over_a_picture_selects_the_picture() {
let mut h = picture_pages(false);
h.state_mut().views[0].image_selection = None;
h.run_steps(2);
// "Overlay" lies on the picture, at x 40, y 220.
let overlay = page_at(&h, 0, 50.0, 224.0);
right_click_at(&mut h, overlay);
let selected = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index));
assert_eq!(selected, Some((0, 0)), "the picture under the text is selected for its menu");
}
/// With Edit text, a right-click on added text over a picture is the picture's, which is selected
/// for its menu: added text has no menu of its own.
#[test]
fn a_right_click_on_added_text_over_a_picture_selects_the_picture() {
let mut h = picture_pages(false);
h.state_mut().views[0].image_selection = None;
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [60.0, 190.0, 150.0, 205.0], text: "Added".into(), ..Default::default() },
}));
h.run_steps(3);
let on_it = page_at(&h, 0, 100.0, 198.0);
right_click_at(&mut h, on_it);
let selected = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index));
assert_eq!(selected, Some((0, 0)), "the picture under the added text is selected for its menu");
assert!(h.query_by_label("Flip horizontal").is_some(), "its menu opens");
}
/// With Edit text, a right-click on the frame of a paragraph open for typing over a picture is the
/// picture's too.
#[test]
fn a_right_click_on_an_open_paragraphs_frame_over_a_picture_selects_the_picture() {
let mut h = picture_pages(false);
h.state_mut().views[0].image_selection = None;
h.run_steps(2);
// "Overlay" lies on the picture, at x 40, y 220.
let overlay = page_at(&h, 0, 50.0, 224.0);
slipping_click(&mut h, overlay, overlay);
assert!(h.state().views[0].line_editor.is_some(), "the paragraph is open for typing");
let id = h.state().views[0].id;
let blocks = h.state().session.get(id).unwrap().text_blocks(0);
let block =
blocks.iter().find(|b| b.rect[0] < 50.0 && 50.0 < b.rect[2] && b.rect[1] < 224.0 && 224.0 < b.rect[3]).expect("the paragraph opened").clone();
// Its frame runs just outside its box.
let frame = page_at(&h, 0, block.rect[0] as f32, ((block.rect[1] + block.rect[3]) / 2.0) as f32) - egui::vec2(3.0, 0.0);
right_click_at(&mut h, frame);
let selected = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index));
assert_eq!(selected, Some((0, 0)), "the picture under the frame is selected for its menu");
assert!(h.query_by_label("Flip horizontal").is_some(), "its menu opens");
}
/// A drag from the selected picture moves it, though another picture lies over it there; a click
/// there selects the one on top.
#[test]
fn a_drag_from_the_selected_picture_under_another_moves_the_selected_one() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("two.pdf", None, two_image_fixture()).unwrap();
app
});
h.run_steps(4);
// The second picture lies over the right half of the first.
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
page: 0,
index: 1,
change: pdfcraft_engine::ImageEdit::Move([110.0, 100.0, 130.0, 120.0]),
});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(4);
h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0));
h.run_steps(2);
let id = h.state().views[0].id;
let rects = |h: &Harness<'static, PdfCraftApp>| {
let images = h.state().session.get(id).unwrap().page_images(0);
(images[0].rect, images[1].rect)
};
let before = rects(&h);
let xf = h.state().views[0].page_xform(0).unwrap();
let doc = h.state().session.get(id).unwrap();
let images = doc.page_images(0);
let frame = |i: usize| pdfcraft_ui_egui::canvas::image_screen_rect(&images[i], &xf, &doc.info, 0);
let overlap = frame(0).intersect(frame(1)).center();
h.hover_at(overlap);
h.run_steps(1);
h.drag_at(overlap);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(overlap + egui::vec2(8.0 * k as f32, 0.0));
h.run_steps(1);
}
h.drop_at(overlap + egui::vec2(32.0, 0.0));
h.run_steps(3);
let after = rects(&h);
assert_ne!(after.0, before.0, "the selected picture moves");
assert_eq!(after.1, before.1, "the one over it stays");
let selected = h.state().views[0].image_selection.as_ref().map(|s| s.index);
assert_eq!(selected, Some(0), "and stays selected");
// After the move the two still overlap, a little further along.
let xf = h.state().views[0].page_xform(0).unwrap();
let doc = h.state().session.get(id).unwrap();
let images = doc.page_images(0);
let frame = |i: usize| pdfcraft_ui_egui::canvas::image_screen_rect(&images[i], &xf, &doc.info, 0);
let overlap = frame(0).intersect(frame(1)).center();
slipping_click(&mut h, overlap, overlap);
let selected = h.state().views[0].image_selection.as_ref().map(|s| s.index);
assert_eq!(selected, Some(1), "a click selects the one on top");
}
/// A move too short to be a drag of the page still resizes the selected picture from its grip, as
/// the grip takes its drag from the press.
#[test]
fn a_short_drag_from_a_picture_grip_resizes_it() {
let mut h = picture_pages(false);
let id = h.state().views[0].id;
let before = h.state().session.get(id).unwrap().page_images(0)[0].matrix;
// The picture shows at x 30..160, y 170..250: its lower right grip, moved 3 px.
let grip = page_at(&h, 0, 160.0, 170.0);
h.hover_at(grip);
h.run_steps(1);
h.drag_at(grip);
h.run_steps(1);
h.hover_at(grip + egui::vec2(3.0, 0.0));
h.run_steps(1);
h.drop_at(grip + egui::vec2(3.0, 0.0));
h.run_steps(3);
assert_ne!(h.state().session.get(id).unwrap().page_images(0)[0].matrix, before, "the picture is resized");
}
/// A click on a selected picture's grip that doesn't move resizes nothing: no edit, and the picture
/// stays selected.
#[test]
fn a_click_on_a_picture_grip_changes_nothing() {
let mut h = picture_pages(false);
let id = h.state().views[0].id;
let before = h.state().session.get(id).unwrap().edit_generation();
let grip = page_at(&h, 0, 160.0, 170.0);
slipping_click(&mut h, grip, grip);
assert_eq!(h.state().session.get(id).unwrap().edit_generation(), before, "no edit");
assert!(h.state().views[0].image_selection.is_some(), "the picture stays selected");
}
/// With Add text holding text that can't be drawn, a drag from a paragraph moves nothing: the draft
/// stays where it is being typed (#125), as a click there leaves it.
#[test]
fn add_text_moves_no_paragraph_while_it_holds_a_draft_it_cant_draw() {
if crate::support::skip_without_glyphs("add_text_moves_no_paragraph_while_it_holds_a_draft_it_cant_draw", "en", "Hello 世界") {
return;
}
let mut h = edit_page(pdfcraft_ui_egui::QuickTool::AddText);
assert!(h.state_mut().execute("edit.text"));
h.run_steps(3);
let id = h.state().views[0].id;
let block = h.state().session.get(id).unwrap().text_blocks(0)[0].clone();
let blank = point_on(&h, block.rect[0], block.rect[3] + 12.0);
slipping_click(&mut h, blank, blank);
h.event(egui::Event::Text("Hello 世界".into()));
h.run_steps(2);
assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Hello 世界"), "the draft holds the text");
let rect = h.state().views[0].content.draft.as_ref().map(|d| d.rect).unwrap();
let on_text = point_on(&h, (block.rect[0] + block.rect[2]) / 2.0, (block.rect[1] + block.rect[3]) / 2.0);
h.hover_at(on_text);
h.run_steps(1);
h.drag_at(on_text);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(on_text + egui::vec2(10.0 * k as f32, 0.0));
h.run_steps(1);
}
h.drop_at(on_text + egui::vec2(40.0, 0.0));
h.run_steps(3);
assert_eq!(h.state().session.get(id).unwrap().text_blocks(0)[0].rect, block.rect, "the paragraph stays");
let draft = h.state().views[0].content.draft.as_ref().map(|d| (d.text.clone(), d.rect));
assert_eq!(draft, Some(("Hello 世界".to_string(), rect)), "and the draft stays where it is typed");
}
/// With Edit text, a right-click on a comment over a picture opens the comment's menu, and the
/// picture is not selected for its own.
#[test]
fn a_right_click_on_a_comment_over_a_picture_opens_its_menu() {
let mut h = picture_pages(false);
h.state_mut().views[0].image_selection = None;
let shape = pdfcraft_engine::Shape::Rectangle { rect: [60.0, 190.0, 150.0, 230.0] };
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
page: 0,
style: pdfcraft_engine::Style::default_for(&shape),
shape,
contents: "Reviewed".into(),
author: "Tester".into(),
})));
h.state_mut().views[0].comments.selected = None;
h.run_steps(3);
let on_it = page_at(&h, 0, 100.0, 210.0);
right_click_at(&mut h, on_it);
assert!(h.state().views[0].comments.selected.is_some(), "the comment is selected");
assert!(h.state().views[0].image_selection.is_none(), "the picture is not");
assert!(h.query_by_label("Properties…").is_some(), "the comment's menu opens");
}
/// In crop mode, a right-click on the next page goes to what lies there, though a clipped artwork's
/// whole frame reaches over it: crop mode ends and the page's menu opens.
#[test]
fn a_right_click_on_the_next_page_leaves_crop_mode() {
let mut h = picture_pages(true);
let on_two = page_at(&h, 1, 100.0, 40.0);
right_click_at(&mut h, on_two);
assert!(h.state().views[0].image_selection.is_none(), "crop mode ends");
assert!(h.query_by_label("Marquee Zoom").is_some(), "the page's menu opens");
}
/// A crop grip takes a press as far off as its cursor shows: 9 px from the grip, outside the box.
#[test]
fn a_crop_grip_takes_a_press_within_its_reach() {
let mut h = picture_pages(true);
let keep = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].image_selection.as_ref().and_then(|s| s.crop.as_ref()).map(|c| c.keep);
let before = keep(&h);
// The box kept is the picture's frame shown, x 30..160, y 170..250: its upper left corner.
let press = page_at(&h, 0, 30.0, 250.0) - egui::vec2(9.0, 0.0);
h.hover_at(press);
h.run_steps(1);
h.drag_at(press);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(press + egui::vec2(8.0 * k as f32, 8.0 * k as f32));
h.run_steps(1);
}
h.drop_at(press + egui::vec2(32.0, 32.0));
h.run_steps(2);
assert_ne!(keep(&h), before, "the grip takes the drag");
}
/// With Add text, a click on another paragraph keeps both what was typed in the open paragraph and
/// the text being added: neither edit takes the other's place.
#[test]
fn add_text_keeps_a_changed_paragraph_and_a_draft_when_it_moves_on() {
let mut h = picture_pages(false);
h.state_mut().views[0].image_selection = None;
assert!(h.state_mut().execute("edit.text"));
h.run_steps(3);
let id = h.state().views[0].id;
let blocks = h.state().session.get(id).unwrap().text_blocks(0);
assert!(blocks.len() >= 2, "two paragraphs: {blocks:?}");
let centre = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
let (ax, ay) = centre(blocks[0].rect);
let (bx, by) = centre(blocks[1].rect);
let a = page_at(&h, 0, ax as f32, ay as f32);
let b = page_at(&h, 0, bx as f32, by as f32);
slipping_click(&mut h, a, a);
assert!(h.state().views[0].line_editor.is_some(), "the first paragraph is open");
h.event(egui::Event::Text("X".into()));
h.run_steps(3);
// A click on the page's empty part, below the picture, starts a box.
let blank = page_at(&h, 0, 180.0, 20.0);
slipping_click(&mut h, blank, blank);
assert!(h.state().views[0].line_editor.is_none(), "that click applied the paragraph");
h.event(egui::Event::Text("Added words".into()));
h.run_steps(3);
slipping_click(&mut h, b, b);
h.run_steps(4);
let doc = h.state().session.get(id).unwrap();
let texts: Vec<String> = doc.text_blocks(0).iter().map(|b| b.text.clone()).collect();
let added: Vec<String> = doc
.added()
.iter()
.filter_map(|a| match &a.content {
pdfcraft_engine::AddedContent::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect();
assert!(added.iter().any(|t| t == "Added words"), "the added text is kept: {added:?}");
assert!(texts.iter().any(|t| t.contains('X')), "the paragraph's typing is kept: {texts:?}");
}
/// In the page grid, Delete deletes the page chosen there. The picture selected before the grid
/// opened is not on screen, and stays.
#[test]
fn delete_in_the_page_grid_deletes_the_page_and_not_the_hidden_picture() {
let mut h = picture_pages(false);
let id = h.state().views[0].id;
let pictures = h.state().session.get(id).unwrap().page_images(0).len();
let pages = h.state().session.get(id).unwrap().info.pages.len();
assert!(h.state_mut().execute("page.organize"));
h.run_steps(4);
h.get_by_label("Select page 2").click();
h.run_steps(2);
h.key_press(egui::Key::Delete);
h.run_steps(4);
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.info.pages.len(), pages - 1, "the page chosen in the grid is deleted");
assert_eq!(doc.page_images(0).len(), pictures, "the picture stays");
}
/// With the picture's page not on screen, its keys do nothing: Delete removes no picture the reader
/// can't see, and Enter applies no crop.
#[test]
fn a_selected_pictures_keys_wait_for_its_page_to_be_on_screen() {
use pdfcraft_ui_egui::canvas::PageLayout;
for (crop, key) in [(false, egui::Key::Delete), (true, egui::Key::Enter)] {
let mut h = picture_pages(crop);
let id = h.state().views[0].id;
let before = h.state().session.get(id).unwrap().page_images(0);
if crop {
let keep = before[0].visible_rect.unwrap();
let half = [keep[0], keep[1], (keep[0] + keep[2]) / 2.0, keep[3]];
h.state_mut().views[0].image_selection.as_mut().unwrap().crop.as_mut().unwrap().keep = half;
}
h.state_mut().views[0].layout = PageLayout::Single;
h.state_mut().views[0].continuous = false;
h.state_mut().views[0].go_to_page(1);
h.run_steps(4);
assert!(h.state().views[0].page_screen_rect(0).is_none(), "page 1 is off the screen");
h.key_press(key);
h.run_steps(4);
let after = h.state().session.get(id).unwrap().page_images(0);
assert_eq!(after.len(), before.len(), "the picture stays");
assert_eq!(after[0].visible_rect, before[0].visible_rect, "and is not cropped");
}
// A page just off the screen, in a continuous view: it is still run for what lies near it.
let mut h = picture_pages(false);
let id = h.state().views[0].id;
let pictures = h.state().session.get(id).unwrap().page_images(0).len();
h.set_size(egui::vec2(1400.0, 400.0));
h.state_mut().views[0].go_to_page(1);
h.run_steps(6);
// Going to a page leaves a margin above it: a little further, past page 1's foot.
let centre = h.state().views[0].viewport_rect().center();
h.hover_at(centre);
h.run_steps(1);
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -60.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
source: egui::MouseWheelSource::Trackpad,
});
h.run_steps(6);
assert!(h.state().views[0].continuous, "a continuous view");
assert!(h.state().views[0].page_screen_rect(0).is_none(), "page 1 is off the screen");
h.key_press(egui::Key::Delete);
h.run_steps(4);
assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), pictures, "the picture stays");
}
/// Delete waits for the button to come up: the press held would then name another item, whose drag or
/// click would follow.
#[test]
fn delete_waits_for_the_button_to_come_up() {
let hold_and_delete = |h: &mut Harness<'static, PdfCraftApp>, on: egui::Pos2| {
h.hover_at(on);
h.run_steps(1);
h.drag_at(on);
h.run_steps(1);
h.key_press(egui::Key::Delete);
h.run_steps(2);
h.drop_at(on);
h.run_steps(3);
};
let mut h = picture_pages(false);
let id = h.state().views[0].id;
let pictures = h.state().session.get(id).unwrap().page_images(0).len();
let on_picture = page_at(&h, 0, 100.0, 180.0);
hold_and_delete(&mut h, on_picture);
assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), pictures, "the picture stays");
let mut h = edit_page(pdfcraft_ui_egui::QuickTool::Select);
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [40.0, 250.0, 140.0, 265.0], text: "Added".into(), ..Default::default() },
}));
assert!(h.state_mut().execute("edit.text"));
h.run_steps(3);
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Select;
h.run_steps(3);
let on_added = point_on(&h, 90.0, 258.0);
slipping_click(&mut h, on_added, on_added);
assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the added text is selected");
hold_and_delete(&mut h, on_added);
let id = h.state().views[0].id;
assert_eq!(h.state().session.get(id).unwrap().added().len(), 1, "the added text stays");
}
/// A drag the page it is over first sees lets the picked picture go, whichever page it began on.
#[test]
fn a_drag_first_seen_on_the_next_page_lets_the_picked_picture_go() {
let mut h = picture_pages(false);
h.state_mut().views[0].image_selection = None;
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Select;
h.run_steps(3);
let on_picture = page_at(&h, 0, 100.0, 180.0);
slipping_click(&mut h, on_picture, on_picture);
assert!(h.state().views[0].picked_image.is_some(), "the picture is picked");
let press = page_at(&h, 0, 180.0, 20.0);
let release = page_at(&h, 1, 180.0, 280.0);
h.hover_at(press);
h.run_steps(1);
h.drag_at(press);
h.run_steps(1);
h.hover_at(release);
h.run_steps(1);
h.drop_at(release);
h.run_steps(4);
assert!(h.state().views[0].picked_image.is_none(), "the drag lets it go");
}
/// With the pointer on the Pages panel, Delete is the panel's: a page goes, though a picture is
/// selected on the pages.
#[test]
fn delete_with_the_pointer_on_the_pages_panel_deletes_a_page() {
let mut h = picture_pages(false);
h.state_mut().set_option("panel", "pages").unwrap();
h.run_steps(4);
let id = h.state().views[0].id;
let pages = h.state().session.get(id).unwrap().info.pages.len();
assert!(h.state().views[0].image_selection.is_some(), "the picture is selected");
assert!(h.state().views[0].page_screen_rect(0).is_some(), "on a page on screen");
let viewport = h.state().views[0].viewport_rect();
assert!(viewport.right() < 1300.0, "the panel lies right of the pages: {viewport:?}");
h.hover_at(egui::pos2(viewport.right() + 60.0, viewport.center().y));
h.run_steps(2);
h.key_press(egui::Key::Delete);
h.run_steps(4);
assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), pages - 1, "a page goes");
}
/// In crop mode, a right-click on another picture selects it for its menu, as without crop mode. It
/// went to crop mode's whole-page hit, which unselected every picture.
#[test]
fn in_crop_mode_a_right_click_on_another_picture_selects_it() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("two.pdf", None, two_image_fixture()).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(4);
let id = h.state().views[0].id;
let rect = h.state().session.get(id).unwrap().page_images(0)[0].rect;
let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0);
selection.crop = Some(pdfcraft_ui_egui::CropMode::new(rect));
h.state_mut().views[0].image_selection = Some(selection);
h.run_steps(2);
let xf = h.state().views[0].page_xform(0).unwrap();
let doc = h.state().session.get(id).unwrap();
let other = pdfcraft_ui_egui::canvas::image_screen_rect(&doc.page_images(0)[1], &xf, &doc.info, 0);
crop_secondary_click(&mut h, other.center());
let selection = h.state().views[0].image_selection.clone();
assert!(selection.as_ref().is_some_and(|s| s.index == 1 && s.crop.is_none()), "the other picture is selected: {selection:?}");
assert!(h.query_by_label("Flip horizontal").is_some(), "its menu opens");
}
/// A drag from a hole in the selected picture (drawn through a clip with a hole in it) moves the
/// picture: its frame is its own.
#[test]
fn a_drag_from_a_hole_in_the_selected_picture_moves_it() {
// The picture shows at x 30..160, y 170..250, around a hole at x 70..120, y 190..230.
let mut h = picture_pages_of(artwork_drawn(b"q 30 170 130 80 re 70 190 50 40 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q"), false);
let id = h.state().views[0].id;
let before = h.state().session.get(id).unwrap().page_images(0)[0].rect;
let hole = page_at(&h, 0, 95.0, 210.0);
h.hover_at(hole);
h.run_steps(1);
h.drag_at(hole);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(hole + egui::vec2(5.0 * k as f32, 0.0));
h.run_steps(1);
}
h.drop_at(hole + egui::vec2(20.0, 0.0));
h.run_steps(4);
let after = h.state().session.get(id).unwrap().page_images(0)[0].rect;
assert!(after[0] - before[0] > 5.0, "the picture moved right: {before:?} → {after:?}");
}
/// A right-click on a crop grip past the page's edge, where those of a picture filling its page lie
/// half, is the crop's, as on the page: crop mode stays and no menu opens (#5835).
#[test]
fn a_right_click_on_a_crop_grip_past_the_page_edge_keeps_crop_mode() {
let mut png = Vec::new();
image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
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("picture.png", None, png.clone()).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let id = h.state().views[0].id;
let keep = h.state().session.get(id).unwrap().page_images(0)[0].visible_rect.unwrap();
let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0);
selection.crop = Some(pdfcraft_ui_egui::CropMode::new(keep));
h.state_mut().views[0].image_selection = Some(selection);
h.run_steps(3);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
crop_secondary_click(&mut h, r.right_bottom() + egui::vec2(4.0, 4.0));
assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_some()), "crop mode stays");
for label in ["Flip horizontal", "Rotate view clockwise"] {
assert!(h.query_all_by_label(label).next().is_none(), "no menu: {label}");
}
}
/// Crop mode's grips reach past the page's edge, where those of a picture filling its page lie
/// half: a drag from just outside the page's corner trims the picture.
#[test]
fn a_crop_grip_past_the_page_edge_drags() {
let mut png = Vec::new();
image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
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("picture.png", None, png.clone()).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let id = h.state().views[0].id;
let before = h.state().session.get(id).unwrap().page_images(0)[0].visible_rect.unwrap();
let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0);
selection.crop = Some(pdfcraft_ui_egui::CropMode::new(before));
h.state_mut().views[0].image_selection = Some(selection);
h.run_steps(3);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let outside = r.right_bottom() + egui::vec2(4.0, 4.0);
let to = r.right_bottom() - egui::vec2(r.width() / 4.0, r.height() / 4.0);
h.hover_at(outside);
h.run_steps(1);
h.drag_at(outside);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(outside + (to - outside) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(2);
let keep = h.state().views[0].image_selection.as_ref().and_then(|s| s.crop.as_ref()).map(|c| c.keep);
assert!(keep.is_some_and(|k| k[2] < before[2] - 1.0 && k[1] > before[1] + 1.0), "trimmed from the corner: {keep:?} of {before:?}");
}
/// Delete removes the selected picture wherever the pointer is, on a paragraph too: the key is the
/// app's, not the pointer's.
#[test]
fn delete_removes_the_selected_picture_with_the_pointer_on_a_paragraph() {
let mut h = picture_pages(false);
let caption = page_at(&h, 0, 65.0, 134.0);
assert_eq!(cursor_over(&mut h, caption), egui::CursorIcon::Move, "on the caption's paragraph");
h.key_press(Key::Delete);
h.run_steps(3);
let id = h.state().views[0].id;
assert!(h.state().session.get(id).unwrap().page_images(0).is_empty(), "the picture is deleted");
assert!(h.state().views[0].image_selection.is_none());
}
/// Delete on a selected added picture deletes it alone: the added text placed after it stays. The
/// key's second reader, added content's own Delete, took the text that the picture's index then
/// named.
#[test]
fn delete_on_a_selected_added_picture_deletes_it_alone() {
let mut h = edit_page(pdfcraft_ui_egui::QuickTool::EditText);
let mut png = Vec::new();
image::RgbImage::from_pixel(30, 20, image::Rgb([20, 90, 200])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
h.state_mut().add_image("logo.png".into(), png);
h.run_steps(3);
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [20.0, 20.0, 120.0, 35.0], text: "Caption".into(), ..Default::default() },
}));
h.run_steps(3);
let id = h.state().views[0].id;
let images = h.state().session.get(id).unwrap().page_images(0).len();
assert_eq!(images, 1, "the added picture is the page's picture");
h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0));
h.run_steps(3);
h.key_press(Key::Delete);
h.run_steps(4);
let doc = h.state().session.get(id).unwrap();
assert!(doc.page_images(0).is_empty(), "the picture is deleted");
let texts: Vec<&str> = doc
.added()
.iter()
.filter_map(|a| if let pdfcraft_engine::AddedContent::Text(t) = &a.content { Some(t.text.as_str()) } else { None })
.collect();
assert_eq!(texts, ["Caption"], "and the text placed after it stays");
}
/// With a dialog open, Delete and Backspace are the dialog's: the selected picture stays.
#[test]
fn a_dialog_keeps_its_delete_while_a_picture_is_selected() {
let mut h = picture_pages(false);
let on_picture = page_at(&h, 0, 95.0, 210.0);
h.hover_at(on_picture);
h.run_steps(1);
assert!(h.state_mut().execute("app.preferences"));
h.run_steps(3);
for key in [Key::Delete, Key::Backspace] {
h.key_press(key);
h.run_steps(3);
}
let id = h.state().views[0].id;
assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), 1, "the picture stays under the dialog");
assert!(h.state().views[0].image_selection.is_some());
assert_eq!(h.state().dialog, Some(Dialog::Preferences));
}
/// A click that slips a few pixels between its press and its release, within egui's click distance.
fn slipping_click(h: &mut Harness<'static, PdfCraftApp>, press: egui::Pos2, release: egui::Pos2) {
h.hover_at(press);
h.run_steps(1);
h.drag_at(press);
h.run_steps(1);
h.drop_at(release);
h.run_steps(4);
}
/// [`harness`]'s page 1 in Edit text (`tool`), and the screen point at PDF (x, y) on it.
fn edit_page(tool: pdfcraft_ui_egui::QuickTool) -> Harness<'static, PdfCraftApp> {
let mut h = harness(2, |_| {});
h.state_mut().quick_tool = tool;
h.run_steps(3);
h
}
fn point_on(h: &Harness<'static, PdfCraftApp>, x: f64, y: f64) -> egui::Pos2 {
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let k = r.width() / 200.0;
egui::pos2(r.left() + x as f32 * k, r.top() + (300.0 - y as f32) * k)
}
/// A click on a paragraph opens it where it was pressed, though the button came up just below its
/// box: an owner acts where its press was. It opened nothing.
#[test]
fn a_click_on_a_paragraph_released_just_below_it_opens_it() {
let mut h = edit_page(pdfcraft_ui_egui::QuickTool::EditText);
let id = h.state().views[0].id;
let block = h.state().session.get(id).unwrap().text_blocks(0)[0].clone();
// The box reaches 2 px below the text: pressed 1 px inside it, released 3 px below it.
let bottom = point_on(&h, (block.rect[0] + block.rect[2]) / 2.0, block.rect[1]) + egui::vec2(0.0, 2.0);
slipping_click(&mut h, bottom - egui::vec2(0.0, 1.0), bottom + egui::vec2(0.0, 3.0));
assert!(h.state().views[0].line_editor.is_some(), "the paragraph pressed is open for typing");
}
/// With the Select tool and the Edit panel open, a click on added text selects it where it was
/// pressed, though the button came up just beside it. It left nothing selected.
#[test]
fn a_click_on_added_text_released_just_beside_it_selects_it() {
let mut h = edit_page(pdfcraft_ui_egui::QuickTool::Select);
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [40.0, 250.0, 140.0, 265.0], text: "Added".into(), ..Default::default() },
}));
assert!(h.state_mut().execute("edit.text"));
h.run_steps(3);
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Select;
h.run_steps(3);
// The item takes presses 2 px past its box: pressed 1 px inside that, released 3 px past it.
let edge = point_on(&h, 140.0, 258.0) + egui::vec2(2.0, 0.0);
slipping_click(&mut h, edge - egui::vec2(1.0, 0.0), edge + egui::vec2(3.0, 0.0));
assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the added text pressed is selected");
}
/// With Add text, a click pressed beside a paragraph and released inside its box starts a new box
/// where it was pressed. It finished the text being typed and placed nothing.
#[test]
fn an_add_text_click_pressed_beside_a_paragraph_starts_a_box_there() {
let mut h = edit_page(pdfcraft_ui_egui::QuickTool::AddText);
let id = h.state().views[0].id;
let block = h.state().session.get(id).unwrap().text_blocks(0)[0].clone();
// The box reaches 2 px past the text: pressed 2 px left of it, released 2 px inside it.
let left = point_on(&h, block.rect[0], (block.rect[1] + block.rect[3]) / 2.0) - egui::vec2(2.0, 0.0);
slipping_click(&mut h, left - egui::vec2(2.0, 0.0), left + egui::vec2(2.0, 0.0));
let draft = h.state().views[0].content.draft.as_ref().map(|d| d.rect);
let x = draft.map(|r| r[0]);
assert!(x.is_some_and(|x| x < block.rect[0]), "a new box at the press, left of the paragraph: {x:?} of {:?}", block.rect);
assert!(h.state().views[0].line_editor.is_none(), "the paragraph stays closed");
}
/// A paragraph drag in Edit text ends when Edit text's input is blocked (the window loses focus), as
/// a drag of the objects selected together does: nothing moves once the input comes back. It
/// waited, and moved the paragraph to wherever the pointer then was.
#[test]
fn a_paragraph_drag_ends_when_the_window_loses_focus() {
for tool in [pdfcraft_ui_egui::QuickTool::EditText, pdfcraft_ui_egui::QuickTool::AddText] {
let mut h = edit_page(tool);
let id = h.state().views[0].id;
let before = h.state().session.get(id).unwrap().text_blocks(0)[0].rect;
let from = point_on(&h, (before[0] + before[2]) / 2.0, (before[1] + before[3]) / 2.0);
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(from + egui::vec2(0.0, 10.0 * k as f32));
h.run_steps(1);
}
assert!(h.state().views[0].block_drag.is_some(), "the paragraph is being moved");
h.input_mut().focused = false;
h.run_steps(2);
h.drop_at(from + egui::vec2(0.0, 40.0));
h.run_steps(2);
h.input_mut().focused = true;
h.hover_at(from + egui::vec2(0.0, 80.0));
h.run_steps(4);
assert!(h.state().views[0].block_drag.is_none(), "the drag is over");
assert_eq!(h.state().session.get(id).unwrap().text_blocks(0)[0].rect, before, "nothing moved");
assert!(!dirty(&h));
}
}
/// The rotate knob of a picture that fills its page lies past the page's edge, and a drag there
/// still turns the picture: the press is the page's whose knob it is.
#[test]
fn dragging_the_rotate_knob_past_the_page_edge_turns_the_picture() {
let mut png = Vec::new();
image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
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("picture.png", None, png.clone()).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0));
h.run_steps(3);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
// Above the top edge, or below the bottom one when the view has no room above.
let knob = [egui::pos2(r.center().x, r.top() - 22.0), egui::pos2(r.center().x, r.bottom() + 22.0)]
.into_iter()
.find(|p| cursor_over(&mut h, *p) == egui::CursorIcon::Grab)
.expect("the knob past the page's edge");
assert!(!r.contains(knob));
let to = egui::pos2(r.right() + 60.0, r.center().y);
h.hover_at(knob);
h.run_steps(1);
h.drag_at(knob);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(knob + (to - knob) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(4);
let id = h.state().views[0].id;
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Rotate image"));
}
/// Organize pages has Acrobat's panel whenever the grid shows, however it was opened, and the
/// panel's Close ends Organize: the grid closes and All tools comes back.
#[test]
@@ -8021,6 +8867,34 @@ fn test_image_context_menu_crop_overlapping_image_and_handle() {
}
}
/// A right-click on the part of a picture its crop cuts away is the crop's, as one on the box kept:
/// crop mode stays and no menu opens (#5835).
#[test]
fn a_right_click_on_the_part_a_crop_cuts_away_keeps_crop_mode() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("cut.pdf", None, two_image_fixture()).unwrap();
app
});
h.run_steps(4);
let id = h.state().views[0].id;
let rect = h.state().session.get(id).unwrap().page_images(0)[0].rect;
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(4);
// Keep the picture's left half.
let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0);
selection.crop = Some(pdfcraft_ui_egui::CropMode::new([rect[0], rect[1], (rect[0] + rect[2]) / 2.0, rect[3]]));
h.state_mut().views[0].image_selection = Some(selection);
h.run_steps(2);
let xf = h.state().views[0].page_xform(0).unwrap();
let doc = h.state().session.get(id).unwrap();
let b = pdfcraft_ui_egui::canvas::image_screen_rect(&doc.page_images(0)[0], &xf, &doc.info, 0);
assert!(b.width() > 60.0, "the picture is wide enough for a point clear of the grips: {b:?}");
crop_secondary_click(&mut h, b.lerp_inside(egui::vec2(0.8, 0.5)));
assert_crop_context_preserved(&h);
}
#[test]
fn test_image_context_menu_crop_page_edge_pills() {
for rotation in [0, 90, 180, 270] {
+119
View File
@@ -385,3 +385,122 @@ fn modifier_selection_picks_foreground_text_and_added_items_over_background_artw
assert!(new.rect.iter().enumerate().all(|(i, value)| (value - old.rect[i] - shift[i % 2]).abs() < 0.01), "{old:?} -> {new:?}");
}
}
/// A press with Shift while a paragraph with changes is open is held until the button is up: it
/// says why, the paragraph keeps its text, and nothing under the press is moved or selected, not
/// even once the press turns into a drag.
#[test]
fn a_shift_click_with_a_changed_paragraph_open_shows_the_notice_and_moves_nothing() {
let mut h = harness();
let before = objects(&h);
let p = at(&h, 55.0, 70.0);
click(&mut h, p, Modifiers::NONE);
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "A draft still being edited".into();
let (a, b) = (at(&h, 60.0, 145.0), at(&h, 120.0, 200.0));
h.hover_at(a);
h.run_steps(2);
h.event_modifiers(
egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::SHIFT },
Modifiers::SHIFT,
);
h.run_steps(2);
let notice = "Finish or discard the text being edited before selecting several objects.";
assert!(h.state().toast.as_ref().is_some_and(|t| t.0.contains(notice)), "the notice says why");
for k in 1..=3 {
h.hover_at(a + (b - a) * (k as f32 / 3.0));
h.run_steps(2);
}
h.drop_at(b);
h.run_steps(4);
assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("A draft still being edited"));
assert_eq!(objects(&h), before, "nothing moved");
assert!(h.state().views[0].image_selection.is_none() && h.state().views[0].content.selected.is_none(), "nothing selected");
assert!(
h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(),
"no objects selected"
);
}
/// A press beside a picture, within the reach the objects selected together are found in but
/// outside the picture itself, is nobody's: it starts an area selection. It used to start nothing.
#[test]
fn a_press_just_outside_a_picture_starts_an_area_selection() {
let mut h = harness();
// The figure is x 40..100, y 130..160 from the page's top; the added note x 130..230, y 130..144.
let (a, b) = (at(&h, 38.5, 140.0), at(&h, 235.0, 175.0));
h.hover_at(a);
h.run_steps(2);
h.drag_at(a);
h.run_steps(2);
h.hover_at(b);
h.run_steps(2);
h.drop_at(b);
h.run_steps(4);
h.get_by_label("2 objects selected");
}
/// A double-click on one of the objects selected together leaves the group and reaches that one:
/// added text opens for typing.
#[test]
fn a_double_click_on_a_grouped_object_reaches_it() {
let mut h = harness();
marquee(&mut h);
// The added note is x 130..230, y 130..144 from the page's top.
let note = at(&h, 180.0, 137.0);
crate::support::input::double_click(&mut h, note);
h.run_steps(2);
assert!(h.state().views[0].content.draft.as_ref().is_some_and(|d| d.index == Some(0)), "the added note opens for typing");
}
/// A double-click on a paragraph selected with others opens the one its press was on, though the
/// button came up just off it.
#[test]
fn a_double_click_on_a_grouped_paragraph_released_just_off_it_opens_it() {
let mut h = harness();
marquee(&mut h);
let (inside, outside) = {
let app = h.state();
let view = &app.views[0];
let xf = view.page_xform(0).unwrap();
let doc = app.session.get(view.id).unwrap();
let block = doc.text_blocks(0).into_iter().find(|b| b.text.contains("Group headline")).expect("the headline");
let frame = xf.user_rect(&doc.info, 0, block.rect.map(|v| v as f32));
(egui::pos2(frame.center().x, frame.top() + 1.0), egui::pos2(frame.center().x, frame.top() - 4.0))
};
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
h.hover_at(inside);
h.run_steps(1);
crate::support::input::one_frame(&mut h, [button(inside, true), button(inside, false)]);
crate::support::input::one_frame(&mut h, [button(inside, true), egui::Event::PointerMoved(outside), button(outside, false)]);
h.run_steps(2);
assert!(h.state().views[0].line_editor.is_some(), "the paragraph pressed is open for typing");
}
/// A press counts its own modifiers, whatever the frame's are: Shift in the press event adds to the
/// selection, though the frame's modifiers show none.
#[test]
fn a_press_counts_the_modifiers_of_its_own_event() {
let mut h = harness();
let p = at(&h, 55.0, 70.0);
click(&mut h, p, Modifiers::NONE);
assert!(h.state().views[0].line_editor.is_some());
let p = at(&h, 60.0, 145.0);
let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT };
h.hover_at(p);
h.run_steps(2);
crate::support::input::one_frame(&mut h, [button(true), button(false)]);
h.run_steps(4);
h.get_by_label("2 objects selected");
}
/// A double-click on a paragraph selected with others opens it for typing (the user's ruling).
#[test]
fn a_double_click_on_a_grouped_paragraph_opens_it() {
let mut h = harness();
marquee(&mut h);
// "Group headline" is at x 40, its baseline y 430 of 500.
let headline = at(&h, 80.0, 65.0);
crate::support::input::double_click(&mut h, headline);
h.run_steps(2);
assert!(h.state().views[0].line_editor.is_some(), "the paragraph is open for typing");
}
+452 -30
View File
@@ -65,6 +65,47 @@ fn linked_text_pdf() -> Vec<u8> {
])
}
/// A 300×400 page with a picture at x 50..250, y 250..350 and a web link over its middle, at
/// x 100..200, y 280..320.
fn linked_picture_pdf() -> Vec<u8> {
let picture =
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream";
let content = stream("q 200 0 0 100 50 250 cm /Im0 Do Q");
pdf(&[
CATALOG,
PAGES,
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /XObject << /Im0 6 0 R >> >> /Annots [4 0 R] >>",
"<< /Type /Annot /Subtype /Link /Rect [100 280 200 320] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>",
content.as_str(),
picture,
])
}
/// Two 300×400 pages, the first with a line of text at its bottom edge, at y 4, the second with one
/// near its top, at y 380.
fn bottom_line_pages_pdf() -> Vec<u8> {
let bottom = stream("BT /F1 12 Tf 40 4 Td (Bottom line) Tj ET");
let top = stream("BT /F1 12 Tf 40 380 Td (Top line) Tj ET");
pdf(&[
CATALOG,
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>",
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> >> >>",
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >>",
bottom.as_str(),
top.as_str(),
HELVETICA,
])
}
/// A 300×400 page with a picture at x 50..250, y 250..350, and graphics states nested deeper than
/// the objects selected together are read through: they can't be selected together on this page.
fn ungroupable_picture_pdf() -> Vec<u8> {
let picture =
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream";
let content = stream(&format!("q 200 0 0 100 50 250 cm /Im0 Do Q {}{}", "q ".repeat(1030), "Q ".repeat(1030)));
pdf(&[CATALOG, PAGES, "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>", content.as_str(), picture])
}
/// 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);
@@ -229,16 +270,13 @@ fn tip_says(h: &Harness<'_, PdfCraftApp>, text: &str) -> bool {
}
/// 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 {
/// worker threads, `support::step_until`), and returns the last cursor seen.
fn wait_for_cursor(h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2, want: impl Fn(egui::CursorIcon) -> bool) -> egui::CursorIcon {
let mut cursor = egui::CursorIcon::Default;
for _ in 0..50 {
let _ = crate::support::step_until(h, |h| {
cursor = cursor_at(h, p);
if want(cursor) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
want(cursor)
});
cursor
}
@@ -344,6 +382,35 @@ fn a_drag_from_text_onto_a_link_selects_the_text() {
assert!(selected.contains("example.org"), "the drag selected across the link: {selected:?}");
}
/// With the Select tool a click on a link over a picture follows the link and picks no picture; one
/// on the picture beside the link picks it. The link's click used to pick the picture under it too.
#[test]
fn a_click_on_a_link_over_a_picture_follows_it_and_picks_no_picture() {
let mut h = open(linked_picture_pdf(), "150", false);
let beside = at(&h, 70.0, 300.0);
mouse_click(&mut h, beside);
assert!(h.state().views[0].picked_image.is_some(), "the picture beside the link is picked");
h.state_mut().views[0].picked_image = None;
let on_link = at(&h, 150.0, 300.0);
mouse_click(&mut h, on_link);
assert_eq!(pending_url(&h).as_deref(), Some("https://example.org/"), "the link is followed");
assert!(h.state().views[0].picked_image.is_none(), "and no picture is picked");
}
/// The I-beam drops over a link only where a press would go to it: with the Select tool the link's
/// hand shows there, and with the highlighter, whose drag selects the link's text, the I-beam. The
/// highlighter's I-beam used to drop over every link.
#[test]
fn the_i_beam_drops_over_a_link_only_where_the_press_goes_to_it() {
let mut h = open(linked_text_pdf(), "150", false);
let (plain, linked) = (at(&h, 200.0, 303.0), at(&h, 100.0, 303.0));
assert_eq!(wait_for_cursor(&mut h, plain, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page's text has arrived");
assert_eq!(cursor_at(&mut h, linked), egui::CursorIcon::PointingHand, "the Select tool follows the link");
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Highlight);
h.run_steps(2);
assert_eq!(cursor_at(&mut h, linked), egui::CursorIcon::Text, "the highlighter selects its text");
}
/// Over two comments the tip names the one drawn on top (#3620), and a click should select that
/// one too. The renderer paints in `/Annots` order, but the hit test takes the last comment in
/// inspection's order, which is by top edge, and selects the one underneath.
@@ -363,7 +430,7 @@ fn a_comments_tip_shows_with_every_tool_beside_its_cursor() {
let mut h = open(comment_on_text_pdf(), "150", false);
// Inside the rectangle comment by "Reviewer": where there is no text, and over a word.
let (blank, on_text) = (at(&h, 120.0, 315.0), at(&h, 60.0, 303.0));
let hover = |h: &mut Harness<'_, PdfCraftApp>, tool: QuickTool, p: egui::Pos2, want: egui::CursorIcon| {
let hover = |h: &mut Harness<'static, PdfCraftApp>, tool: QuickTool, p: egui::Pos2, want: egui::CursorIcon| {
h.state_mut().quick_tool = tool;
h.run_steps(4);
let cursor = wait_for_cursor(h, p, |c| c == want);
@@ -1396,6 +1463,114 @@ fn a_right_click_on_a_comment_under_a_camera_corner_opens_the_comments_menu() {
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu");
}
/// A right-button drag from `a` to `b`, in frames of their own.
fn right_drag(h: &mut Harness<'_, PdfCraftApp>, a: egui::Pos2, b: egui::Pos2) {
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE };
h.hover_at(a);
h.run_steps(1);
h.event(button(a, true));
h.run_steps(1);
for k in 1..=4 {
h.hover_at(a + (b - a) * (k as f32 / 4.0));
h.run_steps(1);
}
h.event(button(b, false));
h.run_steps(3);
}
// A right-button drag starts nothing a left-button drag does. egui's drag start counts every
// button, and each of these started on it.
/// With the Rectangle tool, a right-button drag draws no rectangle.
#[test]
fn a_right_button_drag_draws_no_rectangle() {
let mut h = open(text_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle);
h.run_steps(4);
let (a, b) = (at(&h, 60.0, 150.0), at(&h, 200.0, 80.0));
right_drag(&mut h, a, b);
let s = h.state();
assert!(s.session.get(s.views[0].id).unwrap().info.annotations.is_empty(), "no rectangle is drawn");
}
/// With Prepare open, a right-button drag around a field selects it by no selection rectangle.
#[test]
fn a_right_button_drag_selects_no_field() {
let mut h = open(comment_and_checkbox_pdf(), "150", false);
assert!(h.state_mut().execute("form.prepare"));
h.run_steps(4);
let (a, b) = (at(&h, 60.0, 250.0), at(&h, 140.0, 180.0));
right_drag(&mut h, a, b);
assert!(h.state().views[0].prepare.selected.is_none(), "no field is selected");
}
/// With the Link tool, a right-button drag draws no link.
#[test]
fn a_right_button_drag_draws_no_link() {
let mut h = open(text_pdf(), "150", false);
link_tool(&mut h);
let (a, b) = (at(&h, 60.0, 150.0), at(&h, 200.0, 80.0));
right_drag(&mut h, a, b);
assert!(h.state().link_draft.is_none() && h.state().dialog != Some(Dialog::LinkProps), "no link is drawn");
}
/// With the camera, a right-button drag on another page leaves the quadrilateral where it is.
#[test]
fn a_right_button_drag_leaves_the_camera_quadrilateral() {
let mut h = open(two_pages_pdf(), "100", false);
h.state_mut().quick_tool = QuickTool::EnhanceCamera;
h.run_steps(4);
assert_eq!(h.state().views[0].camera.as_ref().map(|q| q.page), Some(0));
let two = h.state().views[0].page_screen_rect(1).expect("page 2 on screen");
right_drag(&mut h, two.min + egui::vec2(40.0, 40.0), two.min + egui::vec2(120.0, 100.0));
assert_eq!(h.state().views[0].camera.as_ref().map(|q| q.page), Some(0), "the quadrilateral stays on page 1");
}
/// A text selection, then `tool` and a right-button drag across more of the line. The harness, and
/// the text selected before the drag.
fn right_drag_over_a_selection(tool: QuickTool) -> (Harness<'static, PdfCraftApp>, Option<String>) {
let mut h = open(text_pdf(), "150", false);
let (a, b) = (at(&h, 45.0, 303.0), at(&h, 120.0, 303.0));
assert_eq!(wait_for_cursor(&mut h, a, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page's text has arrived");
mouse_drag(&mut h, a, b - a);
let selected = h.state().views[0].selected_text();
assert!(selected.is_some(), "some text is selected");
h.state_mut().quick_tool = tool;
h.run_steps(2);
let (c, d) = (at(&h, 150.0, 303.0), at(&h, 230.0, 303.0));
right_drag(&mut h, c, d);
(h, selected)
}
/// With the Select tool, a right-button drag leaves the text selected as it is. It moved the
/// selection's end to the pointer.
#[test]
fn a_right_button_drag_leaves_a_text_selection_alone() {
let (h, selected) = right_drag_over_a_selection(QuickTool::Select);
assert_eq!(h.state().views[0].selected_text(), selected, "the selection stays as it was");
}
/// A right-button drag with a markup tool or Redact turns no selection left from another tool into
/// a comment: its end finished the selection whichever button it was.
#[test]
fn a_right_button_drag_makes_no_mark_of_a_text_selection() {
for tool in [QuickTool::Comment(CommentTool::Highlight), QuickTool::Redact] {
let (h, _) = right_drag_over_a_selection(tool);
let s = h.state();
assert!(s.session.get(s.views[0].id).unwrap().info.annotations.is_empty(), "no mark with {tool:?}");
}
}
/// With the Select tool, a right-button drag across text selects none.
#[test]
fn a_right_button_drag_selects_no_text() {
let mut h = open(text_pdf(), "150", false);
let (a, b) = (at(&h, 45.0, 303.0), at(&h, 200.0, 303.0));
assert_eq!(wait_for_cursor(&mut h, a, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page's text has arrived");
right_drag(&mut h, a, b);
assert!(h.state().views[0].selected_text().is_none(), "no text is selected");
}
/// A 300×400 page with a web link along its left edge, x 0..100, y 300..350.
fn edge_link_pdf() -> Vec<u8> {
pdf(&[
@@ -1418,19 +1593,42 @@ fn the_link_tools_hand_stops_at_the_pages_edge() {
assert_ne!(cursor_at(&mut h, beside), egui::CursorIcon::PointingHand, "just off the page");
}
/// With Edit text a right-click on a paragraph is Edit text's, as before, though a comment lies
/// over it: the comment is not selected. The page's menu is anchored there all the same (Paste on
/// the picture menu, Delete page); it used to stay where the last right-click comments saw was.
/// With Edit text a right-click goes to what lies on top under it, as with every tool: on a
/// paragraph under a comment, the comment, selected for its menu; on the paragraph beside it, the
/// paragraph, and the comment is unselected. The page's menu is anchored there either way (Paste on
/// the picture menu, Delete page). Edit text used to keep the right-clicks on its paragraphs, and
/// left the comment over them unselected.
#[test]
fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_no_comment() {
fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_the_comment() {
let mut h = open(comment_on_text_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::EditText;
h.run_steps(4);
let on_text = at(&h, 100.0, 303.0);
wait_for_cursor(&mut h, on_text, |c| c != egui::CursorIcon::Default);
right_click(&mut h, on_text);
assert_eq!(h.state().views[0].comments.selected, None);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment over the paragraph is selected");
assert!(h.query_by_label("Properties…").is_some(), "its menu opens");
assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0), "the menu is anchored on the page");
// The rectangle ends at x 200; the line goes on.
let beside = at(&h, 230.0, 303.0);
right_click(&mut h, beside);
assert_eq!(h.state().views[0].comments.selected, None, "the paragraph beside the comment leaves it unselected");
}
/// With Edit text, a right-click on added text over a comment selects no comment: what has no menu
/// gives way to a picture under it and to nothing else, as before.
#[test]
fn a_right_click_with_edit_text_on_added_text_over_a_comment_selects_no_comment() {
let mut h = open(comment_on_text_pdf(), "150", false);
assert!(h.state_mut().execute("edit.edit_text"));
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [110.0, 296.0, 190.0, 311.0], text: "Added".into(), ..Default::default() },
}));
h.run_steps(4);
let on_added = at(&h, 150.0, 303.0);
right_click(&mut h, on_added);
assert_eq!(h.state().views[0].comments.selected, None, "the comment under the added text is not selected");
}
/// With a Prepare field tool a right-click is the tool's, as before: a comment under it is not
@@ -1518,6 +1716,29 @@ fn a_right_click_selects_its_comment_though_a_gesture_was_left_over() {
assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note under the right-click is selected");
}
/// A comment's move ends once its button is up, though the pages missed its release, and applies
/// nothing: where it came up is not known, and the pointer has moved on. A drag elsewhere later moves
/// nothing either. The move waited for the next drag's end on the page, and took the comment there.
#[test]
fn a_comment_move_whose_release_the_pages_missed_ends_once_the_button_is_up() {
let mut h = open(stacked_comments_pdf(), "150", false);
let rect = |h: &Harness<'_, PdfCraftApp>| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.index == 0).map(|a| a.rect)
};
let before = rect(&h);
let bottom = at(&h, 60.0, 160.0);
h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 0, from: bottom });
let elsewhere = at(&h, 250.0, 100.0);
h.hover_at(elsewhere);
h.run_steps(2);
assert!(h.state().views[0].comments.gesture.is_none(), "the move is over");
assert_eq!(rect(&h), before, "and moved nothing to where the pointer turned up");
let blank = at(&h, 250.0, 60.0);
mouse_drag(&mut h, blank, egui::vec2(30.0, 0.0));
assert_eq!(rect(&h), before, "nothing moved the comment");
}
/// A click that began off the page (in the gutter beside it) is no page's: the selected comment
/// stays selected, as the Link tool's and Prepare's selections do. It used to unselect it.
#[test]
@@ -1591,8 +1812,8 @@ fn a_right_click_after_escape_cancelled_the_press_anchors_the_menu_on_its_page()
assert_eq!(menu_page_after_a_right_click_with_the_button_held(true), Some(1), "the menu is page 2's");
}
/// With Edit text a right-click on added text reaches comments, as before (Edit text keeps those on
/// its own paragraphs and pictures): the selected comment is unselected.
/// With Edit text a right-click on added text reaches comments, as before: the selected comment is
/// unselected.
#[test]
fn a_right_click_with_edit_text_on_added_text_unselects_the_comment() {
let mut h = open(comment_on_text_pdf(), "150", false);
@@ -1673,6 +1894,198 @@ fn with_prepare_a_click_that_began_off_the_page_keeps_the_selected_field() {
assert!(h.state().views[0].prepare.selected.is_some(), "a click that began off the page leaves it selected");
}
/// With Prepare open, a drag from a highlight draws the selection rectangle: a highlight lets a drag
/// go, so nobody holds it and it is Prepare's.
#[test]
fn with_prepare_a_drag_from_a_highlight_draws_the_selection_rectangle() {
let mut h = open(highlighted_checkbox_pdf(), "150", false);
assert!(h.state_mut().execute("form.prepare"));
h.run_steps(4);
// On the highlight, beside the box; the drag ends past the box's other corner.
let (from, to) = (at(&h, 97.0, 197.0), at(&h, 130.0, 230.0));
mouse_drag(&mut h, from, to - from);
assert!(h.state().views[0].prepare.selected.is_some(), "the rectangle selects the field inside it");
}
/// A right-click while the primary button is held opens the menu of the comment it went to, when that
/// is the one selected, and leaves the selection as it is.
#[test]
fn a_right_click_with_the_button_held_opens_the_selected_comments_menu() {
let mut h = open(comment_on_text_pdf(), "150", false);
// On the rectangle's left edge: an unfilled one takes a press on its border.
let edge = at(&h, 30.0, 303.0);
wait_for_cursor(&mut h, edge, |c| c != egui::CursorIcon::Default);
mouse_click(&mut h, edge);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected");
h.hover_at(edge);
h.run_steps(1);
h.drag_at(edge);
h.run_steps(1);
let moved = edge + egui::vec2(20.0, 0.0);
h.hover_at(moved);
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: moved, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE });
}
h.run_steps(3);
assert!(h.query_by_label("Properties…").is_some(), "the comment's menu");
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the selection stays");
h.drop_at(moved);
h.run_steps(2);
}
/// With the page gaps hidden, a Shift-click on the row two pages share is the first page's: what it
/// selects there stays selected, and the second page leaves it alone.
#[test]
fn a_shift_click_where_two_pages_touch_keeps_what_it_selected() {
let mut h = open(bottom_line_pages_pdf(), "100", false);
h.state_mut().views[0].show_page_gaps = false;
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(4);
let (one, two) = {
let v = &h.state().views[0];
(v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen"))
};
// "Bottom line" runs from x 40, a few points above page 1's foot.
let seam = egui::pos2(one.min.x + 70.0, one.max.y);
assert!(one.contains(seam) && two.contains(seam), "the pages touch: {one:?} {two:?}");
h.hover_at(seam);
h.run_steps(2);
for pressed in [true, false] {
h.event_modifiers(
egui::Event::PointerButton { pos: seam, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::SHIFT },
egui::Modifiers::SHIFT,
);
}
h.run_steps(4);
assert!(h.query_by_label_contains("1 object").is_some(), "the line is selected");
}
/// With Prepare open on a document that can't be changed, a right-click on a field selects nothing
/// and opens the page's menu, whose field entries would only be refused.
#[test]
fn with_prepare_on_a_locked_document_a_right_click_on_a_field_selects_nothing() {
let locked = crate::editing::encrypted(comment_and_checkbox_pdf(), pdfcraft_cos::Algorithm::Aes256, "", "owner", -1 ^ 8);
let mut h = open(locked, "150", false);
// Prepare doesn't open on such a document; it stays open on a tab switched to one.
h.state_mut().left = pdfcraft_ui_egui::LeftPanel::Tool("form");
h.state_mut().left_open = true;
h.run_steps(4);
assert!(h.state().is_preparing(), "Prepare is open");
let id = h.state().views[0].id;
assert!(!h.state().session.get(id).unwrap().allows_modification(), "the document can't be changed");
let on_box = at(&h, 110.0, 210.0);
right_click(&mut h, on_box);
assert!(h.state().views[0].prepare.selected.is_none(), "no field is selected");
assert!(h.query_by_label("Duplicate…").is_none(), "no field menu");
}
/// With Prepare open and a field selected, a right-click on the empty page opens the page's menu, and
/// one on the field the field's: the menu is what the right-click went to. The field's menu opened
/// wherever the page was right-clicked.
#[test]
fn with_prepare_a_right_click_beside_the_selected_field_opens_the_pages_menu() {
let mut h = open(comment_and_checkbox_pdf(), "150", false);
assert!(h.state_mut().execute("form.prepare"));
h.run_steps(4);
let on_box = at(&h, 110.0, 210.0);
mouse_click(&mut h, on_box);
assert!(h.state().views[0].prepare.selected.is_some(), "the field is selected");
let blank = at(&h, 60.0, 300.0);
right_click(&mut h, blank);
assert!(h.query_by_label("Duplicate…").is_none(), "no field menu on the empty page");
assert!(h.query_by_label("Marquee Zoom").is_some(), "the page's menu");
h.key_press(egui::Key::Escape);
h.run_steps(3);
right_click(&mut h, on_box);
assert!(h.query_by_label("Duplicate…").is_some(), "the field's menu on the field");
}
/// With a Prepare field tool picked, a right-click on a field selects it and opens its menu, as with
/// Prepare's Select tool: the tool's hit leaves fields out (it draws new ones over them), but a
/// right-click reaches them.
#[test]
fn with_a_field_tool_a_right_click_on_a_field_opens_its_menu() {
let mut h = open(comment_and_checkbox_pdf(), "150", false);
assert!(h.state_mut().execute("form.prepare"));
h.run_steps(4);
assert!(h.state_mut().execute("form.add.text"));
h.run_steps(4);
let on_box = at(&h, 110.0, 210.0);
right_click(&mut h, on_box);
assert!(h.state().views[0].prepare.selected.is_some(), "the field is selected");
assert!(h.query_by_label("Duplicate…").is_some(), "the field's menu");
}
/// With Edit text, a right-click on a picture under a link is the picture's (Edit text doesn't
/// follow links): it is selected for its menu.
#[test]
fn with_edit_text_a_right_click_on_a_picture_under_a_link_selects_the_picture() {
let mut h = open(linked_picture_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::EditText;
h.run_steps(4);
let on_link = at(&h, 150.0, 300.0);
right_click(&mut h, on_link);
let selected = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index));
assert_eq!(selected, Some((0, 0)), "the picture is selected for its menu");
}
/// With the gaps between pages hidden, a drag from the top of page 2 under page 1's open paragraph is
/// page 2's: its area selection takes the line there. The paragraph's frame, which reaches past
/// page 1's edge, took the press and did nothing with it.
#[test]
fn a_drag_past_a_pages_edge_under_its_open_paragraph_is_the_next_pages() {
let mut h = open(bottom_line_pages_pdf(), "100", false);
h.state_mut().views[0].show_page_gaps = false;
h.state_mut().quick_tool = QuickTool::EditText;
h.run_steps(4);
let (one, two) = {
let v = &h.state().views[0];
(v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen"))
};
let line = egui::pos2(one.min.x + 60.0, one.max.y - 8.0);
mouse_click(&mut h, line);
assert!(h.state().views[0].line_editor.is_some(), "the bottom line is open");
let below = egui::pos2(one.min.x + 60.0, two.min.y + 3.0);
mouse_drag(&mut h, below, egui::vec2(200.0, 30.0));
// What the area selection took, Delete removes.
h.key_press(egui::Key::Delete);
h.run_steps(4);
let s = h.state();
assert!(s.session.get(s.views[0].id).unwrap().text_blocks(1).is_empty(), "page 2's area selection took its line");
}
/// With Edit text, a click on the empty page leaves the selected picture unselected, on a page whose
/// objects can't be selected together too, where no area selection starts to do it. It stayed.
#[test]
fn a_click_beside_the_picture_unselects_it_where_objects_cant_be_grouped() {
let mut h = open(ungroupable_picture_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::EditText;
h.run_steps(4);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.page_images(0).len(), 1, "the picture");
assert!(doc.editable_objects(0).is_err(), "and no objects to group");
h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0));
h.run_steps(2);
let blank = at(&h, 150.0, 100.0);
mouse_click(&mut h, blank);
assert!(h.state().views[0].image_selection.is_none(), "the picture is unselected");
}
/// With the Select tool a drag that starts on a link lets the picked picture go, as a drag elsewhere
/// does, though the link's click picks none.
#[test]
fn a_drag_from_a_link_lets_the_picked_picture_go() {
let mut h = open(linked_picture_pdf(), "150", false);
let beside = at(&h, 70.0, 300.0);
mouse_click(&mut h, beside);
assert!(h.state().views[0].picked_image.is_some(), "the picture is picked");
let on_link = at(&h, 150.0, 300.0);
mouse_drag(&mut h, on_link, egui::vec2(30.0, 0.0));
assert!(h.state().views[0].picked_image.is_none(), "the drag lets it go");
}
/// With the Edit panel open and the Note tool, a click on added text is the added text's: no note is
/// started under it, as before.
#[test]
@@ -1691,8 +2104,9 @@ fn a_note_tool_click_on_added_text_starts_no_note() {
assert!(h.state().views[0].comments.composer.is_none(), "no note is started");
}
/// With Fill & Sign, a mark being moved on the page keeps the page's presses, as before: a click while
/// its move is still live (its release never seen) places no new mark.
/// With Fill & Sign, a mark being moved on the page keeps the page's presses, as before: a press held
/// through its move and released places no new mark. (A move left over once the button is up ends:
/// `a_comment_move_whose_release_the_pages_missed_ends_once_the_button_is_up`.)
#[test]
fn a_fill_and_sign_click_places_nothing_while_a_mark_is_moved() {
let mut h = open(comment_on_text_pdf(), "150", false);
@@ -1704,8 +2118,13 @@ fn a_fill_and_sign_click_places_nothing_while_a_mark_is_moved() {
};
let before = marks(&h);
let blank = at(&h, 150.0, 100.0);
h.hover_at(blank);
h.run_steps(1);
h.drag_at(blank);
h.run_steps(1);
h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 0, from: blank });
mouse_click(&mut h, blank);
h.drop_at(blank);
h.run_steps(4);
assert_eq!(marks(&h), before, "no mark is placed");
}
@@ -1757,11 +2176,12 @@ fn a_right_click_between_the_pages_opens_the_page_menu_not_the_selected_comments
assert!(h.query_by_label("Reply").is_none(), "not the comment's");
}
/// Where pages touch, a right-click on the row they share is page 1's, and a picture on page 2 there
/// opens its menu, whose Paste then places the image as Ctrl+V does. It used to centre the image on
/// page 1's point, which on page 2 lies at its foot.
/// Where pages touch, a right-click on the row they share is page 1's, whatever lies there on page 2:
/// page 2's picture across its top edge opens no menu, and page 1's menu pastes on page 1, where it
/// opened. The page view's own picture scan used to open page 2's picture's menu there, whose Paste
/// centred the image on page 1's point, which on page 2 lies at its foot.
#[test]
fn paste_from_a_picture_menu_where_two_pages_touch_keeps_off_the_first_pages_point() {
fn paste_from_a_right_click_where_two_pages_touch_keeps_to_the_first_page() {
let (mut h, seam) = touching_pages(|app| {
app.system_clipboard = false;
app.quick_tool = QuickTool::EditText;
@@ -1774,16 +2194,18 @@ fn paste_from_a_picture_menu_where_two_pages_touch_keeps_off_the_first_pages_poi
h.run_steps(4);
right_click(&mut h, seam);
assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0), "page 1 takes the right-click");
assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| s.page), Some(1), "page 2's picture opens its menu");
h.get_by_label("Paste").click();
assert!(h.state().views[0].image_selection.is_none(), "page 2's picture is not selected for its menu");
h.get_by_label_contains("Paste").click();
h.run_steps(3);
let pasted = {
let images = |page| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().page_images(1)
s.session.get(s.views[0].id).unwrap().page_images(page)
};
assert_eq!(pasted.len(), 2, "the image is pasted on page 2");
let r = pasted[1].rect;
assert!((r[1] + r[3]) / 2.0 > 100.0, "not centred at page 2's foot: {r:?}");
assert_eq!(images(1).len(), 1, "nothing is pasted on page 2");
let pasted = images(0);
assert_eq!(pasted.len(), 1, "the image is pasted on page 1");
let r = pasted[0].rect;
assert!((r[1] + r[3]) / 2.0 < 100.0, "at page 1's foot, where its menu opened: {r:?}");
}
/// With the camera tool, a click on another page moves the quadrilateral there, as a drag from it