diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0acab64a08..423f3bbaab 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3148,7 +3148,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let column = tool == QuickTool::ColumnSelect || ui.input(|inp| inp.modifiers.alt); // A link or a form field under the pointer keeps its own cursor: a check box // drawn with a glyph, or set on printed text, shows the hand, not the I-beam. - let cursor_free = !over_link && !over_field; + // So do the mode bar and side panels over a page scrolled under them (#2755), + // unless a selection is being dragged there. + let cursor_free = !over_link && !over_field && (resp.contains_pointer() || resp.dragged()); if column && cursor_free { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } else if over_text && cursor_free { diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index d8881ba47d..57cdb537c8 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -1081,14 +1081,18 @@ pub(crate) fn page_input( 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. - ui.ctx().set_cursor_icon(if on_edge { - egui::CursorIcon::ResizeHorizontal - } else if active.is_none() { - egui::CursorIcon::Move - } else { - egui::CursorIcon::Text - }); + // for typing); an edge rewraps it. The open one takes the text cursor. Only where the page + // shows, so the mode bar and side panels over a paragraph scrolled under them keep their own + // cursor (#2755). + if resp.contains_pointer() { + ui.ctx().set_cursor_icon(if on_edge { + egui::CursorIcon::ResizeHorizontal + } else if active.is_none() { + egui::CursorIcon::Move + } else { + egui::CursorIcon::Text + }); + } if resp.clicked() { view.image_selection = None; // A click that moves the editor to another paragraph applies the open one first, so its @@ -1313,6 +1317,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - .as_ref() .is_some_and(|ed| view.text_background.ready(ed.page, ed.block) && !ed.source.as_ref().is_some_and(|(b, _)| b.invisible)); let draft_rect = view.text_background.draft_rect.map(|r| xf.user_rect(info, page, r.map(|v| v as f32))); + let viewport = view.viewport_rect(); let ed = view.line_editor.as_mut()?; if hold { ed.focus = true; @@ -1332,9 +1337,15 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - let font = editor_font(&ed.look, ed.size); let text_color = color32(ed.look.color); let mut done = None; - egui::Area::new(egui::Id::new("edit-text-line")).order(egui::Order::Foreground).fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())).show( - ctx, - |ui| { + // Over the page, so only where the document shows: a paragraph scrolled under the mode bar or + // a side panel must leave them their pointer (#2755). It stays at its paragraph, cut off as + // the page is, rather than pushed back into the window. + egui::Area::new(egui::Id::new("edit-text-line")) + .order(egui::Order::Foreground) + .fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())) + .constrain_to(viewport) + .constrain(false) + .show(ctx, |ui| { // The editor opens at the source size. Once text changes, show the real draft // extent: single rows grow toward the page edge, paragraphs rewrap at their // source width, and the visible frame grows/shrinks vertically with their rows. @@ -1358,6 +1369,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - // On the frame, a press takes hold of the box (a click selects it, a drag moves it): // the pointer says so there, and reads as text inside. if let Some(p) = ui.input(|i| i.pointer.hover_pos()) + && ui.clip_rect().contains(p) && live.expand(2.0 + FRAME_REACH).contains(p) && !live.expand(2.0).contains(p) { @@ -1412,8 +1424,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - done = Some(true); } }); - }, - ); + }); match done { Some(apply) => { let ed = view.line_editor.take()?; diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index f8db525751..8177842fd0 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -4673,3 +4673,100 @@ fn leaving_organize_goes_back_to_the_workspace_panel() { h.run_steps(3); assert_eq!(h.state().left, LeftPanel::AllTools); } + +/// Where a mode bar tab is, by its label. +fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { + h.get_all_by_role_and_label(Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab") +} + +/// The cursor over each mode bar tab that page 1's text lies under, once that text is scrolled +/// up under the bar. With the All tools panel closed it lies under Read, Edit, Convert and E-Sign. +fn mode_tab_cursors(h: &mut Harness<'static, PdfCraftApp>, text: egui::Rect) -> [(&'static str, egui::CursorIcon); 4] { + h.state_mut().views[0].goto = Some((0, 0.5)); + h.run_steps(4); + let offset = h.state().views[0].page_screen_rect(0).expect("on screen").top(); + ["Read", "Edit", "Convert", "E-Sign"].map(|label| { + let at = mode_tab(h, label).center(); + assert!(text.translate(egui::vec2(0.0, offset)).contains(at), "the text lies under {label}"); + h.hover_at(at); + h.run_steps(2); + (label, h.output().platform_output.cursor_icon) + }) +} + +const HAND_ON_EVERY_TAB: [(&str, egui::CursorIcon); 4] = [ + ("Read", egui::CursorIcon::PointingHand), + ("Edit", egui::CursorIcon::PointingHand), + ("Convert", egui::CursorIcon::PointingHand), + ("E-Sign", egui::CursorIcon::PointingHand), +]; + +/// Page 1's text block on screen, relative to the page's top edge, in the window as it is now. +fn page_text(h: &Harness<'static, PdfCraftApp>) -> egui::Rect { + let id = h.state().views[0].id; + let block = 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 at = |x: f64, y: f64| egui::pos2(r.left() + x as f32 * k, (300.0 - y as f32) * k); + egui::Rect::from_two_pos(at(block.rect[0], block.rect[1]), at(block.rect[2], block.rect[3])) +} + +/// Text scrolled up under the mode bar leaves its tabs the hand (#2755). The Select tool's +/// text layer showed its I-beam there, over chrome that hides the page. +#[test] +fn mode_tabs_keep_the_hand_over_page_text_scrolled_under_them() { + let mut h = harness(2, |app| app.set_option("left", "closed").unwrap()); + let text = page_text(&h); + // Where the page shows, its text takes the I-beam, once the page's text has arrived. + h.state_mut().views[0].goto = Some((0, 0.3)); + h.run_steps(4); + let top = h.state().views[0].page_screen_rect(0).expect("on screen").top(); + let mut cursor = egui::CursorIcon::Default; + for _ in 0..50 { + h.hover_at(text.center() + egui::vec2(0.0, top)); + h.run_steps(2); + cursor = h.output().platform_output.cursor_icon; + if cursor == egui::CursorIcon::Text { + break; + } + } + assert_eq!(cursor, egui::CursorIcon::Text); + assert_eq!(mode_tab_cursors(&mut h, text), HAND_ON_EVERY_TAB); +} + +/// Edit text, then the All tools panel closed, as reported (#2755): a paragraph scrolled up +/// under the mode bar gave its tabs the four-way arrow, or the I-beam once a paragraph was open. +/// The open paragraph's editor also covered the tabs and took their clicks. +#[test] +fn mode_tabs_keep_the_hand_over_a_paragraph_scrolled_under_them() { + let mut h = harness(2, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.state_mut().set_option("left", "closed").unwrap(); + h.run_steps(4); + let text = page_text(&h); + assert_eq!(mode_tab_cursors(&mut h, text), HAND_ON_EVERY_TAB, "a closed paragraph under the tabs"); + // Open it, where it shows. + h.state_mut().views[0].goto = Some((0, 0.3)); + h.run_steps(4); + let top = h.state().views[0].page_screen_rect(0).expect("on screen").top(); + click_at(&mut h, text.center() + egui::vec2(0.0, top)); + assert!(h.state().views[0].line_editor.is_some()); + assert_eq!(mode_tab_cursors(&mut h, text), HAND_ON_EVERY_TAB, "the open paragraph under the tabs"); + // Nor does the editor's frame, with the paragraph's top edge just under a tab (the frame + // reaches 2 to 6 points outside it). + let convert = mode_tab(&h, "Convert").center(); + h.state_mut().views[0].goto = Some((0, 0.0)); + h.run_steps(4); + let page = h.state().views[0].page_screen_rect(0).expect("on screen"); + h.state_mut().views[0].goto = Some((0, (page.top() - (convert.y + 4.0 - text.top())) / page.height())); + h.run_steps(4); + let page = h.state().views[0].page_screen_rect(0).expect("on screen"); + assert!((page.top() + text.top() - convert.y - 4.0).abs() < 1.0, "Convert lies on the paragraph's frame"); + h.hover_at(convert); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the open paragraph's frame under a tab"); + h.state_mut().views[0].goto = Some((0, 0.5)); + h.run_steps(4); + click_at(&mut h, convert); + assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the paragraph's editor"); +}