Browse / github.com/storytold/pdfcraft / patch
feat(ui-egui): flash destination indicator after following an internal link (#4787)
Missing destination indicator: no flashing rectangle after following an internal link When clicking an internal link (e.g. an index entry or cross-reference pointing to another page of the same PDF), PdfCraft correctly navigates to the target page and scrolls to the destination position. However, unlike Adobe Acrobat, it shows no visual indicator of where on the page the destination is. Acrobat briefly draws a blinking black border (rectangle) around the destination area for a few seconds after the jump, which makes it easy to spot the referenced location — especially on a dense page. Expected: after following a GoTo link within the same document, briefly flash/highlight the
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- YusufMntm (imported from pull request #4924, open)
- Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf- Size
- +169 −3 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
6e65e0e3f0f466f6a91381ab
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/src/canvas.rs crates/ui-egui/src/canvas/tests.rs crates/ui-egui/tests/it/pdf_links.rs parity/acrobat-features.toml
@@ -307,6 +307,9 @@ pub struct DocView { /// (fractions of the displayed page; `None` leaves that axis to `goto`) to put `rel` points /// from the viewport's top-left corner. goto_point: Option<(usize, Option<f32>, Option<f32>, Vec2)>,+ /// Active destination flash indicator after following an internal link:+ /// `(page, normalized_rect [x0, y0, x1, y1], start_time)`.+ pub dest_flash: Option<(usize, [f32; 4], f64)>, /// Pending keyboard scrolling, in points down (negative: up): ↓ / ↑, and Page Down / /// Page Up where the pages scroll. pub key_scroll: f32,@@ -619,6 +622,7 @@ impl DocView { forward: Vec::new(), goto: None, goto_point: None,+ dest_flash: None, key_scroll: 0.0, single_edges: None, flash: None,@@ -744,6 +748,7 @@ impl DocView { } self.goto = None; self.goto_point = None;+ self.dest_flash = None; self.zoom_anchor = None; self.flash = None; // Thumbnail drags hold page indexes from before the change.@@ -806,6 +811,60 @@ impl DocView { self.wheel_boost_x.reset(); self.wheel_boost_y.reset(); }++ /// Computes the normalized destination indicator rectangle `[x0, y0, x1, y1]` (as fractions+ /// `0.0..=1.0` of the displayed page) for `dest` on `page`, to flash after following a link.+ pub(crate) fn dest_indicator_rect(&self, page: usize, dest: DestView, info: &DocInfo) -> Option<[f32; 4]> {+ let p = info.pages.get(page)?;+ let (dw, dh) = self.display_size(p);+ let (dw, dh) = (dw.max(1.0), dh.max(1.0));+ let band_x = (24.0 / dw).clamp(0.02, 0.3);+ let band_y = (24.0 / dh).clamp(0.02, 0.3);+ let point = |x: Option<f32>, y: Option<f32>| p.dest_fraction(x, y, self.rotation);+ // The destination coordinate lands on one displayed axis only; a quarter turn swaps it.+ let band = |[fx, fy]: [Option<f32>; 2], default_vertical: bool| match (fx, fy) {+ (Some(x), _) => {+ let x1 = (x + band_x).min(1.0);+ [(x1 - band_x).max(0.0), 0.0, x1, 1.0]+ }+ (None, Some(y)) => {+ let y1 = (y + band_y).min(1.0);+ [0.0, (y1 - band_y).max(0.0), 1.0, y1]+ }+ (None, None) if default_vertical => [0.0, 0.0, band_x, 1.0],+ (None, None) => [0.0, 0.0, 1.0, band_y],+ };+ let rect = match dest {+ DestView::Top | DestView::Fit => [0.0, 0.0, 1.0, band_y],+ DestView::FitH { top } => band(point(None, top), (u32::from(p.rotation) + u32::from(self.rotation)) % 180 == 90),+ DestView::FitV { left } => band(point(left, None), (u32::from(p.rotation) + u32::from(self.rotation)).is_multiple_of(180)),+ DestView::Xyz { left, top, .. } => {+ let [fx, fy] = point(left, top);+ let (x0, x1) = match fx {+ Some(x) => {+ let x1 = (x + (120.0 / dw).clamp(0.1, 0.5)).min(1.0);+ ((x1 - band_x).min(x).max(0.0), x1)+ }+ None => (0.0, 1.0),+ };+ let (y0, y1) = match fy {+ Some(y) => {+ let y1 = (y + band_y).min(1.0);+ ((y1 - band_y).max(0.0), y1)+ }+ None => (0.0, band_y),+ };+ [x0, y0, x1, y1]+ }+ DestView::FitR { rect } => {+ let ([Some(ax), Some(ay)], [Some(bx), Some(by)]) = (point(Some(rect[0]), Some(rect[3])), point(Some(rect[2]), Some(rect[1]))) else {+ return None;+ };+ [ax.min(bx), ay.min(by), ax.max(bx), ay.max(by)]+ }+ };+ Some(rect)+ } } /// Draws the document tab at `index`: its notice bars, then its pages (or the Organize grid) in a@@ -2071,6 +2130,28 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.flash = None; } }+ if let Some((fp, [x0, y0, x1, y1], t0)) = view.dest_flash+ && fp == i+ {+ let now = ui.input(|inp| inp.time);+ let t0 = if t0 == 0.0 { now } else { t0 };+ view.dest_flash = Some((fp, [x0, y0, x1, y1], t0));+ let age = (now - t0).max(0.0);+ if age < 1.5 {+ let step = (age / 0.25) as usize;+ if step.is_multiple_of(2) {+ let sr = Rect::from_min_max(+ pos2(r.left() + x0 * r.width(), r.top() + y0 * r.height()),+ pos2(r.left() + x1 * r.width(), r.top() + y1 * r.height()),+ );+ painter.rect_stroke(sr, CornerRadius::ZERO, Stroke::new(2.0, Color32::BLACK), egui::StrokeKind::Inside);+ }+ let next = ((step + 1) as f64 * 0.25).min(1.5);+ ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((next - age).max(0.01)));+ } else {+ view.dest_flash = None;+ }+ } // Validation is viewer chrome over the signer's original artwork, which // may still say "Signature Not Verified". These badges never enter document // streams, image/text exports, vector printing or Print as Image.@@ -2519,7 +2600,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } match clicked_link {- Some(LinkTarget::Page(p, dest)) => view.go_to_dest(p, dest, info),+ Some(LinkTarget::Page(p, dest)) => {+ view.go_to_dest(p, dest, info);+ let target = view.current;+ view.dest_flash = view.dest_indicator_rect(target, dest, info).map(|r| (target, r, now));+ } Some(LinkTarget::Uri(u)) => app.request_document_url(&u, crate::LinkOrigin::Link), Some(LinkTarget::SetLayers { changes, preserve_rb }) => set_layers(app, index, &changes, preserve_rb), Some(LinkTarget::RemoteGoTo { file, destination, .. }) => remote_open = Some((view.id, file, destination)),@@ -1889,6 +1889,45 @@ fn destinations_set_the_zoom_mode_and_the_point_to_show() { assert_eq!((v.current, v.goto_point), (1, None)); } +#[test]+fn dest_indicator_rect_covers_each_destination_view() {+ let info = pdfcraft_render::DocInfo {+ pages: vec![pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }],+ ..Default::default()+ };+ let v = view(1, PageLayout::Single, true);++ let top = v.dest_indicator_rect(0, DestView::Top, &info).expect("top rect");+ assert_eq!([top[0], top[1], top[2]], [0.0, 0.0, 1.0]);+ assert!((top[3] - 24.0 / 400.0).abs() < 1e-4);++ let fit = v.dest_indicator_rect(0, DestView::Fit, &info).expect("fit rect");+ assert_eq!(fit, top);++ let fith = v.dest_indicator_rect(0, DestView::FitH { top: Some(300.0) }, &info).expect("fith rect");+ assert!((fith[0] - 0.0).abs() < 1e-4 && (fith[1] - 0.25).abs() < 1e-4 && (fith[2] - 1.0).abs() < 1e-4);++ let fitv = v.dest_indicator_rect(0, DestView::FitV { left: Some(75.0) }, &info).expect("fitv rect");+ assert!((fitv[0] - 0.25).abs() < 1e-4 && (fitv[1] - 0.0).abs() < 1e-4 && (fitv[3] - 1.0).abs() < 1e-4);++ let xyz = v.dest_indicator_rect(0, DestView::Xyz { left: Some(150.0), top: Some(200.0), zoom: None }, &info).expect("xyz rect");+ assert!((xyz[0] - 0.5).abs() < 1e-4 && (xyz[1] - 0.5).abs() < 1e-4 && xyz[2] > xyz[0] && xyz[3] > xyz[1]);++ let fitr = v.dest_indicator_rect(0, DestView::FitR { rect: [60.0, 100.0, 150.0, 300.0] }, &info).expect("fitr rect");+ assert!((fitr[0] - 0.2).abs() < 1e-4 && (fitr[1] - 0.25).abs() < 1e-4 && (fitr[2] - 0.5).abs() < 1e-4 && (fitr[3] - 0.75).abs() < 1e-4);++ // Quarter-turn rotation swaps the displayed axis for FitH and FitV.+ let mut rotated = v;+ rotated.rotation = 90;+ let fith_rot = rotated.dest_indicator_rect(0, DestView::FitH { top: Some(300.0) }, &info).expect("rotated fith rect");+ assert!((fith_rot[0] - 0.75).abs() < 1e-4 && (fith_rot[1] - 0.0).abs() < 1e-4 && (fith_rot[3] - 1.0).abs() < 1e-4);++ let fitv_rot = rotated.dest_indicator_rect(0, DestView::FitV { left: Some(75.0) }, &info).expect("rotated fitv rect");+ assert!((fitv_rot[0] - 0.0).abs() < 1e-4 && (fitv_rot[1] - 0.25).abs() < 1e-4 && (fitv_rot[2] - 1.0).abs() < 1e-4);++ assert!(rotated.dest_indicator_rect(5, DestView::Top, &info).is_none());+}+ #[test] fn page_text_failure_preserves_valid_search_match_without_jitter() { let mut v = view(3, PageLayout::Single, true);@@ -145,3 +145,45 @@ fn the_link_context_menu_shows_only_for_a_right_click_on_a_link() { assert!(links(&h).is_empty()); assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Remove all links")); }++const INTERNAL_LINK_PDF: &[u8] = b"%PDF-1.7+1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj+2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj+3 0 obj << /Type /Page /Parent 2 0 R /Annots [20 0 R] >> endobj+4 0 obj << /Type /Page /Parent 2 0 R >> endobj+20 0 obj << /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /Dest [4 0 R /FitH 300] >> endobj+trailer << /Root 1 0 R >>+%%EOF";++/// Following an internal page link activates the flashing destination indicator on the target page+/// and clears it once the animation duration elapses (#4787).+#[test]+fn following_an_internal_link_flashes_the_destination_indicator() {+ let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(|_cc| {+ let mut app = PdfCraftApp::new();+ app.set_option("language", "en").unwrap();+ app.open_bytes("internal.pdf", None, INTERNAL_LINK_PDF.to_vec()).unwrap();+ app.set_option("zoom", "100").unwrap();+ app+ });+ h.run_steps(6);+ assert!(h.state().views[0].dest_flash.is_none());++ let click_pos = at(&h, 150.0, 75.0);+ h.hover_at(click_pos);+ h.run_steps(2);+ h.drag_at(click_pos);+ h.drop_at(click_pos);+ h.run_steps(2);++ let v = &h.state().views[0];+ assert_eq!(v.current, 1, "internal link navigates to page 2");+ let (flash_page, rect, _) = v.dest_flash.expect("destination flash indicator is active after following an internal link");+ assert_eq!(flash_page, 1);+ assert!((rect[0] - 0.0).abs() < 1e-4 && (rect[2] - 1.0).abs() < 1e-4);+ assert!((rect[1] - 0.25).abs() < 1e-4 && rect[3] > rect[1]);++ // Advance past the 1.5s animation window (100 steps at 1/60s > 1.5s) and verify it clears.+ h.run_steps(100);+ assert!(h.state().views[0].dest_flash.is_none(), "destination flash indicator clears after the animation finishes");+}@@ -1190,8 +1190,8 @@ milestone = "M3" status = "shipped" commands = [] tools = ["link_open"]-evidence = ["crates/ui-egui/tests/it/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/it/view.rs::link_destinations_scroll_to_their_position", "crates/ui-egui/tests/it/view.rs::a_link_without_a_position_keeps_the_place_on_the_next_page", "crates/ui-egui/src/canvas/tests.rs::a_null_xyz_coordinate_keeps_the_readers_place_on_the_new_page", "crates/render/src/inspect/tests.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect/tests.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/it/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/it/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/it/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/it/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/it/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords", "crates/render/src/inspect/tests.rs::launch_actions_that_name_a_pdf_open_it_like_a_remote_link", "crates/automation/tests/it/automation.rs::a_launch_link_to_a_pdf_opens_it_like_a_remote_link"]-notes = "GoTo and URI links plus local-PDF GoToR links: source-relative or local absolute /F paths, /UF preferred in file-spec dictionaries, explicit numeric page/view destinations, and internal tabs. Absolute local GoToR targets require desktop Allow/Cancel confirmation or an explicit allow_absolute opt-in in link_open; relative links open normally. link_open honors --root. Named remote destinations open at default view with a warning; /NewWindow is a tab hint. A Launch action whose /F names a .pdf file (and has no /Win launch parameters) opens that PDF the same way, backslash separators read as / (#4168); URL/network file systems, GoToE, other Launch actions and other actions are not executed. Same-document destinations retain their supported /XYZ, /Fit, /FitH, /FitV and /FitR views; /FitB* is read as whole-page fit. A null /XYZ left or top keeps the reader's place, as ISO 32000-2 §12.3.2.2 says and Acrobat Reader was observed to do (synthetic fixture): the user-space point at the window's top-left stays there on the target page at the same zoom, along each axis the page overflows; CAD exports (WSCAD) write every cross-reference this way. Null /FitH top and /FitV left still go to the page edge."+evidence = ["crates/ui-egui/tests/it/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/it/view.rs::link_destinations_scroll_to_their_position", "crates/ui-egui/tests/it/view.rs::a_link_without_a_position_keeps_the_place_on_the_next_page", "crates/ui-egui/src/canvas/tests.rs::a_null_xyz_coordinate_keeps_the_readers_place_on_the_new_page", "crates/ui-egui/src/canvas/tests.rs::dest_indicator_rect_covers_each_destination_view", "crates/ui-egui/tests/it/pdf_links.rs::following_an_internal_link_flashes_the_destination_indicator", "crates/render/src/inspect/tests.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect/tests.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/it/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/it/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/it/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/it/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/it/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords", "crates/render/src/inspect/tests.rs::launch_actions_that_name_a_pdf_open_it_like_a_remote_link", "crates/automation/tests/it/automation.rs::a_launch_link_to_a_pdf_opens_it_like_a_remote_link"]+notes = "GoTo and URI links plus local-PDF GoToR links: source-relative or local absolute /F paths, /UF preferred in file-spec dictionaries, explicit numeric page/view destinations, and internal tabs. Absolute local GoToR targets require desktop Allow/Cancel confirmation or an explicit allow_absolute opt-in in link_open; relative links open normally. link_open honors --root. Named remote destinations open at default view with a warning; /NewWindow is a tab hint. A Launch action whose /F names a .pdf file (and has no /Win launch parameters) opens that PDF the same way, backslash separators read as / (#4168); URL/network file systems, GoToE, other Launch actions and other actions are not executed. Same-document destinations retain their supported /XYZ, /Fit, /FitH, /FitV and /FitR views and briefly flash a black destination indicator rectangle after following an internal link (#4787); /FitB* is read as whole-page fit. A null /XYZ left or top keeps the reader's place, as ISO 32000-2 §12.3.2.2 says and Acrobat Reader was observed to do (synthetic fixture): the user-space point at the window's top-left stays there on the target page at the same zoom, along each axis the page overflows; CAD exports (WSCAD) write every cross-reference this way. Null /FitH top and /FitV left still go to the page edge." [[feature]] id = "view.annotation-hover"