From a4c5fbed64425ce7811ec62bb67d1627330d8e13 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 29 Oct 2026 12:50:01 +0100 Subject: [PATCH] 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 --- crates/ui-egui/src/canvas.rs | 176 +++++++++++++----- crates/ui-egui/src/canvas/page_router.rs | 40 ++++ crates/ui-egui/src/tools/content_ui.rs | 56 +++--- crates/ui-egui/src/tools/edit_text_ui.rs | 66 ++++--- .../ui-egui/src/tools/edit_text_ui/image.rs | 100 +++++----- crates/ui-egui/src/tools/object_ui.rs | 54 +++--- crates/ui-egui/tests/it/editing.rs | 23 +++ crates/ui-egui/tests/it/pointer.rs | 1 - 8 files changed, 354 insertions(+), 162 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4ec1ea6f4e..b96364cb56 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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::>(); + 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::>() { + 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, Vec) { + 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, + /// 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 { + 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; diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index c5751cdf14..06d3c48568 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -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. diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs index a0d9cefa13..fdc6e3f98b 100644 --- a/crates/ui-egui/src/tools/content_ui.rs +++ b/crates/ui-egui/src/tools/content_ui.rs @@ -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 { + 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; } diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 224bc3f545..4ea80b1d6b 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -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, 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, p: Pos2) -> Option { + if !xf.rect.contains(p) { + return None; + } + let boxes: Vec = 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 diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index d9dbb59fa3..6956e656af 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -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 { + 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 { + 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)); diff --git a/crates/ui-egui/src/tools/object_ui.rs b/crates/ui-egui/src/tools/object_ui.rs index 96cc0adf25..db3982cd28 100644 --- a/crates/ui-egui/src/tools/object_ui.rs +++ b/crates/ui-egui/src/tools/object_ui.rs @@ -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, p: Pos2) -> Option { + 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) { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 0d44f91067..f247eb59b0 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -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 { diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index b110041986..397d6316f8 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -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);