From 818b00d47e965ee796cc31b321d6fbb88aebd3d8 Mon Sep 17 00:00:00 2001 From: yenru0 Date: Thu, 24 Sep 2026 03:45:02 +0900 Subject: [PATCH] image_viewer: Add PDF find in document and internal link jumps Chrome-parity navigation work for the PDF viewer: - Find in document (ctrl-f): query editor, match counter, Enter/Shift+Enter and ctrl-g navigation, yellow highlights with a stronger tint on the active match, and a page jump per match. - Internal link destinations: pdfium now reports dest_page for GoTo links, so clicking a link that points inside the document jumps to that page instead of being dropped. - Space/Shift+Space scroll the viewport a screen at a time, unless the find bar has focus so the query stays typeable. --- assets/keymaps/default-linux.json | 3 + assets/keymaps/default-macos.json | 3 + assets/keymaps/default-windows.json | 3 + crates/image_viewer/src/image_viewer.rs | 983 ++++++++++++++++++++---- crates/pdf_viewer/src/cache.rs | 24 +- crates/pdf_viewer/src/pdf_viewer.rs | 4 +- crates/pdf_viewer/src/pdfium.rs | 74 +- crates/pdf_viewer/src/view.rs | 2 +- crates/pdf_viewer/src/watcher.rs | 2 +- 9 files changed, 951 insertions(+), 147 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 825912be8f..196f153682 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1626,6 +1626,9 @@ "home": "image_viewer::FirstPage", "end": "image_viewer::LastPage", "ctrl-c": "image_viewer::CopyText", + "ctrl-f": "image_viewer::FindInDocument", + "ctrl-g": "image_viewer::FindNext", + "ctrl-shift-g": "image_viewer::FindPrevious", "ctrl-shift-t": "image_viewer::ToggleTextPanel" }, }, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 7bdb257ef6..234b6c2b52 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1678,6 +1678,9 @@ "home": "image_viewer::FirstPage", "end": "image_viewer::LastPage", "cmd-c": "image_viewer::CopyText", + "cmd-f": "image_viewer::FindInDocument", + "cmd-g": "image_viewer::FindNext", + "cmd-shift-g": "image_viewer::FindPrevious", "cmd-shift-t": "image_viewer::ToggleTextPanel" }, }, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index ed7a1c584b..a6d7509d31 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1601,6 +1601,9 @@ "home": "image_viewer::FirstPage", "end": "image_viewer::LastPage", "ctrl-c": "image_viewer::CopyText", + "ctrl-f": "image_viewer::FindInDocument", + "ctrl-g": "image_viewer::FindNext", + "ctrl-shift-g": "image_viewer::FindPrevious", "ctrl-shift-t": "image_viewer::ToggleTextPanel" }, }, diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index e703b1e9f3..de8229a253 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -12,26 +12,28 @@ use file_icons::FileIcons; use gpui::{ AnyElement, App, Bounds, Context, DismissEvent, DispatchPhase, Element, ElementId, Entity, EventEmitter, FocusHandle, Focusable, Font, GlobalElementId, InspectorElementId, - InteractiveElement, IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, - MouseUpEvent, ParentElement, PinchEvent, Pixels, Point, Render, RenderImage, ScrollDelta, - ScrollWheelEvent, Style, Styled, Subscription, Task, WeakEntity, Window, actions, anchored, - canvas, checkerboard, deferred, div, img, point, px, size, + InteractiveElement, IntoElement, KeyDownEvent, LayoutId, MouseButton, MouseDownEvent, + MouseMoveEvent, MouseUpEvent, ParentElement, PinchEvent, Pixels, Point, Render, RenderImage, + ScrollDelta, ScrollWheelEvent, Style, Styled, Subscription, Task, WeakEntity, Window, actions, + anchored, canvas, checkerboard, deferred, div, img, point, px, size, }; use language::File as _; +use pdf_viewer::{ + PdfiumEngine, + cache::{CacheKey, CacheablePage, DEFAULT_MEMORY_BUDGET_BYTES, PageLruCache}, + rasterizer::RasterizerOptions, +}; use persistence::ImageViewerDb; use project::{ - ImageItem, Project, ProjectPath, git_store::GitStoreEvent, + ImageItem, Project, ProjectPath, + git_store::GitStoreEvent, image_store::{ImageItemEvent, PdfPageEntry, PdfPageInfo}, }; -use pdf_viewer::{ - cache::{CacheKey, CacheablePage, PageLruCache, DEFAULT_MEMORY_BUDGET_BYTES}, - rasterizer::RasterizerOptions, - PdfiumEngine, -}; use settings::Settings; use theme_settings::ThemeSettings; use ui::{ - ContextMenu, Divider, ScrollAxes, Scrollbars, ScrollbarStyle, Tooltip, WithScrollbar, prelude::*, + ContextMenu, Divider, ScrollAxes, ScrollbarStyle, Scrollbars, Tooltip, WithScrollbar, + prelude::*, }; use util::{ResultExt as _, paths::PathExt}; use workspace::{ @@ -68,7 +70,19 @@ actions!( /// Copy text from PDF. CopyText, /// Toggle text inspection panel. - ToggleTextPanel + ToggleTextPanel, + /// Open the find-in-document bar (PDF). + FindInDocument, + /// Jump to the next find-in-document match (PDF). + FindNext, + /// Jump to the previous find-in-document match (PDF). + FindPrevious, + /// Close the find-in-document bar (PDF). + CloseFind, + /// Scroll the PDF viewport down by one screen. + ScrollPageDown, + /// Scroll the PDF viewport up by one screen. + ScrollPageUp ] ); @@ -81,6 +95,9 @@ const ZOOM_EDITOR_MIN_DIGITS: usize = 3; // Reserve room for common values like const ZOOM_EDITOR_MAX_DIGITS: usize = 4; // MAX_ZOOM is 2000%. const ZOOM_EDITOR_APPROX_CHAR_WIDTH: f32 = 8.0; // Approximate width of one small UI digit. const ZOOM_EDITOR_HORIZONTAL_PADDING: f32 = 12.0; // Extra room for cursor and editor edge padding. +const FIND_MATCH_HIGHLIGHT: u32 = 0xffd60a66; // Yellow tint marking every find-in-document match. +const FIND_ACTIVE_MATCH_HIGHLIGHT: u32 = 0xff8c34a6; // Stronger tint marking the active match. +const FIND_BAR_WIDTH: f32 = 280.0; #[derive(Clone, Debug, PartialEq)] pub struct PdfSelection { @@ -93,7 +110,9 @@ pub struct PdfSelection { impl PdfSelection { pub fn min_endpoint(&self) -> (usize, usize) { - if self.start_page < self.end_page || (self.start_page == self.end_page && self.start_seg <= self.end_seg) { + if self.start_page < self.end_page + || (self.start_page == self.end_page && self.start_seg <= self.end_seg) + { (self.start_page, self.start_seg) } else { (self.end_page, self.end_seg) @@ -101,7 +120,9 @@ impl PdfSelection { } pub fn max_endpoint(&self) -> (usize, usize) { - if self.start_page < self.end_page || (self.start_page == self.end_page && self.start_seg <= self.end_seg) { + if self.start_page < self.end_page + || (self.start_page == self.end_page && self.start_seg <= self.end_seg) + { (self.end_page, self.end_seg) } else { (self.start_page, self.start_seg) @@ -109,6 +130,30 @@ impl PdfSelection { } } +/// A single find-in-document hit, covering one or more words on a single page. +#[derive(Clone, Debug, PartialEq)] +struct PdfFindMatch { + page_index: usize, + rects: Vec, +} + +/// Page-relative normalized box of one highlight quad. +#[derive(Clone, Copy, Debug, PartialEq)] +struct PdfFindRect { + x: f32, + y: f32, + width: f32, + height: f32, +} + +/// Find-in-document bar state: the query editor, its matches, and the active match. +struct PdfFindState { + editor: Entity, + matches: Vec, + current: Option, + _subscription: Subscription, +} + #[derive(Clone)] pub struct CachedPageImage { pub image: Arc, @@ -142,6 +187,7 @@ pub struct ImageView { pdf_autoscroll_task: Option>, context_menu: Option<(Entity, Point, Subscription)>, page_bounds: Rc>>>, + pdf_find: Option, pdf_page_cache: PageLruCache, } @@ -271,6 +317,7 @@ impl ImageView { pdf_autoscroll_task: None, context_menu: None, page_bounds: Rc::new(RefCell::new(HashMap::default())), + pdf_find: None, pdf_page_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), } } @@ -292,12 +339,18 @@ impl ImageView { .map(|m| (m.width, m.height)); self.page_bounds.borrow_mut().clear(); self.pdf_page_cache.clear(); + if self.pdf_find.is_some() { + self.refresh_pdf_find_matches(cx); + } cx.emit(ImageViewEvent::TitleChanged); cx.notify(); } ImageItemEvent::ReloadNeeded => { self.page_bounds.borrow_mut().clear(); self.pdf_page_cache.clear(); + if self.pdf_find.is_some() { + self.refresh_pdf_find_matches(cx); + } cx.notify(); } } @@ -521,7 +574,9 @@ impl ImageView { let seg_center_x = seg.x + seg.width * 0.5; let dist_x = (norm_x - seg_center_x).abs(); let allowed_dist = MAX_LINE_PROXIMITY.max(seg.width * 1.5); - if dist_x <= allowed_dist && closest_on_line.as_ref().map_or(true, |(_, d)| dist_x < *d) { + if dist_x <= allowed_dist + && closest_on_line.as_ref().map_or(true, |(_, d)| dist_x < *d) + { closest_on_line = Some((i, dist_x)); } } @@ -537,7 +592,9 @@ impl ImageView { let center_x = seg.x + seg.width * 0.5; let center_y = seg.y + seg.height * 0.5; let dist_sq = (norm_x - center_x).powi(2) + (norm_y - center_y).powi(2); - if dist_sq <= MAX_OVERALL_DIST_SQ && closest.as_ref().map_or(true, |(_, d)| dist_sq < *d) { + if dist_sq <= MAX_OVERALL_DIST_SQ + && closest.as_ref().map_or(true, |(_, d)| dist_sq < *d) + { closest = Some((i, dist_sq)); } } @@ -551,11 +608,12 @@ impl ImageView { end_page: usize, end_seg: usize, ) -> String { - let (min_p, min_s, max_p, max_s) = if start_page < end_page || (start_page == end_page && start_seg <= end_seg) { - (start_page, start_seg, end_page, end_seg) - } else { - (end_page, end_seg, start_page, start_seg) - }; + let (min_p, min_s, max_p, max_s) = + if start_page < end_page || (start_page == end_page && start_seg <= end_seg) { + (start_page, start_seg, end_page, end_seg) + } else { + (end_page, end_seg, start_page, start_seg) + }; let mut result_parts = Vec::new(); for p in min_p..=max_p { @@ -564,8 +622,16 @@ impl ImageView { if segs.is_empty() { continue; } - let first_idx = if p == min_p { min_s.min(segs.len().saturating_sub(1)) } else { 0 }; - let last_idx = if p == max_p { max_s.min(segs.len().saturating_sub(1)) } else { segs.len().saturating_sub(1) }; + let first_idx = if p == min_p { + min_s.min(segs.len().saturating_sub(1)) + } else { + 0 + }; + let last_idx = if p == max_p { + max_s.min(segs.len().saturating_sub(1)) + } else { + segs.len().saturating_sub(1) + }; if first_idx <= last_idx { let page_text = segs[first_idx..=last_idx] .iter() @@ -614,14 +680,17 @@ impl ImageView { } else { 0.0 }; - if let Some(seg_idx) = Self::find_segment_at(&page.text_segments, norm_x, norm_y) { + if let Some(seg_idx) = + Self::find_segment_at(&page.text_segments, norm_x, norm_y) + { return Some((page_idx, seg_idx)); } } } } - let mut sorted_pages: Vec<(usize, Bounds)> = page_bounds.iter().map(|(&idx, &b)| (idx, b)).collect(); + let mut sorted_pages: Vec<(usize, Bounds)> = + page_bounds.iter().map(|(&idx, &b)| (idx, b)).collect(); sorted_pages.sort_by_key(|(idx, _)| *idx); let first = sorted_pages.first()?; @@ -661,7 +730,9 @@ impl ImageView { } else { 0.0 }; - if let Some(seg_idx) = Self::find_segment_at(&page.text_segments, norm_x, norm_y) { + if let Some(seg_idx) = + Self::find_segment_at(&page.text_segments, norm_x, norm_y) + { return Some((*page_idx, seg_idx)); } } @@ -689,7 +760,9 @@ impl ImageView { fn update_pdf_drag_selection(&mut self, cursor_pos: Point, cx: &mut Context) { let item = self.image_item.read(cx); - let Some(ref pdf_info) = item.pdf_info else { return; }; + let Some(ref pdf_info) = item.pdf_info else { + return; + }; if let Some((page_idx, seg_idx)) = self.find_page_and_segment_at(cursor_pos, pdf_info) { if let Some(ref mut sel) = self.pdf_selection { if sel.end_page != page_idx || sel.end_seg != seg_idx { @@ -753,53 +826,58 @@ impl ImageView { let task = cx.spawn(async move |this, cx| { let mut last_tick = std::time::Instant::now(); loop { - cx.background_executor().timer(std::time::Duration::from_millis(8)).await; + cx.background_executor() + .timer(std::time::Duration::from_millis(8)) + .await; let now = std::time::Instant::now(); let dt = (now - last_tick).as_secs_f32().min(0.05); last_tick = now; - let should_continue = this.update(cx, |this, cx| { - if !this.pdf_mouse_down { - this.pdf_autoscroll_task = None; - return false; - } - let Some(cursor_pos) = this.pdf_drag_position else { - this.pdf_autoscroll_task = None; - return false; - }; - - let viewport_bounds = this.pdf_scroll_handle.bounds(); - if viewport_bounds.size.height <= px(0.) { - return true; - } - - let velocity = Self::calculate_pdf_autoscroll_velocity( - cursor_pos.y, - viewport_bounds.top(), - viewport_bounds.bottom(), - ); - - if velocity != 0.0 { - let scroll_dy = px(velocity * dt); - let current_offset = this.pdf_scroll_handle.offset(); - let max_offset = this.pdf_scroll_handle.max_offset(); - let new_y = (current_offset.y + scroll_dy).clamp(-max_offset.y, px(0.)); - if new_y != current_offset.y { - let dy = new_y - current_offset.y; - this.pdf_scroll_handle.set_offset(point(current_offset.x, new_y)); - { - let mut bounds_map = this.page_bounds.borrow_mut(); - for bounds in bounds_map.values_mut() { - bounds.origin.y += dy; - } - } - this.update_pdf_drag_selection(cursor_pos, cx); - cx.notify(); + let should_continue = this + .update(cx, |this, cx| { + if !this.pdf_mouse_down { + this.pdf_autoscroll_task = None; + return false; } - } + let Some(cursor_pos) = this.pdf_drag_position else { + this.pdf_autoscroll_task = None; + return false; + }; - true - }).unwrap_or(false); + let viewport_bounds = this.pdf_scroll_handle.bounds(); + if viewport_bounds.size.height <= px(0.) { + return true; + } + + let velocity = Self::calculate_pdf_autoscroll_velocity( + cursor_pos.y, + viewport_bounds.top(), + viewport_bounds.bottom(), + ); + + if velocity != 0.0 { + let scroll_dy = px(velocity * dt); + let current_offset = this.pdf_scroll_handle.offset(); + let max_offset = this.pdf_scroll_handle.max_offset(); + let new_y = (current_offset.y + scroll_dy).clamp(-max_offset.y, px(0.)); + if new_y != current_offset.y { + let dy = new_y - current_offset.y; + this.pdf_scroll_handle + .set_offset(point(current_offset.x, new_y)); + { + let mut bounds_map = this.page_bounds.borrow_mut(); + for bounds in bounds_map.values_mut() { + bounds.origin.y += dy; + } + } + this.update_pdf_drag_selection(cursor_pos, cx); + cx.notify(); + } + } + + true + }) + .unwrap_or(false); if !should_continue { break; @@ -828,8 +906,12 @@ impl ImageView { ) { let bounds = self.page_bounds.borrow().get(&page_index).copied(); let item = self.image_item.read(cx); - let Some(ref pdf_info) = item.pdf_info else { return; }; - let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_index) else { return; }; + let Some(ref pdf_info) = item.pdf_info else { + return; + }; + let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_index) else { + return; + }; if click_count >= 3 { self.pdf_mouse_down = false; @@ -846,7 +928,9 @@ impl ImageView { return; } - let Some(bounds) = bounds else { return; }; + let Some(bounds) = bounds else { + return; + }; let b_w: f32 = bounds.size.width.into(); let b_h: f32 = bounds.size.height.into(); if b_w <= 0.0 || b_h <= 0.0 { @@ -880,7 +964,6 @@ impl ImageView { cx.notify(); } - fn handle_pdf_mouse_up(&mut self, cx: &mut Context) { if self.pdf_mouse_down { self.pdf_mouse_down = false; @@ -912,7 +995,9 @@ impl ImageView { let item = self.image_item.read(cx); if let Some(ref pdf_info) = item.pdf_info { if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_index) { - if let Some(seg_idx) = Self::find_segment_at(&page.text_segments, norm_x, norm_y) { + if let Some(seg_idx) = + Self::find_segment_at(&page.text_segments, norm_x, norm_y) + { let need_select = self.pdf_selection.as_ref().map_or(true, |s| { let (min_p, min_s) = s.min_endpoint(); let (max_p, max_s) = s.max_endpoint(); @@ -945,13 +1030,19 @@ impl ImageView { self.deploy_context_menu(click_pos, window, cx); } - fn toggle_text_panel(&mut self, _: &ToggleTextPanel, window: &mut Window, cx: &mut Context) { + fn toggle_text_panel( + &mut self, + _: &ToggleTextPanel, + window: &mut Window, + cx: &mut Context, + ) { self.show_text_panel = !self.show_text_panel; if self.show_text_panel && self.text_editor.is_none() { if let Some(text) = self.image_item.read(cx).extract_text() { let buffer = cx.new(|cx| language::Buffer::local(text, cx)); let editor = cx.new(|cx| { - let mut editor = Editor::for_buffer(buffer.clone(), Some(self.project.clone()), window, cx); + let mut editor = + Editor::for_buffer(buffer.clone(), Some(self.project.clone()), window, cx); editor.set_read_only(true); editor }); @@ -962,6 +1053,362 @@ impl ImageView { cx.notify(); } + /// Scans pages for case-insensitive occurrences of `query`. Text segments are + /// word-sized, so each hit is mapped onto the boxes of the words it spans, with the + /// first and last box trimmed by character fraction. + fn find_pdf_matches(pages: &[PdfPageEntry], query: &str) -> Vec { + let needle: Vec = query.to_lowercase().chars().collect(); + if needle.is_empty() { + return Vec::new(); + } + + let mut matches = Vec::new(); + for page in pages { + if page.text_segments.is_empty() { + continue; + } + + // The page's lower-cased text, plus each segment's character span, so a hit + // that spans a word boundary is located the way a browser would find it. + let mut haystack: Vec = Vec::new(); + let mut spans: Vec<(usize, usize)> = Vec::with_capacity(page.text_segments.len()); + for (index, segment) in page.text_segments.iter().enumerate() { + if index > 0 { + haystack.push(' '); + } + let start = haystack.len(); + haystack.extend(segment.text.to_lowercase().chars()); + spans.push((start, haystack.len())); + } + + let mut cursor = 0; + while cursor + needle.len() <= haystack.len() { + if haystack[cursor..cursor + needle.len()] != needle[..] { + cursor += 1; + continue; + } + + if let Some(rects) = + Self::match_rects(&page.text_segments, &spans, cursor, needle.len()) + { + matches.push(PdfFindMatch { + page_index: page.page_index, + rects, + }); + } + cursor += needle.len(); + } + } + + matches + } + + /// Builds the highlight quads for a hit covering `length` characters from `start`. + fn match_rects( + segments: &[pdf_viewer::PdfTextSegment], + spans: &[(usize, usize)], + start: usize, + length: usize, + ) -> Option> { + let end = start + length; + let first = spans.iter().position(|(_, span_end)| *span_end > start)?; + let last = spans + .iter() + .rposition(|(span_start, _)| *span_start < end)?; + + let mut rects = Vec::with_capacity(last - first + 1); + for index in first..=last { + let (span_start, span_end) = spans[index]; + let span_len = (span_end - span_start) as f32; + let segment = segments.get(index)?; + if span_len <= 0.0 || segment.width <= 0.0 { + continue; + } + + let trim_start = start.saturating_sub(span_start).min(span_end - span_start) as f32; + let trim_end = end.min(span_end).saturating_sub(span_start) as f32; + let width_fraction = ((trim_end - trim_start) / span_len).clamp(0.0, 1.0); + if width_fraction <= 0.0 { + continue; + } + + rects.push(PdfFindRect { + x: segment.x + segment.width * (trim_start / span_len), + y: segment.y, + width: segment.width * width_fraction, + height: segment.height, + }); + } + + (!rects.is_empty()).then_some(rects) + } + + fn open_pdf_find(&mut self, _: &FindInDocument, window: &mut Window, cx: &mut Context) { + if !self.image_item.read(cx).is_pdf() { + return; + } + + if self.pdf_find.is_none() { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Find in document", window, cx); + editor + }); + let subscription = cx.subscribe_in(&editor, window, |this, _, event, _window, cx| { + if matches!(event, EditorEvent::Edited { .. }) { + this.refresh_pdf_find_matches(cx); + } + }); + self.pdf_find = Some(PdfFindState { + editor, + matches: Vec::new(), + current: None, + _subscription: subscription, + }); + } + + if let Some(find) = self.pdf_find.as_ref() { + let handle = find.editor.read(cx).focus_handle(cx); + window.focus(&handle, cx); + } + self.refresh_pdf_find_matches(cx); + cx.notify(); + } + + fn close_pdf_find(&mut self, _: &CloseFind, window: &mut Window, cx: &mut Context) { + if self.pdf_find.take().is_none() { + return; + } + window.focus(&self.focus_handle, cx); + cx.notify(); + } + + /// Re-runs the current query and moves the active match back to the first hit. + fn refresh_pdf_find_matches(&mut self, cx: &mut Context) { + let Some(query) = self + .pdf_find + .as_ref() + .map(|find| find.editor.read(cx).text(cx)) + else { + return; + }; + + let matches = match self.image_item.read(cx).pdf_info.as_ref() { + Some(info) => Self::find_pdf_matches(&info.pages, &query), + None => Vec::new(), + }; + + let Some(find) = self.pdf_find.as_mut() else { + return; + }; + find.matches = matches; + find.current = (!find.matches.is_empty()).then_some(0); + let target_page = find + .current + .and_then(|index| find.matches.get(index)) + .map(|hit| hit.page_index); + + if let Some(page_index) = target_page { + self.jump_to_pdf_page(page_index, cx); + } + } + + fn find_next_match(&mut self, _: &FindNext, _window: &mut Window, cx: &mut Context) { + self.step_pdf_find(1, cx); + } + + fn find_previous_match( + &mut self, + _: &FindPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + self.step_pdf_find(-1, cx); + } + + /// Moves the active match by `delta`, wrapping around the match list. + fn step_pdf_find(&mut self, delta: isize, cx: &mut Context) { + let Some(find) = self.pdf_find.as_mut() else { + return; + }; + if find.matches.is_empty() { + return; + } + + let count = find.matches.len() as isize; + let current = find.current.unwrap_or(0) as isize; + find.current = Some((current + delta).rem_euclid(count) as usize); + let target_page = find + .current + .and_then(|index| find.matches.get(index)) + .map(|hit| hit.page_index); + + if let Some(page_index) = target_page { + self.jump_to_pdf_page(page_index, cx); + } + cx.notify(); + } + + fn scroll_page_down( + &mut self, + _: &ScrollPageDown, + _window: &mut Window, + cx: &mut Context, + ) { + self.scroll_pdf_viewport(0.9, cx); + } + + fn scroll_page_up(&mut self, _: &ScrollPageUp, _window: &mut Window, cx: &mut Context) { + self.scroll_pdf_viewport(-0.9, cx); + } + + /// Jumps the viewport to the top of a page, clamped to the document bounds. + fn jump_to_pdf_page(&mut self, page_index: usize, cx: &mut Context) { + let total_pages = self.image_item.read(cx).total_pages(); + if total_pages == 0 { + return; + } + let target = page_index.min(total_pages - 1); + self.image_item.update(cx, |item, cx| { + item.set_page(target, cx); + }); + self.pdf_scroll_handle.scroll_to_top_of_item(target); + cx.notify(); + } + + /// Scrolls the PDF viewport by a fraction of its visible height. + fn scroll_pdf_viewport(&mut self, fraction: f32, cx: &mut Context) { + let viewport = self.pdf_scroll_handle.bounds(); + let offset = self.pdf_scroll_handle.offset(); + let max_offset = self.pdf_scroll_handle.max_offset(); + let new_y = (offset.y - viewport.size.height * fraction).clamp(-max_offset.y, px(0.0)); + if new_y != offset.y { + self.pdf_scroll_handle.set_offset(point(offset.x, new_y)); + cx.notify(); + } + } + + /// Handles key events for the PDF viewport before they reach focused children. + /// Space only scrolls while the find bar is not focused, so it stays typeable. + fn handle_pdf_key_down( + &mut self, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + let modifiers = event.keystroke.modifiers; + if event.keystroke.key.as_str() != "space" + || modifiers.control + || modifiers.alt + || modifiers.platform + { + return; + } + + let find_focused = self + .pdf_find + .as_ref() + .is_some_and(|find| find.editor.read(cx).focus_handle(cx).is_focused(window)); + if find_focused { + return; + } + + cx.stop_propagation(); + if modifiers.shift { + self.scroll_page_up(&ScrollPageUp, window, cx); + } else { + self.scroll_page_down(&ScrollPageDown, window, cx); + } + } + + /// Handles Enter/Shift+Enter/Escape while the find bar owns focus. + fn handle_find_bar_key_down( + &mut self, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event.keystroke.key.as_str() { + "enter" if event.keystroke.modifiers.shift => { + cx.stop_propagation(); + self.find_previous_match(&FindPrevious, window, cx); + } + "enter" => { + cx.stop_propagation(); + self.find_next_match(&FindNext, window, cx); + } + "escape" => { + cx.stop_propagation(); + self.close_pdf_find(&CloseFind, window, cx); + } + _ => {} + } + } + + /// Renders the floating find bar: query input, match counter, and navigation. + fn render_pdf_find_bar( + editor: Entity, + status: String, + cx: &mut Context, + ) -> AnyElement { + h_flex() + .id("pdf-find-bar") + .absolute() + .top_2() + .right_2() + .w(px(FIND_BAR_WIDTH)) + .items_center() + .gap_1() + .px_2() + .py_1() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().elevated_surface_background) + .shadow_lg() + .occlude() + .on_mouse_down(MouseButton::Left, { + let editor = editor.clone(); + move |_, window, cx| { + cx.stop_propagation(); + let handle = editor.read(cx).focus_handle(cx); + window.focus(&handle, cx); + } + }) + .capture_key_down(cx.listener(Self::handle_find_bar_key_down)) + .child(div().flex_1().min_w_0().child(editor)) + .child( + Label::new(status) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child( + IconButton::new("find-previous", IconName::ChevronUp) + .icon_size(IconSize::Small) + .tooltip(|_, cx| Tooltip::for_action("Previous Match", &FindPrevious, cx)) + .on_click(cx.listener(|this, _, window, cx| { + this.find_previous_match(&FindPrevious, window, cx); + })), + ) + .child( + IconButton::new("find-next", IconName::ChevronDown) + .icon_size(IconSize::Small) + .tooltip(|_, cx| Tooltip::for_action("Next Match", &FindNext, cx)) + .on_click(cx.listener(|this, _, window, cx| { + this.find_next_match(&FindNext, window, cx); + })), + ) + .child( + IconButton::new("find-close", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(|_, cx| Tooltip::for_action("Close Find", &CloseFind, cx)) + .on_click(cx.listener(|this, _, window, cx| { + this.close_pdf_find(&CloseFind, window, cx); + })), + ) + .into_any_element() + } + fn reveal_in_file_manager( &mut self, _: &RevealInFileManager, @@ -1095,7 +1542,8 @@ impl ImageView { rendered.height, rendered.rgba_buffer.as_ref().clone(), ) { - let render_image = Arc::new(gpui::RenderImage::new(vec![image::Frame::new(img_buf)])); + let render_image = + Arc::new(gpui::RenderImage::new(vec![image::Frame::new(img_buf)])); self.pdf_page_cache.insert( key, CachedPageImage { @@ -1422,6 +1870,7 @@ impl Item for ImageView { pdf_autoscroll_task: None, context_menu: None, page_bounds: Rc::new(RefCell::new(HashMap::default())), + pdf_find: None, pdf_page_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), }))) } @@ -1545,11 +1994,16 @@ impl Render for ImageView { let top_item = self.pdf_scroll_handle.top_item(); let bottom_item = self.pdf_scroll_handle.bottom_item(); let visible_start = top_item.saturating_sub(1); - let visible_end = (bottom_item.max(top_item + 1) + 1).min(total_pages.saturating_sub(1)); + let visible_end = + (bottom_item.max(top_item + 1) + 1).min(total_pages.saturating_sub(1)); for page_idx in visible_start..=visible_end { - if let std::collections::hash_map::Entry::Vacant(e) = page_images.entry(page_idx) { - if let Some(img) = self.get_or_render_page_image(&info.pdf_bytes, page_idx) { + if let std::collections::hash_map::Entry::Vacant(e) = + page_images.entry(page_idx) + { + if let Some(img) = + self.get_or_render_page_image(&info.pdf_bytes, page_idx) + { e.insert(img); } } @@ -1574,6 +2028,13 @@ impl Render for ImageView { .on_action(cx.listener(Self::copy_text)) .on_action(cx.listener(Self::toggle_text_panel)) .on_action(cx.listener(Self::select_all)) + .on_action(cx.listener(Self::open_pdf_find)) + .on_action(cx.listener(Self::find_next_match)) + .on_action(cx.listener(Self::find_previous_match)) + .on_action(cx.listener(Self::close_pdf_find)) + .on_action(cx.listener(Self::scroll_page_down)) + .on_action(cx.listener(Self::scroll_page_up)) + .capture_key_down(cx.listener(Self::handle_pdf_key_down)) .on_mouse_down( MouseButton::Left, cx.listener(|this, _event: &MouseDownEvent, window, cx| { @@ -1696,17 +2157,59 @@ impl Render for ImageView { } }); let segs = segments_arc; + // Find-in-document highlights for this page, with the + // active match marked for a stronger tint. + let find_rects: Vec<(PdfFindRect, bool)> = self + .pdf_find + .as_ref() + .map(|find| { + find.matches + .iter() + .enumerate() + .filter(|(_, hit)| hit.page_index == page_idx) + .flat_map(|(index, hit)| { + let is_active = find.current == Some(index); + hit.rects + .iter() + .map(move |rect| (*rect, is_active)) + }) + .collect() + }) + .unwrap_or_default(); canvas( move |bounds: Bounds, _window: &mut Window, _cx: &mut App| { page_bounds.borrow_mut().insert(page_idx, bounds); }, move |bounds: Bounds, _state: (), window: &mut Window, _cx: &mut App| { + let b_ox = bounds.origin.x; + let b_oy = bounds.origin.y; + let b_w = f32::from(bounds.size.width); + let b_h = f32::from(bounds.size.height); + + for (rect, is_active) in &find_rects { + let quad_bounds = Bounds { + origin: point( + b_ox + px(rect.x * b_w), + b_oy + px(rect.y * b_h), + ), + size: size( + px(rect.width * b_w), + px(rect.height * b_h), + ), + }; + let color = if *is_active { + FIND_ACTIVE_MATCH_HIGHLIGHT + } else { + FIND_MATCH_HIGHLIGHT + }; + window.paint_quad( + gpui::fill(quad_bounds, gpui::rgba(color)) + .corner_radii(px(2.0)), + ); + } + if let Some((first_idx, last_idx)) = seg_range { let highlight_color = gpui::rgba(0x3b82f644); - let b_ox = bounds.origin.x; - let b_oy = bounds.origin.y; - let b_w = f32::from(bounds.size.width); - let b_h = f32::from(bounds.size.height); for seg_idx in first_idx..=last_idx { if let Some(seg) = segs.get(seg_idx) { let quad_bounds = Bounds { @@ -1734,7 +2237,11 @@ impl Render for ImageView { let link_w = px(link.width * page_w_val); let link_h = px(link.height * page_h_val); let link_url = link.url.clone(); - let link_url_tooltip = link.url; + let link_dest_page = link.dest_page; + let link_tooltip = match link_dest_page { + Some(page) => format!("Go to page {}", page + 1), + None => format!("Open: {}", link.url), + }; div() .id(("pdf-link", page_idx * 100_000 + link_idx)) @@ -1750,17 +2257,17 @@ impl Render for ImageView { .border_color(gpui::rgba(0x3b82f688)) .rounded_xs() }) - .tooltip(Tooltip::text(format!("Open: {}", link_url_tooltip))) + .tooltip(Tooltip::text(link_tooltip)) .on_mouse_down( MouseButton::Left, - move |_event: &MouseDownEvent, _window, cx| { - if Self::is_safe_pdf_url(&link_url) { - cx.open_url(&link_url); - } else { - log::warn!("Blocked opening potentially unsafe PDF link: {}", link_url); - } + cx.listener(move |this, _event: &MouseDownEvent, _window, cx| { cx.stop_propagation(); - }, + match link_dest_page { + Some(page) => this.jump_to_pdf_page(page, cx), + None if Self::is_safe_pdf_url(&link_url) => cx.open_url(&link_url), + None => log::warn!("Blocked opening potentially unsafe PDF link: {}", link_url), + } + }), ) })) ) @@ -1779,7 +2286,19 @@ impl Render for ImageView { .notify_content(), window, cx, - ); + ) + .children(self.pdf_find.as_ref().map(|find| { + let status = if find.matches.is_empty() { + if find.editor.read(cx).text(cx).trim().is_empty() { + String::new() + } else { + "No results".to_string() + } + } else { + format!("{} / {}", find.current.unwrap_or(0) + 1, find.matches.len()) + }; + Self::render_pdf_find_bar(find.editor.clone(), status, cx) + })); if self.show_text_panel { h_flex() @@ -2005,7 +2524,9 @@ impl Render for ImageViewToolbarControls { IconButton::new("prev-page", IconName::ChevronLeft) .icon_size(IconSize::Small) .disabled(current_page == 0) - .tooltip(|_window, cx| Tooltip::for_action("Previous Page", &PreviousPage, cx)) + .tooltip(|_window, cx| { + Tooltip::for_action("Previous Page", &PreviousPage, cx) + }) .on_click(move |_, window, cx| { if let Some(view) = image_view_prev.upgrade() { view.update(cx, |this, cx| { @@ -2016,9 +2537,10 @@ impl Render for ImageViewToolbarControls { }), ) .child( - h_flex() - .px_1() - .child(Label::new(format!("{} / {}", current_page + 1, total_pages)).size(LabelSize::Small)) + h_flex().px_1().child( + Label::new(format!("{} / {}", current_page + 1, total_pages)) + .size(LabelSize::Small), + ), ) .child( IconButton::new("next-page", IconName::ChevronRight) @@ -2039,7 +2561,22 @@ impl Render for ImageViewToolbarControls { .when(is_pdf, |this| { let image_view_copy = image_view.downgrade(); let image_view_text = image_view.downgrade(); + let image_view_find = image_view.downgrade(); this.child( + IconButton::new("find-in-document", IconName::MagnifyingGlass) + .icon_size(IconSize::Small) + .tooltip(|_window, cx| { + Tooltip::for_action("Find in Document", &FindInDocument, cx) + }) + .on_click(move |_, window, cx| { + if let Some(view) = image_view_find.upgrade() { + view.update(cx, |this, cx| { + this.open_pdf_find(&FindInDocument, window, cx); + }); + } + }), + ) + .child( IconButton::new("copy-text", IconName::Copy) .icon_size(IconSize::Small) .tooltip(|_window, cx| Tooltip::for_action("Copy Text", &CopyText, cx)) @@ -2055,7 +2592,9 @@ impl Render for ImageViewToolbarControls { IconButton::new("toggle-text-panel", IconName::FileDoc) .icon_size(IconSize::Small) .toggle_state(show_text_panel) - .tooltip(|_window, cx| Tooltip::for_action("Toggle Text Panel", &ToggleTextPanel, cx)) + .tooltip(|_window, cx| { + Tooltip::for_action("Toggle Text Panel", &ToggleTextPanel, cx) + }) .on_click(move |_, window, cx| { if let Some(view) = image_view_text.upgrade() { view.update(cx, |this, cx| { @@ -2520,8 +3059,20 @@ mod tests { image: Some(dummy_img.clone()), page_text: "Hello world".into(), text_segments: vec![ - PdfTextSegment { text: "Hello".into(), x: 0., y: 0., width: 10., height: 10. }, - PdfTextSegment { text: "world".into(), x: 10., y: 0., width: 10., height: 10. }, + PdfTextSegment { + text: "Hello".into(), + x: 0., + y: 0., + width: 10., + height: 10., + }, + PdfTextSegment { + text: "world".into(), + x: 10., + y: 0., + width: 10., + height: 10., + }, ], links: vec![], }, @@ -2532,9 +3083,27 @@ mod tests { image: Some(dummy_img), page_text: "from page two".into(), text_segments: vec![ - PdfTextSegment { text: "from".into(), x: 0., y: 0., width: 10., height: 10. }, - PdfTextSegment { text: "page".into(), x: 10., y: 0., width: 10., height: 10. }, - PdfTextSegment { text: "two".into(), x: 20., y: 0., width: 10., height: 10. }, + PdfTextSegment { + text: "from".into(), + x: 0., + y: 0., + width: 10., + height: 10., + }, + PdfTextSegment { + text: "page".into(), + x: 10., + y: 0., + width: 10., + height: 10., + }, + PdfTextSegment { + text: "two".into(), + x: 20., + y: 0., + width: 10., + height: 10., + }, ], links: vec![], }, @@ -2547,6 +3116,121 @@ mod tests { assert_eq!(text_backward, "world\n\nfrom page"); } + fn find_test_page( + page_index: usize, + segments: Vec<(&str, f32, f32, f32, f32)>, + ) -> PdfPageEntry { + use pdf_viewer::PdfTextSegment; + + PdfPageEntry { + page_index, + width: 100, + height: 100, + image: None, + page_text: segments + .iter() + .map(|(text, ..)| *text) + .collect::>() + .join(" "), + text_segments: segments + .into_iter() + .map(|(text, x, y, width, height)| PdfTextSegment { + text: text.into(), + x, + y, + width, + height, + }) + .collect(), + links: vec![], + } + } + + #[test] + fn test_pdf_find_matches_whole_words_case_insensitive() { + let pages = vec![find_test_page( + 0, + vec![ + ("Hello", 0.0, 0.0, 0.2, 0.05), + ("World", 0.2, 0.0, 0.25, 0.05), + ], + )]; + + let matches = ImageView::find_pdf_matches(&pages, "hello"); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].page_index, 0); + assert_eq!(matches[0].rects.len(), 1); + assert_eq!( + matches[0].rects[0], + PdfFindRect { + x: 0.0, + y: 0.0, + width: 0.2, + height: 0.05 + } + ); + + let upper = ImageView::find_pdf_matches(&pages, "WORLD"); + assert_eq!(upper.len(), 1); + assert_eq!(upper[0].rects[0].x, 0.2); + } + + #[test] + fn test_pdf_find_matches_across_segments() { + let pages = vec![find_test_page( + 0, + vec![ + ("Hello", 0.0, 0.0, 0.2, 0.05), + ("World", 0.2, 0.0, 0.25, 0.05), + ], + )]; + + let matches = ImageView::find_pdf_matches(&pages, "hello world"); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].rects.len(), 2); + assert_eq!(matches[0].rects[1].x, 0.2); + assert_eq!(matches[0].rects[1].width, 0.25); + } + + #[test] + fn test_pdf_find_matches_trims_partial_words() { + let pages = vec![find_test_page(0, vec![("Multiple", 0.1, 0.2, 0.4, 0.05)])]; + + let matches = ImageView::find_pdf_matches(&pages, "tipl"); + assert_eq!(matches.len(), 1); + let rect = matches[0].rects[0]; + // "tipl" covers characters 3..7 of "Multiple", so the box starts at 3/8 of the + // word's width and keeps 4/8 of it. + assert!((rect.x - 0.25).abs() < 1e-5, "x was {}", rect.x); + assert!((rect.width - 0.2).abs() < 1e-5, "width was {}", rect.width); + assert_eq!(rect.y, 0.2); + assert_eq!(rect.height, 0.05); + } + + #[test] + fn test_pdf_find_matches_reports_every_page() { + let pages = vec![ + find_test_page(0, vec![("alpha", 0.0, 0.0, 0.1, 0.05)]), + find_test_page(1, vec![("beta", 0.0, 0.0, 0.1, 0.05)]), + find_test_page( + 2, + vec![ + ("alpha", 0.0, 0.0, 0.1, 0.05), + ("alpha", 0.1, 0.0, 0.1, 0.05), + ], + ), + ]; + + let matches = ImageView::find_pdf_matches(&pages, "alpha"); + assert_eq!( + matches.iter().map(|hit| hit.page_index).collect::>(), + vec![0, 2, 2] + ); + + assert!(ImageView::find_pdf_matches(&pages, "gamma").is_empty()); + assert!(ImageView::find_pdf_matches(&pages, "").is_empty()); + } + #[test] fn test_pdf_autoscroll_symmetry_and_speed() { let v_top = px(100.0); @@ -2560,8 +3244,10 @@ mod tests { // At edge threshold (dist = 0) let top_thresh = px(210.0); // 100 + 110 let bot_thresh = px(790.0); // 900 - 110 - let v_thresh_up = ImageView::calculate_pdf_autoscroll_velocity(top_thresh - px(0.01), v_top, v_bottom); - let v_thresh_down = ImageView::calculate_pdf_autoscroll_velocity(bot_thresh + px(0.01), v_top, v_bottom); + let v_thresh_up = + ImageView::calculate_pdf_autoscroll_velocity(top_thresh - px(0.01), v_top, v_bottom); + let v_thresh_down = + ImageView::calculate_pdf_autoscroll_velocity(bot_thresh + px(0.01), v_top, v_bottom); assert_eq!(v_thresh_up.round(), 700.0); assert_eq!(v_thresh_down.round(), -700.0); @@ -2581,15 +3267,19 @@ mod tests { assert_eq!(v_edge_down, -4500.0); // Past viewport edge (extra = 20px -> +1000.0 speed) - let v_past_up = ImageView::calculate_pdf_autoscroll_velocity(v_top - px(20.0), v_top, v_bottom); - let v_past_down = ImageView::calculate_pdf_autoscroll_velocity(v_bottom + px(20.0), v_top, v_bottom); + let v_past_up = + ImageView::calculate_pdf_autoscroll_velocity(v_top - px(20.0), v_top, v_bottom); + let v_past_down = + ImageView::calculate_pdf_autoscroll_velocity(v_bottom + px(20.0), v_top, v_bottom); assert_eq!(v_past_up, -v_past_down); assert_eq!(v_past_up, 5500.0); assert_eq!(v_past_down, -5500.0); // Far past viewport edge (extra = 150px -> capped at 9500.0 speed) - let v_far_up = ImageView::calculate_pdf_autoscroll_velocity(v_top - px(150.0), v_top, v_bottom); - let v_far_down = ImageView::calculate_pdf_autoscroll_velocity(v_bottom + px(150.0), v_top, v_bottom); + let v_far_up = + ImageView::calculate_pdf_autoscroll_velocity(v_top - px(150.0), v_top, v_bottom); + let v_far_down = + ImageView::calculate_pdf_autoscroll_velocity(v_bottom + px(150.0), v_top, v_bottom); assert_eq!(v_far_up, -v_far_down); assert_eq!(v_far_up, 9500.0); assert_eq!(v_far_down, -9500.0); @@ -2606,7 +3296,9 @@ mod tests { // Dangerous / blocked schemes assert!(!ImageView::is_safe_pdf_url("file:///etc/passwd")); assert!(!ImageView::is_safe_pdf_url("javascript:alert(1)")); - assert!(!ImageView::is_safe_pdf_url("data:text/html,")); + assert!(!ImageView::is_safe_pdf_url( + "data:text/html," + )); assert!(!ImageView::is_safe_pdf_url("shell:launch")); assert!(!ImageView::is_safe_pdf_url("powershell:something")); assert!(!ImageView::is_safe_pdf_url("ms-msdt:/id DevDiagnostic")); @@ -2614,15 +3306,13 @@ mod tests { #[test] fn test_find_segment_at_whitespace_rejection() { - let segments = vec![ - pdf_viewer::PdfTextSegment { - text: "Hello".to_string(), - x: 0.2, - y: 0.2, - width: 0.1, - height: 0.05, - }, - ]; + let segments = vec![pdf_viewer::PdfTextSegment { + text: "Hello".to_string(), + x: 0.2, + y: 0.2, + width: 0.1, + height: 0.05, + }]; // Direct hit inside bounding box assert_eq!(ImageView::find_segment_at(&segments, 0.25, 0.22), Some(0)); @@ -2647,14 +3337,20 @@ mod tests { assert_ne!(key_100, key_150); - cache.insert(key_100, CachedPageImage { - image: dummy_img.clone(), - byte_size: 400_000, - }); - cache.insert(key_150, CachedPageImage { - image: dummy_img.clone(), - byte_size: 400_000, - }); + cache.insert( + key_100, + CachedPageImage { + image: dummy_img.clone(), + byte_size: 400_000, + }, + ); + cache.insert( + key_150, + CachedPageImage { + image: dummy_img.clone(), + byte_size: 400_000, + }, + ); assert_eq!(cache.len(), 2); assert_eq!(cache.current_memory_bytes(), 800_000); @@ -2663,10 +3359,13 @@ mod tests { // Exceed budget (1MB = 1,048,576 bytes; 800_000 + 400_000 = 1,200_000 bytes) let key_200 = CacheKey::new(0, 2.0, false); - cache.insert(key_200, CachedPageImage { - image: dummy_img, - byte_size: 400_000, - }); + cache.insert( + key_200, + CachedPageImage { + image: dummy_img, + byte_size: 400_000, + }, + ); // key_100 should be evicted as the least recently used assert_eq!(cache.len(), 2); @@ -2681,14 +3380,20 @@ mod tests { let mut cache: PageLruCache = PageLruCache::new(1000); let dummy_img = Arc::new(gpui::RenderImage::new(Vec::new())); - cache.insert(CacheKey::new(0, 1.0, false), CachedPageImage { - image: dummy_img.clone(), - byte_size: 300, - }); - cache.insert(CacheKey::new(1, 1.0, false), CachedPageImage { - image: dummy_img, - byte_size: 300, - }); + cache.insert( + CacheKey::new(0, 1.0, false), + CachedPageImage { + image: dummy_img.clone(), + byte_size: 300, + }, + ); + cache.insert( + CacheKey::new(1, 1.0, false), + CachedPageImage { + image: dummy_img, + byte_size: 300, + }, + ); assert_eq!(cache.len(), 2); assert_eq!(cache.current_memory_bytes(), 600); diff --git a/crates/pdf_viewer/src/cache.rs b/crates/pdf_viewer/src/cache.rs index 44a3ca7312..2cea0f5e05 100644 --- a/crates/pdf_viewer/src/cache.rs +++ b/crates/pdf_viewer/src/cache.rs @@ -336,12 +336,30 @@ mod tests { let key0 = CacheKey::new(0, 1.0, false); let key1 = CacheKey::new(1, 1.0, false); - cache.insert(key0, MockCustomPage { id: 100, size: 600_000 }); - cache.insert(key1, MockCustomPage { id: 101, size: 600_000 }); + cache.insert( + key0, + MockCustomPage { + id: 100, + size: 600_000, + }, + ); + cache.insert( + key1, + MockCustomPage { + id: 101, + size: 600_000, + }, + ); // Total 1.2 MB > 1 MB, key0 must be evicted assert!(cache.get(&key0).is_none()); - assert_eq!(cache.get(&key1), Some(MockCustomPage { id: 101, size: 600_000 })); + assert_eq!( + cache.get(&key1), + Some(MockCustomPage { + id: 101, + size: 600_000 + }) + ); assert_eq!(cache.len(), 1); assert_eq!(cache.memory_usage(), 600_000); } diff --git a/crates/pdf_viewer/src/pdf_viewer.rs b/crates/pdf_viewer/src/pdf_viewer.rs index 6b76e4c532..0f157980b6 100644 --- a/crates/pdf_viewer/src/pdf_viewer.rs +++ b/crates/pdf_viewer/src/pdf_viewer.rs @@ -19,7 +19,7 @@ pub mod ui; pub mod view; pub mod watcher; -pub use cache::{CacheKey, CacheablePage, PageLruCache, RenderedPage, DEFAULT_MEMORY_BUDGET_BYTES}; +pub use cache::{CacheKey, CacheablePage, DEFAULT_MEMORY_BUDGET_BYTES, PageLruCache, RenderedPage}; pub use document::{PageDimensions, PdfDocument}; pub use pdfium::{ PdfDocumentDetails, PdfLinkAnnotation, PdfPageDetails, PdfTextSegment, PdfiumEngine, @@ -27,5 +27,5 @@ pub use pdfium::{ pub use rasterizer::{LuminosityToneMapper, PageRasterizer, RasterizerOptions}; pub use settings::{DefaultZoomPolicy, PageLayoutMode, PdfViewerSettings}; pub use ui::{PdfToolbarAction, PdfToolbarState}; -pub use view::{PdfView, PdfViewEvent, MAX_ZOOM, MIN_ZOOM, PAGE_SPACING_PX, ZOOM_STEP}; +pub use view::{MAX_ZOOM, MIN_ZOOM, PAGE_SPACING_PX, PdfView, PdfViewEvent, ZOOM_STEP}; pub use watcher::{PdfReloadDebouncer, ViewerStateSnapshot}; diff --git a/crates/pdf_viewer/src/pdfium.rs b/crates/pdf_viewer/src/pdfium.rs index 987539733b..025cb7eaae 100644 --- a/crates/pdf_viewer/src/pdfium.rs +++ b/crates/pdf_viewer/src/pdfium.rs @@ -135,7 +135,9 @@ impl PdfiumEngine { } } - log::warn!("Dynamic libpdfium not found in standard system paths; engine will use synthetic fallback"); + log::warn!( + "Dynamic libpdfium not found in standard system paths; engine will use synthetic fallback" + ); None } @@ -445,7 +447,10 @@ impl PdfiumEngine { #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct PdfLinkAnnotation { + /// Target URL for external links; empty for internal (in-document) links. pub url: String, + /// Zero-based target page for internal links; `None` for external links. + pub dest_page: Option, pub x: f32, pub y: f32, pub width: f32, @@ -672,6 +677,7 @@ fn extract_page_text_and_links( links.push(PdfLinkAnnotation { url: url_clean, + dest_page: None, x: norm_x, y: norm_y, width: norm_w, @@ -681,6 +687,32 @@ fn extract_page_text_and_links( } } } + + if let Some(PdfAction::LocalDestination(dest_action)) = link.action() { + if let Ok(dest_page) = dest_action + .destination() + .and_then(|destination| destination.page_index()) + { + if let Ok(rect) = link.rect() { + let left = rect.left().value.min(rect.right().value); + let right = rect.left().value.max(rect.right().value); + let bottom = rect.bottom().value.min(rect.top().value); + let top = rect.bottom().value.max(rect.top().value); + + let (norm_x, norm_y, norm_w, norm_h) = + normalize_coordinates(left, right, bottom, top, page_w, page_h); + + links.push(PdfLinkAnnotation { + url: String::new(), + dest_page: Some(dest_page as usize), + x: norm_x, + y: norm_y, + width: norm_w, + height: norm_h, + }); + } + } + } } // 3. Links from page.annotations() @@ -708,6 +740,7 @@ fn extract_page_text_and_links( if !exists { links.push(PdfLinkAnnotation { url: url_clean, + dest_page: None, x: norm_x, y: norm_y, width: norm_w, @@ -720,6 +753,44 @@ fn extract_page_text_and_links( } } } + + if let Some(link_annot) = annot.as_link_annotation() { + if let Ok(link) = link_annot.link() { + if let Some(PdfAction::LocalDestination(dest_action)) = link.action() { + if let Ok(dest_page) = dest_action + .destination() + .and_then(|destination| destination.page_index()) + { + if let Ok(rect) = link.rect() { + let left = rect.left().value.min(rect.right().value); + let right = rect.left().value.max(rect.right().value); + let bottom = rect.bottom().value.min(rect.top().value); + let top = rect.bottom().value.max(rect.top().value); + + let (norm_x, norm_y, norm_w, norm_h) = + normalize_coordinates(left, right, bottom, top, page_w, page_h); + + let dest_page = dest_page as usize; + let exists = links.iter().any(|l| { + l.dest_page == Some(dest_page) + && (l.x - norm_x).abs() < 0.01 + && (l.y - norm_y).abs() < 0.01 + }); + if !exists { + links.push(PdfLinkAnnotation { + url: String::new(), + dest_page: Some(dest_page), + x: norm_x, + y: norm_y, + width: norm_w, + height: norm_h, + }); + } + } + } + } + } + } } // 4. Text URLs from segments (heuristic for raw text URLs) @@ -737,6 +808,7 @@ fn extract_page_text_and_links( if !exists { links.push(PdfLinkAnnotation { url, + dest_page: None, x: seg.x, y: seg.y, width: seg.width, diff --git a/crates/pdf_viewer/src/view.rs b/crates/pdf_viewer/src/view.rs index cd38fee189..81e8bcf7c5 100644 --- a/crates/pdf_viewer/src/view.rs +++ b/crates/pdf_viewer/src/view.rs @@ -4,7 +4,7 @@ use anyhow::{Context as _, Result}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use crate::cache::{CacheKey, PageLruCache, RenderedPage, DEFAULT_MEMORY_BUDGET_BYTES}; +use crate::cache::{CacheKey, DEFAULT_MEMORY_BUDGET_BYTES, PageLruCache, RenderedPage}; use crate::document::{PageDimensions, PdfDocument}; use crate::pdfium::PdfiumEngine; use crate::rasterizer::RasterizerOptions; diff --git a/crates/pdf_viewer/src/watcher.rs b/crates/pdf_viewer/src/watcher.rs index 1262708ec0..ae9fce43b7 100644 --- a/crates/pdf_viewer/src/watcher.rs +++ b/crates/pdf_viewer/src/watcher.rs @@ -9,8 +9,8 @@ //! 3. Active page index and selection states. use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; /// Snapshot of viewer position to restore seamlessly across document reloads.