From 94d3e628b0ce07f6239e59a9409d760963b21ffc Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:02:45 +0800 Subject: [PATCH] M3: full screen clicks on fields and notes, Shift+Ctrl first and last page, no redraw while a page renders - A click on a link, form field or comment turns no page in full screen: the pointer stays active to click links and open notes (Adobe Acrobat Help, 2019, p. 65). - Shift+Ctrl+Page Up / Up Arrow go to the first page and Shift+Ctrl+Page Down / Down Arrow to the last, as Home and End (p. 83). - A transition waiting for its incoming page looks again every 50 ms rather than asking for every frame; the render cache wakes the window. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/canvas.rs | 20 ++++-- crates/ui-egui/src/canvas/navigation.rs | 15 ++-- crates/ui-egui/src/canvas/page_cursor.rs | 10 +++ crates/ui-egui/src/canvas/presenter.rs | 6 +- crates/ui-egui/src/canvas/tests.rs | 35 ++++++++++ crates/ui-egui/tests/it/view.rs | 87 ++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 412ba11a58..90464d90d6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -911,6 +911,9 @@ struct PageOut { open_signature: bool, open_table: Option<(usize, [f64; 4])>, primary_down: bool, + /// The pointer is over a link, comment, field or what is placed on the page: a click there is + /// for it, and turns no page in full screen. + on_object: bool, refused: Option, stamp_placed: bool, } @@ -2082,6 +2085,7 @@ fn draw_pages( open_signature, open_table, primary_down: _, + on_object: _, refused, stamp_placed, } = page_out; @@ -2097,6 +2101,8 @@ fn draw_pages( let now = ui.input(|i| i.time); hide_pointer = presenter::frame(view, info, painter, ui.ctx(), now, animates, pointer); } + // Read before the claims are spent below, and before the pointer hides, which claims the top layer. + let on_object = view.cursor.over_object(tool); // A still pointer hides over a link too, and its tip with it: the top layer, which every tool reaches. if hide_pointer { view.cursor.claim(Layer::Drag, egui::CursorIcon::None); @@ -2269,6 +2275,7 @@ fn draw_pages( open_signature, open_table, primary_down: ptr.primary_down, + on_object, refused, stamp_placed, }; @@ -2770,12 +2777,14 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, prim /// Full screen: the click turns the page unless it did something else first. It waits out the /// double-click time: turning on the first click's release sent a double-click's second click, /// meant to select a word, to the next page (review on #2070). A selection cancels it, and so do a -/// link followed meanwhile and leaving full screen; a double-click elsewhere still turns once. +/// click on a link, field or comment (`clicked_object`) and leaving full screen: the pointer stays +/// active "so that you can click links and open notes" (*Adobe Acrobat Help*, 2019, p. 65), and +/// fields to fill. A double-click elsewhere still turns once. /// `click` is `Some(back)` on the frame of a click, `back` when it was a shift-click. -fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option, link_followed: bool) { +fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option, clicked_object: bool) { let now = ui.input(|i| i.time); if let Some(back) = click - && !link_followed + && !clicked_object && view.selection.is_none() && view.clicks < 2 { @@ -2783,7 +2792,7 @@ fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option= due { view.present_turn = None; @@ -3281,6 +3290,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { open_signature, open_table, primary_down, + on_object, mut refused, stamp_placed, } = page_out; @@ -3354,7 +3364,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { Some(comments::CanvasAction::CopyImageFile(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::CopyFile(p, i)), None => {} } - schedule_presentation_turn(ui, view, present, clicked_link.is_some()); + schedule_presentation_turn(ui, view, present, clicked_link.is_some() || on_object && present.is_some()); let remote_open = follow_link(app, index, clicked_link); if tool == QuickTool::Crop && cropped { tool = QuickTool::Select; diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index ae716c8d3c..24f358bc6b 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -391,18 +391,26 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s view.find = None; } } - if key(Key::Home) { + // First and last page: Home and End, or Shift+Ctrl+Page Up / Up Arrow and Shift+Ctrl+Page Down / + // Down Arrow (*Adobe Acrobat Help*, 2019, "Keys for navigating a PDF", p. 83). These take the + // key from the page turning and scrolling below. + let (command, shift) = ctx.input(|i| (i.modifiers.command, i.modifiers.shift)); + let to_end = command && shift && (key(Key::PageDown) || key(Key::ArrowDown)); + let to_start = command && shift && (key(Key::PageUp) || key(Key::ArrowUp)); + if key(Key::Home) || to_start { view.go_to_page(0); } - if key(Key::End) { + if key(Key::End) || to_end { view.go_to_page(usize::MAX); } + if to_end || to_start { + return; + } // Full screen: Space and Enter go forward a page, and Shift+Enter and Backspace back // (*Adobe Acrobat Help*, 2019, "Read a document in Full Screen mode", p. 66: "To go to the // previous page, press Shift+Enter"). The arrows and Page Up / Page Down already turn pages // where nothing scrolls, below. if view.presentation.is_some() { - let shift = ctx.input(|i| i.modifiers.shift); if key(Key::Space) || (key(Key::Enter) && !shift) { view.step_page(true); } @@ -412,7 +420,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s } // As in Acrobat: → / ← go to the next / previous page in every layout (#185), and so do // ⌘Page Down / ⌘Page Up, or plain Page Down / Page Up in single-page view. - let command = ctx.input(|i| i.modifiers.command); let scrolls = view.continuous; if key(Key::ArrowRight) || (key(Key::PageDown) && (command || !scrolls)) { view.step_page(true); diff --git a/crates/ui-egui/src/canvas/page_cursor.rs b/crates/ui-egui/src/canvas/page_cursor.rs index e40dc1c4f3..b820f74d77 100644 --- a/crates/ui-egui/src/canvas/page_cursor.rs +++ b/crates/ui-egui/src/canvas/page_cursor.rs @@ -109,6 +109,16 @@ impl PageCursor { } } + /// Whether the pointer is over something a press with `tool` acts on in its own right: a + /// link, a comment, a field, or what is placed on the page and its grips and editors. Not the + /// page and its text, nor a drag, whose layer also hides the pointer in full screen. + pub(crate) fn over_object(&self, tool: QuickTool) -> bool { + LAYERS + .into_iter() + .zip(&self.claims) + .any(|(layer, claim)| claim.is_some() && (Layer::Link..Layer::Drag).contains(&layer) && layer.reached_by(tool)) + } + /// The claim on the highest layer `tool` reaches. fn winner(claims: [Option; LAYERS.len()], tool: QuickTool) -> Option { LAYERS.into_iter().zip(claims).rev().find_map(|(layer, claim)| claim.filter(|_| layer.reached_by(tool))) diff --git a/crates/ui-egui/src/canvas/presenter.rs b/crates/ui-egui/src/canvas/presenter.rs index dd43132731..3ecd16e882 100644 --- a/crates/ui-egui/src/canvas/presenter.rs +++ b/crates/ui-egui/src/canvas/presenter.rs @@ -158,8 +158,12 @@ pub(super) fn frame( paint(painter, &turn.from, from_rect, &f.outgoing); if let Some(tex) = &incoming { paint(painter, tex, to_rect, &f.incoming); + ctx.request_repaint(); + } else { + // Nothing moves until the page arrives, and the render cache wakes the window + // while pages are queued: look again now and then rather than every frame. + ctx.request_repaint_after(std::time::Duration::from_millis(50)); } - ctx.request_repaint(); } } } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index b2e5974f08..0610439118 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2046,6 +2046,41 @@ fn a_transition_into_a_page_that_failed_to_render_ends_at_once() { assert!(!v.presenting_a_transition(), "no wait for a page that won't render"); } +/// While the incoming page is still rendering, the presenter holds the page before without asking +/// for a frame after frame: the render cache wakes the window while pages are queued. +#[test] +fn a_transition_waiting_for_its_page_does_not_redraw_every_frame() { + use pdfcraft_render::{PageTransition, TransitionStyle}; + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let wipe = PageTransition { + page: 1, + style: Some(TransitionStyle::Wipe), + duration: 1.0, + horizontal: false, + inward: false, + direction: Some(0), + scale: 1.0, + opaque: false, + display: None, + }; + let info = DocInfo { pages: vec![page.clone(), page], transitions: vec![wipe], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::Single, continuous: false, ..Default::default() }); + v.enter_presentation(); + let ctx = egui::Context::default(); + let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + v.pages.insert(0, super::cache::PageTex { tag: 0, tex }); + presenter_frame(&ctx, &mut v, &info, 0.0); + v.current = 1; + v.screen_rects = vec![(1, Rect::from_min_size(Pos2::ZERO, egui::vec2(300.0, 400.0)))]; + // egui asks for its first frames itself: past them, only the presenter asks. + for now in [0.1, 0.2, 0.3] { + presenter_frame(&ctx, &mut v, &info, now); + } + let again = presenter_frame(&ctx, &mut v, &info, 0.4); + assert!(v.presenting_a_transition(), "still waiting for page 2"); + assert!(!again, "without asking for a frame after frame"); +} + /// A display time on the first page of the last two-page spread can't advance (there is no spread /// after it): the presenter then stays on it without asking for a frame after frame. #[test] diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 815ce284d3..5f6ed64783 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2514,3 +2514,90 @@ fn full_screen_hides_a_still_pointer_over_a_link() { h.run_steps(16); assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "still for over three seconds"); } + +/// A text field on page 1 of three. +const FIELD_PAGES: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [6 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R >> endobj +5 0 obj << /Type /Page /Parent 2 0 R >> endobj +6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [50 100 250 140] /P 3 0 R /F 4 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// Full screen keeps the pointer active "so that you can click links and open notes" (*Adobe +/// Acrobat Help*, 2019, p. 65): a click on a form field opens it for typing rather than turning +/// the page, and a click beside it still turns. +#[test] +fn full_screen_click_on_a_field_fills_it_and_turns_no_page() { + let mut h = harness(&[]); + h.state_mut().open_bytes("field.pdf", None, FIELD_PAGES.to_vec()).expect("opens"); + h.run_steps(8); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + let n = h.state().views.len() - 1; + let r = h.state().views[n].page_screen_rect(0).expect("page 1 on screen"); + let on_field = egui::pos2(r.left() + r.width() * 0.5, r.top() + r.height() * (400.0 - 120.0) / 400.0); + click_with(&mut h, on_field, Modifiers::NONE); + h.run_steps(8); + assert_eq!(h.state().views[n].current, 0, "no page turned"); + assert!(h.ctx.egui_wants_keyboard_input(), "the field is open for typing"); + click_with(&mut h, egui::pos2(r.center().x, r.top() + 20.0), Modifiers::NONE); + h.run_steps(4); + assert_eq!(h.state().views[n].current, 1, "a click beside the field turns the page"); +} + +/// Shift+Ctrl+Page Down or Down Arrow goes to the last page, and Shift+Ctrl+Page Up or Up Arrow +/// to the first, as Home and End do (*Adobe Acrobat Help*, 2019, "Keys for navigating a PDF", +/// p. 83), in full screen and out of it. +#[test] +fn shift_ctrl_page_keys_and_arrows_go_to_the_first_and_last_page() { + for full_screen in [false, true] { + let mut h = harness(&[("layout", "single")]); + if full_screen { + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + } + let both = Modifiers::COMMAND | Modifiers::SHIFT; + for (k, want) in [(Key::PageDown, 2), (Key::PageUp, 0), (Key::ArrowDown, 2), (Key::ArrowUp, 0)] { + h.key_press_modifiers(both, k); + h.run_steps(6); + assert_eq!(h.state().views[0].current, want, "{k:?}, full screen {full_screen}"); + } + } +} + +/// In full screen a double-click on a note opens it to edit, and turns no page: the pointer stays +/// active "so that you can click links and open notes" (*Adobe Acrobat Help*, 2019, p. 65). +#[test] +fn full_screen_double_click_on_a_note_opens_it_and_turns_no_page() { + const NOTE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [100 100 124 124] /Contents (Hello) /T (A) /F 4 >> endobj +5 0 obj << /Type /Page /Parent 2 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut h = harness_stepping(1.0 / 60.0, &[]); + h.state_mut().open_bytes("note.pdf", None, NOTE.to_vec()).expect("opens"); + h.run_steps(8); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + let n = h.state().views.len() - 1; + let r = h.state().views[n].page_screen_rect(0).expect("page 1 on screen"); + let p = egui::pos2(r.left() + r.width() * 112.0 / 300.0, r.top() + r.height() * (400.0 - 112.0) / 400.0); + h.hover_at(p); + h.run_steps(2); + for _ in 0..2 { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + h.step(); + } + // Past the double-click time, when a turn would have come. + h.run_steps(30); + assert_eq!(h.state().views[n].current, 0, "no page turned"); + assert!(h.ctx.egui_wants_keyboard_input(), "the note is open to edit"); +}