fix(ui): Edit text's paragraphs, pictures, objects and added content decide a press by one hit test

Edit text's paragraph edges were tested twice: by edit_text_ui for the cursor, on the page's
paragraphs, and by object_ui for the press, on its own object inventory (grabs_edge). Where the
two inventories disagree, the resize arrow shows and a press starts an invisible area selection
instead (#2276, the review's step-3 probe). The open paragraph's frame showed the Move cursor
4 px out and took the press 6 px out.

Each of these parts now has one hit test, in the page router's table, and acts only on the
press it was captured on:
- edit_text_ui::hit: a paragraph's edges (Handle), its body (Text; Editor for the open one).
- edit_text_ui::picture_hit and picture_handle_hit: a picture's body on the Text layer, asked
  before the paragraphs, so text on top of artwork takes the press; the selected picture's
  corners and rotate knob on Handle, asked after them, so they win over text.
- object_ui::hit: the objects selected together (Placed) and the open paragraph's frame
  (Handle), with one reach (FRAME_REACH, 6 px) for the cursor and the press. An area selection
  starts only where nobody's hit claims the press.
- content_ui::hit: added text and pictures (Placed; the selected one's corners on Handle), over
  form fields; Add text starts a new box only where nobody's hit claims the press.
image_owns_pointer and content_ui::select_grabs go, with the edge test object_ui kept a copy of
(grabs_edge). The page's hits move into canvas::PageHits.

Un-ignores a_paragraph_edge_resizes_wherever_it_shows_the_resize_cursor: it passes on main
already (the inventories agree for its paragraph since the probe was written), so it stays as a
guard rather than as proof. New test an_open_paragraphs_frame_shows_move_wherever_a_press_takes_the_box
fails on the previous commit (the cursor is Default there).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alehel
2026-10-29 12:53:23 +01:00
co-authored by Claude Opus 5.5
parent 63fa5010d8
commit a4c5fbed64
8 changed files with 354 additions and 162 deletions
+134 -42
View File
@@ -1129,26 +1129,34 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
let PageFrame { resp, ptr, pointer, .. } = *frame;
let 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 };
let (lines, images) =
if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() };
let field_tool = match tool {
QuickTool::Field(f) => Some(f),
_ => None,
};
// While a table is being set up, nothing else is drawn or moved.
let prepare_allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none();
// What lies under a point, bottom up: links, comments, then form fields (Prepare's fields,
// being moved, over the comments; filled ones under the comments that move).
let hits = |view: &DocView, p: Pos2| {
let fields = if preparing {
crate::prepare::hit(&xf, info, i, form, field_tool, prepare_allowed, &view.prepare, p)
} else if tool == QuickTool::Select {
crate::forms_ui::hit(&xf, info, i, form, can_fill, p)
} else {
None
};
[link_hit(info, &xf, i, p), comments::hit(&pcx, view, p), fields].into_iter().flatten()
let hits = PageHits {
page: i,
xf: &xf,
info,
tool,
pcx: &pcx,
form,
can_fill,
can_modify,
editing_content,
preparing,
field_tool,
prepare_allowed,
added,
lines: &lines,
images: &images,
clip: ui.clip_rect(),
};
if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) {
let at = hits(view, o).collect::<Vec<_>>();
let at = hits.at(view, o);
view.router.press(i, at, tool);
}
if let QuickTool::Measure(measure_tool) = tool {
@@ -1254,9 +1262,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
frame.out.open_table = frame.out.open_table.or(o.table);
o.consumed || field_tool.is_some()
} else {
tool == QuickTool::Select
&& !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, ptr, i, info, added, view))
&& crate::forms_ui::page_input(cx, form, can_fill, view)
tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, view)
};
// Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat).
let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed;
@@ -1268,21 +1274,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
};
crate::redact_ui::page_input(cx, over_text, view)
};
let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, ptr, i, info, view);
let on_objects = !image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view);
let lines = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify {
let generation = doc.edit_generation();
match view.edit_lines.get(&i) {
Some((g, l)) if *g == generation => l.clone(),
_ => {
let l = doc.text_blocks(i);
view.edit_lines.insert(i, (generation, l.clone()));
l
}
}
} else {
Vec::new()
};
let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view);
let over_source = tool == QuickTool::AddText
&& pointer.is_some_and(|p| {
!crate::content_ui::hits_added(&xf, info, i, added, p)
@@ -1314,18 +1306,9 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
// 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 && {
let generation = doc.edit_generation();
let images = match view.edit_images.get(&i) {
Some((g, l)) if *g == generation => l.clone(),
_ => {
let l = doc.page_images(i);
view.edit_images.insert(i, (generation, l.clone()));
l
}
};
// 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 && !image_owns_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,
@@ -1333,7 +1316,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
ptr,
page: i,
info,
allow_interaction: !others_own_pointer && (image_owns_pointer || !text_used),
allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used),
};
let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view, &mut frame.out.image_action);
!others_own_pointer && (text_used || image_used)
@@ -1353,13 +1336,122 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
if frame.env.unobstructed
&& let Some(p) = pointer
{
for h in hits(view, p).collect::<Vec<_>>() {
for h in hits.at(view, p) {
view.cursor.claim_with_tip(h.layer, h.icon, h.tip);
}
}
PageInput { consumed, area_tool }
}
/// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged.
fn edit_inventory(
view: &mut DocView,
doc: &pdfcraft_engine::Document,
i: usize,
) -> (Vec<pdfcraft_engine::TextBlock>, Vec<pdfcraft_engine::PageImage>) {
let generation = doc.edit_generation();
let lines = match view.edit_lines.get(&i) {
Some((g, l)) if *g == generation => l.clone(),
_ => {
let l = doc.text_blocks(i);
view.edit_lines.insert(i, (generation, l.clone()));
l
}
};
let images = match view.edit_images.get(&i) {
Some((g, l)) if *g == generation => l.clone(),
_ => {
let l = doc.page_images(i);
view.edit_images.insert(i, (generation, l.clone()));
l
}
};
(lines, images)
}
/// What the page modules have under a point on one page, for the page router.
struct PageHits<'a> {
page: usize,
xf: &'a PageXform,
info: &'a DocInfo,
tool: QuickTool,
pcx: &'a comments::PageCx<'a>,
form: &'a [pdfcraft_engine::FormField],
can_fill: bool,
can_modify: bool,
/// Edit a PDF is open: added text and pictures can be picked up.
editing_content: bool,
preparing: bool,
field_tool: Option<crate::prepare::FieldTool>,
/// Prepare may draw and move fields (not while a table is being set up).
prepare_allowed: bool,
added: &'a [pdfcraft_engine::Added],
lines: &'a [pdfcraft_engine::TextBlock],
images: &'a [pdfcraft_engine::PageImage],
/// The pages' visible area (the picture's rotate knob stays inside it).
clip: Rect,
}
impl PageHits<'_> {
/// 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; then form fields
/// (Prepare's, being moved, over the comments; filled ones under added content and the
/// comments that move).
fn at(&self, view: &DocView, p: Pos2) -> Vec<crate::page_router::Hit> {
let PageHits {
page,
xf,
info,
tool,
pcx,
form,
can_fill,
can_modify,
editing_content,
preparing,
field_tool,
prepare_allowed,
added,
lines,
images,
clip,
} = *self;
let fields = if preparing {
crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p)
} else if tool == QuickTool::Select {
crate::forms_ui::hit(xf, info, page, form, can_fill, p)
} else {
None
};
// With Add text, only the paragraphs and pictures not under added content.
let editing =
can_modify && (tool == QuickTool::EditText || tool == QuickTool::AddText && !crate::content_ui::hits_added(xf, info, page, added, p));
let (pictures, paragraphs, objects, grips) = if editing {
let open = view.line_editor.as_ref().filter(|e| e.page == page);
let frame = open
.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));
(
crate::edit_text_ui::picture_hit(xf, page, info, images, view, p),
crate::edit_text_ui::hit(xf, info, page, lines, open.map(|e| e.block), p),
(tool == QuickTool::EditText).then(|| crate::object_ui::hit(view, xf, info, page, frame, p)).flatten(),
crate::edit_text_ui::picture_handle_hit(clip, xf, page, info, images, view, p),
)
} else {
(None, None, None, None)
};
// Added text and pictures, over the page's own paragraphs and under the objects selected
// together.
let added = (editing_content && can_modify).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten();
[link_hit(info, xf, page, p), comments::hit(pcx, view, p), pictures, paragraphs, added, objects, grips, fields]
.into_iter()
.flatten()
.collect()
}
}
/// Text layer: find matches, selection, I-beam and drag-to-select.
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;
+40
View File
@@ -26,6 +26,46 @@ pub(crate) enum Owner {
PrepareField { field: usize, widget: usize },
/// Prepare Form: a corner handle of the selected field, to resize it.
PrepareCorner { left: bool, top: bool },
/// Edit text: a paragraph, by its index among the page's, and what of it a press takes.
Paragraph { block: usize, part: ParagraphPart },
/// Edit text: a picture, by its index among the page's, and what of it a press takes.
Picture { index: usize, part: PicturePart },
/// Edit text: the objects selected together, or the frame of the paragraph open for typing.
Objects { part: ObjectsPart },
/// Added text or a picture placed on the page, by its index among the page's, and a corner of
/// the selected one (left, top) or the item itself.
Added { index: usize, corner: Option<(bool, bool)> },
}
/// The part of a picture a hit is on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PicturePart {
/// The picture: a click selects it, a drag moves the selected one.
Body,
/// A corner of the selected picture (left top, right top, left bottom, right bottom): a drag
/// resizes it.
Corner(usize),
/// The selected picture's rotate knob: a drag turns it.
Knob,
}
/// What of the objects a hit is on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ObjectsPart {
/// The objects selected together: a drag moves them all.
Selection,
/// The frame drawn around the paragraph open for typing: a click selects the box, a drag moves it.
Frame,
}
/// The part of a paragraph a hit is on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ParagraphPart {
/// The paragraph: a drag moves it, a click opens it for typing.
Body,
/// Its left or right edge (on screen): a drag rewraps it.
Left,
Right,
}
/// The part of a comment a hit is on.
+31 -25
View File
@@ -9,7 +9,7 @@ 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};
use crate::theme::Tokens;
use crate::widgets;
@@ -330,18 +330,22 @@ fn dragged(r: Rect, grab: Grab, d: egui::Vec2, keep_aspect: bool) -> Rect {
}
}
/// Added text and images keep pointer ownership over a form field while editing content.
pub(crate) fn select_grabs(xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, added: &[Added], view: &DocView) -> bool {
let cv = &view.content;
if cv.grab.is_some_and(|(grab_page, _, _)| grab_page == page) {
return true;
/// What of the added text and pictures lies under `p` on `page`: the selected item's corners, then
/// the topmost item. Placed on the page, over a form field under it.
pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, added: &[Added], view: &DocView, p: Pos2) -> Option<Hit> {
if !xf.rect.contains(p) {
return None;
}
let Some(p) = ptr.within(xf.rect) else { return false };
on_page(added, page).into_iter().any(|(index, item)| {
let rect = screen_rect(xf, info, page, item.content.rect());
rect.expand(2.0).contains(p)
|| cv.selected == Some((page, index)) && handles(rect).into_iter().any(|(corner, _, _)| corner.distance(p) <= 6.0)
})
let items = on_page(added, page);
let selected = view.content.selected.filter(|s| s.0 == page).and_then(|(_, si)| items.iter().find(|(i, _)| *i == si));
if let Some((index, a)) = selected
&& let Some((_, left, top)) = handles(screen_rect(xf, info, page, a.content.rect())).into_iter().find(|(c, ..)| c.distance(p) <= 6.0)
{
let icon = if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw };
return Some(Hit::new(Owner::Added { index: *index, corner: Some((left, top)) }, Layer::Handle, icon));
}
let (index, _) = items.iter().rev().find(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(p))?;
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
@@ -396,7 +400,8 @@ pub(crate) fn page_input(
let corner = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(p) <= 6.0));
if adding_text && hit.is_none() {
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Text);
if resp.clicked() {
// A new box where nothing drawn over the page holds the press.
if resp.clicked() && view.router.owner(page).is_none() {
// Text that can't be drawn stays where it is being typed (#125).
if cv.hold_blocked() {
return true;
@@ -420,20 +425,21 @@ pub(crate) fn page_input(
}
return consumed;
}
if let Some((_, left, top)) = corner {
view.cursor.claim(Layer::Handle, if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw });
} else if hit.is_some() {
view.cursor.claim(Layer::Placed, egui::CursorIcon::Move);
}
// A drag on what the press was on ([`hit`]): a corner resizes the selected item, the item
// itself moves.
if resp.drag_started() {
let origin = ptr.origin.unwrap_or(p);
if let Some((_, left, top)) = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(origin) <= 6.0)) {
cv.grab = Some((page, Grab::Corner(left, top), origin));
consumed = true;
} else if let Some((i, _)) = items.iter().rev().find(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(origin)) {
cv.selected = Some((page, *i));
cv.grab = Some((page, Grab::Move, origin));
consumed = true;
match view.router.owner(page) {
Some(Owner::Added { corner: Some((left, top)), .. }) => {
cv.grab = Some((page, Grab::Corner(left, top), origin));
consumed = true;
}
Some(Owner::Added { index, corner: None }) => {
cv.selected = Some((page, index));
cv.grab = Some((page, Grab::Move, origin));
consumed = true;
}
_ => {}
}
return consumed;
}
+42 -24
View File
@@ -14,6 +14,7 @@ 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};
mod background;
mod image;
@@ -21,7 +22,7 @@ mod layout;
pub(crate) use background::TextBackground;
pub use image::{CropMode, ImageSelection};
pub(crate) use image::{ImageAction, ImageCx, image_input, image_owns_pointer};
pub(crate) use image::{ImageAction, ImageCx, body_hit as picture_hit, handle_hit as picture_handle_hit, image_input};
#[cfg(test)]
use image::{rotate_knob, turn_degrees};
#[cfg(test)]
@@ -233,8 +234,9 @@ pub struct BlockDrag {
/// How far an edge grip reaches either side of a box's edge (screen pixels).
const EDGE_REACH: f32 = 6.0;
/// How far outside an open paragraph's frame the pointer still means the frame (screen pixels).
const FRAME_REACH: f32 = 4.0;
/// How far outside an open paragraph's frame the pointer still means the frame (screen pixels):
/// the frame's Move cursor shows, and a press there takes hold of the box (`object_ui`).
pub(crate) const FRAME_REACH: f32 = 6.0;
/// The rewrap handles drawn on a paragraph box's left and right edges (screen).
fn width_handles(b: Rect) -> [Rect; 2] {
@@ -277,11 +279,25 @@ fn has_edges(info: &DocInfo, page: usize) -> bool {
info.pages.get(page).is_some_and(|p| p.rotation % 180 == 0)
}
/// Whether a press at `p` takes hold of the width handle of one of the paragraphs `rects` (user
/// space). The handle is drawn half outside the box and grabs from a little further out still,
/// where a press is not on the box itself, so area selection must leave it to [`page_input`].
pub(crate) fn grabs_edge(xf: &PageXform, info: &DocInfo, page: usize, mut rects: impl Iterator<Item = [f64; 4]>, p: Pos2) -> bool {
has_edges(info, page) && rects.any(|r| edge_at(paragraph_box(xf, info, page, r), p).is_some())
/// What of the paragraphs `lines` lies under `p` on `page`: the one hit test of Edit text's
/// paragraphs, for the cursor and the press. A closed paragraph moves when dragged (the four-way
/// arrow says so; a click still opens it for typing) and its edge rewraps it, from a little way
/// outside the box (the handle is drawn half outside it, #2276); the open one takes the text
/// cursor. `active` is the paragraph open for typing on this page.
pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, lines: &[pdfcraft_engine::TextBlock], active: Option<usize>, p: Pos2) -> Option<Hit> {
if !xf.rect.contains(p) {
return None;
}
let boxes: Vec<Rect> = lines.iter().map(|l| paragraph_box(xf, info, page, l.rect)).collect();
let (block, grip) = grab_at(&boxes, p, has_edges(info, page))?;
let paragraph = |part| Owner::Paragraph { block, part };
Some(match (active, grip) {
(None, Grip::Left) => Hit::new(paragraph(ParagraphPart::Left), Layer::Handle, egui::CursorIcon::ResizeHorizontal),
(None, Grip::Right) => Hit::new(paragraph(ParagraphPart::Right), Layer::Handle, egui::CursorIcon::ResizeHorizontal),
(None, Grip::Move) => Hit::new(paragraph(ParagraphPart::Body), Layer::Text, egui::CursorIcon::Move),
(Some(open), _) if open == block => Hit::new(paragraph(ParagraphPart::Body), Layer::Editor, egui::CursorIcon::Text),
(Some(_), _) => Hit::new(paragraph(ParagraphPart::Body), Layer::Text, egui::CursorIcon::Text),
})
}
fn user_box(xf: &PageXform, info: &DocInfo, page: usize, r: Rect) -> [f64; 4] {
@@ -416,35 +432,37 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text
return true;
}
// Dragging a box (not while a paragraph is open for typing) moves it; from an edge, resizes it.
// What it grabs comes from where the button went down, not where the pointer is now: egui
// 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)),
_ => None,
};
if resp.drag_started()
&& active.is_none()
&& let Some(o) = ptr.origin
&& let Some((block, grip)) = grab_at(&boxes, o, upright)
&& let Some((block, part)) = owner
{
let grip = match part {
ParagraphPart::Body => Grip::Move,
ParagraphPart::Left => Grip::Left,
ParagraphPart::Right => Grip::Right,
};
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 };
let Some((hit, grip)) = grab_at(&boxes, p, upright) else { return false };
if active != Some(hit) {
painter.rect_stroke(boxes[hit], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
// 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);
}
let on_edge = active.is_none() && grip != Grip::Move;
if upright && active.is_none() {
draw_handles(boxes[hit]);
}
// A closed paragraph moves when dragged (the four-way arrow says so; a click still opens it
// for typing); an edge rewraps it. The open one takes the text cursor.
if on_edge {
view.cursor.claim(Layer::Handle, egui::CursorIcon::ResizeHorizontal);
} else if active.is_none() {
view.cursor.claim(Layer::Text, egui::CursorIcon::Move);
} else {
view.cursor.claim(if active == Some(hit) { Layer::Editor } else { Layer::Text }, egui::CursorIcon::Text);
draw_handles(boxes[hovered]);
}
// A click opens the paragraph the press was on.
let Some((hit, _)) = owner else { return true };
if resp.clicked() {
view.image_selection = None;
// A click that moves the editor to another paragraph applies the open one first, so its
+57 -43
View File
@@ -1,5 +1,6 @@
use super::{ACCENT, Color32, CornerRadius, DocInfo, DocView, Edit, MAX_OUTLINES, PagePointer, PageXform, Pos2, Rect, Stroke, user_box};
use crate::page_cursor::Layer;
use crate::page_router::{Hit, Owner, PicturePart};
/// A selected page image, and what the pointer is doing to it.
#[derive(Clone, Debug, PartialEq)]
@@ -229,19 +230,45 @@ fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::Texture
clipped.add(egui::Shape::mesh(mesh));
}
/// Once a selected corner or the rotate knob is grabbed, text under it cannot take over the gesture. The same
/// applies after the pointer leaves the image/page while dragging.
pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, view: &DocView) -> bool {
let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false };
if selection.drag.is_some() {
return true;
/// The selected picture's grips under `p`: its corners and its rotate knob. They are asked after
/// the paragraphs, so text on top of a picture cannot take them; `clip` is the pages' visible
/// area, which the knob stays inside.
#[allow(clippy::too_many_arguments)]
pub(crate) fn handle_hit(
clip: Rect,
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 && s.crop.is_none())?;
let b = images.get(s.index)?.visible_rect.map(|r| xf.user_rect(info, page, r.map(|v| v as f32)))?;
let picture = |part| Owner::Picture { index: s.index, part };
if rotate_knob(b, clip).0.distance(p) < 8.0 {
return Some(Hit::new(picture(PicturePart::Knob), Layer::Handle, egui::CursorIcon::Grab));
}
let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false };
let Some(rect) = image.visible_rect else { return false };
let b = xf.user_rect(info, page, rect.map(|v| v as f32));
let p = if ptr.primary_down { ptr.origin } else { ptr.pos };
let knob = rotate_knob(b, ui.clip_rect()).0;
p.is_some_and(|p| [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom(), knob].iter().any(|c| c.distance(p) < 8.0))
let corner = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()].iter().position(|c| c.distance(p) < 8.0)?;
let icon = if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw };
Some(Hit::new(picture(PicturePart::Corner(corner)), Layer::Handle, icon))
}
/// 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.
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 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))
}
/// What a right-click on a selected image asks for.
@@ -508,37 +535,28 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
draw_handles(painter, b);
}
if allow_interaction && let Some(corner) = hover_pos.and_then(|p| corners.iter().position(|c| c.distance(p) < 8.0)) {
view.cursor.claim(Layer::Handle, if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw });
}
// The rotate handle: a knob above the top edge, joined to it by a short stem.
let (knob, stem) = rotate_knob(b, ui.clip_rect());
if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) {
painter.line_segment([stem, knob], Stroke::new(1.0, ACCENT));
painter.circle(knob, 5.0, Color32::WHITE, Stroke::new(1.0, ACCENT));
if allow_interaction && hover_pos.is_some_and(|p| p.distance(knob) < 8.0) {
view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab);
}
}
// A drag on what the press was on ([`handle_hit`], [`body_hit`]): the knob turns the
// picture, a corner resizes it from the opposite one, the picture itself moves.
if allow_interaction
&& resp.drag_started()
&& let Some(o) = ptr.origin
&& o.distance(knob) < 8.0
&& let Some(Owner::Picture { index, part }) = view.router.owner(page)
&& index == i
&& let Some(s) = view.image_selection.as_mut()
{
if let Some(s) = view.image_selection.as_mut() {
s.drag = Some((o, None));
s.turning = true;
}
} else if allow_interaction
&& resp.drag_started()
&& let Some(o) = ptr.origin
{
let corner = corners.iter().position(|c| c.distance(o) < 8.0);
if corner.is_some() || image_contains(i, o) {
let opposite = corner.map(|k| corners[3 - k]);
if let Some(s) = view.image_selection.as_mut() {
s.drag = Some((o, opposite));
match part {
PicturePart::Knob => {
s.drag = Some((o, None));
s.turning = true;
}
PicturePart::Corner(k) => s.drag = Some((o, 3usize.checked_sub(k).and_then(|opposite| corners.get(opposite)).copied())),
PicturePart::Body => s.drag = Some((o, None)),
}
}
if view.image_selection.as_ref().is_some_and(|s| s.turning) {
@@ -584,17 +602,13 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
if selected != Some(hit) {
painter.rect_stroke(boxes[hit], CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside);
}
let Some(b) = boxes.get(hit).copied() else { return false };
let corner = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()].iter().position(|c| c.distance(p) < 8.0);
match (selected == Some(hit), corner) {
(true, Some(0 | 3)) => view.cursor.claim(Layer::Handle, egui::CursorIcon::ResizeNwSe),
(true, Some(_)) => view.cursor.claim(Layer::Handle, egui::CursorIcon::ResizeNeSw),
(true, None) => view.cursor.claim(Layer::Placed, egui::CursorIcon::Move),
// A click selects it.
_ => view.cursor.claim(Layer::Placed, egui::CursorIcon::PointingHand),
}
if resp.clicked() || resp.secondary_clicked() {
view.image_selection = Some(ImageSelection { page, index: hit, drag: None, crop: None, turning: false, settled: None });
// A click selects the picture the press was on; a right-click the one under the pointer.
let clicked = match view.router.owner(page) {
Some(Owner::Picture { index, .. }) if resp.clicked() => Some(index),
_ => resp.secondary_clicked().then_some(hit),
};
if let Some(index) = clicked {
view.image_selection = Some(ImageSelection { page, index, drag: None, crop: None, turning: false, settled: None });
}
if resp.secondary_clicked() {
ui.ctx().data_mut(|d| d.insert_temp(menu_key, true));
+27 -27
View File
@@ -9,6 +9,7 @@ use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::page_router::{Hit, ObjectsPart, Owner};
const BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
@@ -161,6 +162,19 @@ fn leave_single(view: &mut DocView) {
view.block_drag = None;
}
/// What of the objects lies under `p` on `page`: the objects selected together (a drag moves
/// them), or the frame around the paragraph open for typing (`open`: its box on screen), which
/// 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> {
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)
{
return Some(Hit::new(Owner::Objects { part: ObjectsPart::Selection }, Layer::Placed, egui::CursorIcon::Move));
}
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 {
@@ -246,14 +260,7 @@ fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) -
return s.page == Some(page) && !s.selected.is_empty();
};
if !pressed {
if s.page == Some(page) && !s.selected.is_empty() {
let union = s.selected.iter().fold(Rect::NOTHING, |r, o| r.union(screen(xf, info, page, o)));
if union.contains(p) {
view.cursor.claim(Layer::Placed, egui::CursorIcon::Move);
}
return true;
}
return false;
return s.page == Some(page) && !s.selected.is_empty();
}
let objects = match s.inventory(doc, page) {
Ok(objects) => objects,
@@ -296,18 +303,16 @@ fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) -
view.current = page;
return true;
}
if s.page == Some(page) && !s.selected.is_empty() {
let union = s.selected.iter().fold(Rect::NOTHING, |r, o| r.union(screen(xf, info, page, o)));
if union.contains(p) && !resp.double_clicked() {
s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false });
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;
}
// The frame of the paragraph open for typing takes hold of the box, as in Acrobat: a click
// selects it (for the arrow keys), a drag moves it. Its text keeps the caret.
if let Some(block) = view.line_editor.as_ref().filter(|e| e.page == page && !e.has_changes()).map(|e| e.block)
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 })
&& on_frame(screen(xf, info, page, object), p)
{
s.clear();
s.page = Some(page);
@@ -318,11 +323,9 @@ fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) -
return true;
}
s.clear();
// A closed paragraph's width handle reaches past its box, and a press there rewraps the
// paragraph (as in `edit_text_ui`) instead of starting an area selection (#2276).
let on_edge = view.line_editor.as_ref().is_none_or(|e| e.page != page)
&& crate::edit_text_ui::grabs_edge(xf, info, page, objects.iter().filter(|o| o.target.kind == ObjectKind::Text).map(|o| o.rect), p);
if hit.is_some() || on_edge {
// 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() {
return false;
}
leave_single(view);
@@ -335,12 +338,9 @@ fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) -
/// Whether `p` is on the frame drawn around a paragraph open for typing (`r`, screen): just
/// outside the text, where a press would otherwise close the paragraph.
fn on_frame(r: Rect, p: Pos2) -> bool {
r.expand(2.0 + FRAME_REACH).contains(p) && !r.expand(2.0).contains(p)
r.expand(2.0 + crate::edit_text_ui::FRAME_REACH).contains(p) && !r.expand(2.0).contains(p)
}
/// How far outside a paragraph's frame a press still grabs it (screen pixels).
const FRAME_REACH: f32 = 6.0;
/// The arrow keys move the selected image or objects a point, Shift and an arrow ten points,
/// as in Acrobat. They are taken before the view turns the page with them.
pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, doc: &Document) {
+23
View File
@@ -4334,6 +4334,29 @@ fn an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys() {
assert_eq!(h.state().session.get(id).unwrap().text_lines(0).len(), 1, "moved, not retyped");
}
/// The open paragraph's frame shows the Move cursor as far out as a press there takes hold of the
/// box: the cursor reached 4 px past the frame, the press 6.
#[test]
fn an_open_paragraphs_frame_shows_move_wherever_a_press_takes_the_box() {
let mut h = harness(2, |_| {});
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().text_blocks(0)[0].clone();
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let k = r.width() / 200.0;
let screen = |x: f64, y: f64| egui::pos2(r.left() + x as f32 * k, r.top() + (300.0 - y as f32) * k);
click_at(&mut h, screen((before.rect[0] + before.rect[2]) / 2.0, (before.rect[1] + before.rect[3]) / 2.0));
assert!(h.state().views[0].line_editor.is_some(), "a click on the text opens it for typing");
// 7 px above the text: 5 px past the frame drawn 2 px out.
let far = screen((before.rect[0] + before.rect[2]) / 2.0, before.rect[3]) - egui::vec2(0.0, 7.0);
h.hover_at(far);
h.run_steps(3);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move, "the frame offers to move the box there");
click_at(&mut h, far);
assert!(h.state().views[0].line_editor.is_none(), "and a click there selects the box");
}
/// Contributor-original text, with an off-origin CropBox. Keep the moving paragraph last in
/// source order so an ordinary hit picks it again when two independent boxes fully overlap.
fn paragraph_drag_fixture(content: &str, rotation: i64, unit: i64) -> Vec<u8> {
-1
View File
@@ -405,7 +405,6 @@ fn narrow_from_left(dx: f32) -> (egui::CursorIcon, usize) {
}
#[test]
#[ignore = "step 3 of the page pointer router: one hit test"]
fn a_paragraph_edge_resizes_wherever_it_shows_the_resize_cursor() {
let (inside_cursor, inside_lines) = narrow_from_left(1.0);
assert_eq!(inside_cursor, egui::CursorIcon::ResizeHorizontal);