mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 12:32:52 +00:00
Merge remote-tracking branch 'upstream/main' into HEAD
This commit is contained in:
commit
7fa6c82b9e
23 files changed
+2474
-745
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user