diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index be2dd97d53..d65d20051d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1604,8 +1604,6 @@ fn draw_pages( prefs, preparing, selection_wheel, - ppp, - rects, single_paging, tool, unobstructed, @@ -1674,54 +1672,9 @@ 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 keeps its menu, off the pages as on them. 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; - } } let mut frame = PageFrame { env: *env, @@ -1759,9 +1712,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, diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 7c715bed8f..97aad93100 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -37,8 +37,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, @@ -90,24 +91,31 @@ 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)) { + 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) && !resp.context_menu_opened() && !egui::Popup::is_any_open(ui.ctx()); let objects = adds.then(|| view.objects.inventory(doc, i)); let at = hits.at(view, o, objects.as_ref()); - view.router.press(i, at, tool); + // A press is the page's it lies on, or the one with something under it past its edge (the + // selected picture's rotate knob and grips). + if xf.rect.contains(o) || !at.is_empty() { + view.router.press(i, at, tool); + } } // 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; + // 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); let released_over = match press { PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner), _ => false, @@ -117,7 +125,17 @@ 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)); + let right = frame.right_click_on(xf.rect); + 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, part }) if part != crate::page_router::PicturePart::Crop(crate::page_router::CropPart::Page) => Some(index), + _ => None, + }; + crate::edit_text_ui::picture_context(ui.ctx(), 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). @@ -143,58 +161,38 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut }; crate::redact_ui::page_input(cx, over_text, press == PagePress::Free, view) }; + // 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()); let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, press, 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; - } - let on_content = !on_objects && 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 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, - press, - allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used), - }; + let on_content = 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| 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; } @@ -340,7 +338,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. @@ -351,13 +350,10 @@ fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mu 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(), }; diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index ee30ca98c6..de4e0fb9ab 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -570,21 +570,13 @@ const ROUTER_READS: [&str; 6] = ["router.press_for(", "router.owner(", "router.keeps_drag(", "PageRouter::press_for(", "PageRouter::owner(", "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); 7] = [ - ( - "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", - ), +/// page and tells each module what the press is to its page (`PagePress`); 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); 4] = [ + ("canvas/page_input.rs", "page_tool_input", "the page view: what the press is to each page (`press_for`)"), ("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)"), ]; /// What ignores the press the pages hold until the button is up. @@ -594,8 +586,8 @@ 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). +/// The module hit tests, called by path (HIT_TESTERS says who may): an entry no page module calls +/// any more fails the test. const PAGE_HIT_CALLS: [&str; 13] = [ "link_ui::hit(", "prepare::hit(", @@ -612,17 +604,6 @@ const PAGE_HIT_CALLS: [&str; 13] = [ "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::>().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 @@ -694,16 +675,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")] - { - 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::>().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( @@ -774,13 +745,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}"); - } -} diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 06e32a272e..1302901136 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -62,6 +62,9 @@ pub(crate) enum CropPart { 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, @@ -146,7 +149,8 @@ pub(crate) enum PagePress { /// 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, } @@ -183,8 +187,9 @@ impl PageRouter { 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, tool: QuickTool) { diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs index 5b5154f90a..be6b1a2734 100644 --- a/crates/ui-egui/src/tools/content_ui.rs +++ b/crates/ui-egui/src/tools/content_ui.rs @@ -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,8 @@ 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)>, undrawable: UndrawableMemo, layout: Option<(AddedText, f32, f64, [f64; 4])>, } @@ -116,6 +118,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 { @@ -372,17 +380,33 @@ 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 every tool ([`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; + if !gate { + return false; + } let mut consumed = false; let pointer = ptr.pos; let cv = &mut view.content; @@ -412,19 +436,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 +467,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 +480,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. + // 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 resp.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 +530,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 diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index c1a5a6557c..ed24d45839 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -14,7 +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}; +use crate::page_router::{Hit, Owner, PagePress, ParagraphPart}; mod background; mod image; @@ -24,7 +24,7 @@ pub(crate) use background::TextBackground; pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection, image_menu_id}; 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, - picture_keys, quad_contains, user_to_screen, + 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,8 +766,8 @@ 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() @@ -767,18 +783,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 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 c0c52001a5..de36b80f5c 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -15,7 +15,8 @@ pub(crate) struct ImageCx<'a> { pub info: &'a DocInfo, /// What the press the pages hold is to this page. pub press: PagePress, - pub allow_interaction: bool, + /// 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). @@ -281,7 +282,7 @@ pub(crate) fn crop_controls_contain( /// (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, press, 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; @@ -292,7 +293,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); // A drag takes what its press was on ([`crop_hit`]). - if allow_interaction + if gate && resp.drag_started() && let Some(o) = ptr.origin && let PagePress::Mine(Owner::Picture { part: PicturePart::Crop(part), .. }) = press @@ -300,7 +301,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e let grip = match part { CropPart::Grip(g) => Some(g), CropPart::Keep => Some(8), - CropPart::Page => None, + CropPart::Cut | CropPart::Page => None, }; if let Some(g) = grip { mode.drag = Some((g, o, mode.keep)); @@ -406,10 +407,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()); @@ -448,9 +449,9 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e view.cursor.claim(Layer::Drag, GRIPS.get(grip).map_or(egui::CursorIcon::Grabbing, |g| g.cursor_icon())); } - 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)))); @@ -575,9 +576,9 @@ pub(crate) fn handle_hit( /// 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`]), the box itself, 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 and its controls. +/// [`CROP_GRIP_REACH`]), 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 and its controls. pub(crate) fn crop_hit(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)?; let mode = s.crop.as_ref()?; @@ -589,14 +590,19 @@ pub(crate) fn crop_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pd if control { return crop(CropPart::Page, egui::CursorIcon::PointingHand); } - let Some((_, k)) = images.get(s.index).and_then(|image| crop_frames(xf, info, page, image, mode.keep)) else { - return crop(CropPart::Page, egui::CursorIcon::Default); - }; - if let Some(g) = 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())); + let Some(image) = images.get(s.index) else { return crop(CropPart::Page, egui::CursorIcon::Default) }; + if let Some((_, k)) = crop_frames(xf, info, page, image, mode.keep) { + if let Some(g) = 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 k.contains(p) { + return crop(CropPart::Keep, egui::CursorIcon::Grab); + } } - if k.contains(p) { - return crop(CropPart::Keep, egui::CursorIcon::Grab); + let shown = ImageObb::from_image(image, xf, info, page); + let whole = ImageObb::from_matrix(image.matrix, xf, info, page); + if quad_contains(shown.corners(), p) || quad_contains(whole.corners(), p) { + return crop(CropPart::Cut, egui::CursorIcon::Default); } crop(CropPart::Page, egui::CursorIcon::Default) } @@ -677,10 +683,12 @@ 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 = images.iter().map(|im| crate::canvas::image_screen_rect(im, xf, info, page)).collect(); let painter = ui.painter(); let hover_pos = ptr.pos; @@ -776,56 +784,22 @@ 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. + if gate + && resp.drag_started() + && let Some(o) = ptr.origin + && let PagePress::Mine(Owner::Picture { index, part }) = press + && index == i + && let Some(s) = view.image_selection.as_mut() + { + 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), @@ -833,11 +807,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 => { s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: b, initial_matrix: image.matrix }); } + PicturePart::Crop(_) => {} } } @@ -1002,50 +976,36 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage return true; } } - 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; + // A click selects the picture its press was on; its cursor is the hit's. + let owner = match press { + PagePress::Mine(Owner::Picture { index, .. }) => Some(index), + _ => None, }; - 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); - } - } - 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 { + if gate + && resp.clicked() + && let Some(index) = owner + { view.image_selection = Some(ImageSelection::at(page, index)); } - if resp.secondary_clicked() { - ui.ctx().data_mut(|d| d.insert_temp(menu_key, true)); + owner.is_some() +} + +/// A right-click on `page` that went to its picture `index` (on it, a grip of it, or the box kept +/// while it is cropped) selects it, keeping it as it is if it was selected, and opens the picture's +/// menu; one that went to anything else (`None`) unselects the picture selected and closes the menu. +pub(crate) fn picture_context(ctx: &egui::Context, page: usize, index: Option, 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)); + } + ctx.data_mut(|d| d.insert_temp(image_menu_id(), true)); + } + None => { + view.image_selection = None; + ctx.data_mut(|d| d.insert_temp(image_menu_id(), false)); + } } - true } /// The right-click context menu for a selected image. diff --git a/crates/ui-egui/src/tools/object_ui.rs b/crates/ui-egui/src/tools/object_ui.rs index bc6524c53a..e504aaecf1 100644 --- a/crates/ui-egui/src/tools/object_ui.rs +++ b/crates/ui-egui/src/tools/object_ui.rs @@ -48,6 +48,17 @@ impl ObjectSelection { 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) { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 5be20ce51b..f8c56ae6ab 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -7347,6 +7347,117 @@ fn a_dialog_keeps_its_delete_while_a_picture_is_selected() { 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"); +} + +/// 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] @@ -8138,6 +8249,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] { diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 50801d903f..003cf2d0fa 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1418,19 +1418,25 @@ 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_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 a Prepare field tool a right-click is the tool's, as before: a comment under it is not @@ -1591,8 +1597,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); @@ -1757,11 +1763,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 +1781,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