-
Notifications
You must be signed in to change notification settings - Fork 43
Improved internal PDF link handling and navigation #108
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
itsjunetime
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall, I think this is a fine approach, but there are a few concerns I have before merging it. I also think it's pertinent to point out that link support is also being worked on in #95, but that is a keyboard-oriented approach, so I don't think these two PRs conflict at all. It's totally fine to allow users to click or use their keyboard (though I think tdf should remain keyboard-forward, so to speak).
My biggest concern with this would be the details in your disclaimer. It seems that this doesn't work when multiple pages are on the screen? Is there any technical reason or just that you couldn't get it working? I'm happy to assist in figuring it out if there's no technical reason, as I don't think we could merge this if it doesn't work when multiple pages are displayed.
Feel free to re-request a review once you've responded to these concerns :)
src/main.rs
Outdated
| match resp_rx.recv_async().await { | ||
| Ok(Some(LinkTarget::Uri(uri))) => { | ||
| if let Err(e) = webbrowser::open(&uri) { | ||
| log::error!("Failed to open uri {}: {:?}", uri, e); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since this is a user-facing application, I think log should only be used for debugging and context, and the tui should be used to show errors like this. Probably the Tui::show_error or Tui::set_msg fns.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Replaced the log::error with Tui::set_msg to display the error in the app's bottom message area.
src/main.rs
Outdated
| to_renderer.send(RenderNotif::JumpToPage(page_index))?; | ||
| to_converter.send(ConverterMsg::GoToPage(page_index))?; | ||
| } | ||
| _ => (), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This error also shouldn't be swallowed; I doubt the channel will fail, but just in case it does, we want to either show that to the user or panic (if it requires that)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have made an update on this if the channel fails due to a programmer error or receiver disconnnected the error will be surfaced.
src/renderer.rs
Outdated
| let result = (|| -> Result<Option<LinkTarget>, mupdf::error::Error> { | ||
| // load the requested page and compute same scale as used when rendering | ||
| let page = doc.load_page(qpage as i32)?; | ||
| let bounds = page.bounds()?; | ||
| let page_dim = (bounds.x1 - bounds.x0, bounds.y1 - bounds.y0); | ||
| let scaled = scale_img_for_area(page_dim, (area_w, area_h), fit_or_fill); | ||
| let scale_factor = scaled.scale_factor; | ||
| let pdf_x = device_x_px / scale_factor; | ||
| let pdf_y = device_y_px / scale_factor; | ||
| if let Ok(mut links) = page.links() { | ||
| while let Some(link) = links.next() { | ||
| let lb = link.bounds; | ||
| if pdf_x >= lb.x0 && pdf_x <= lb.x1 && pdf_y >= lb.y0 && pdf_y <= lb.y1 { | ||
| // prefer URI if present and non-empty | ||
| if !link.uri.is_empty() { | ||
| return Ok(Some(LinkTarget::Uri(link.uri))); | ||
| } | ||
| if let Some(dest) = link.dest { | ||
| return Ok(Some(LinkTarget::GoTo { page_index: dest.loc.page_number as usize })); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Ok(None) | ||
| })(); | ||
| match result { | ||
| Ok(Some(t)) => { let _ = resp.send(Some(t)); }, | ||
| Ok(None) => { let _ = resp.send(None); }, | ||
| Err(e) => { | ||
| log::error!("Failed to query links: {e}"); | ||
| let _ = resp.send(None); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A few notes about this arm:
- Since this is inside a macro, auto-complete and syntax highlighting support isn't great, so this code would probably be best moved to its own function. That would help with this nested indentation as well.
- I understand the usefulness of the immediately-executed closure, but I think this would be better served by doing something like the following at the beginning:
let bounds = match doc.load_page(qpage as i32).and_then(Page::bounds) { Ok(b) => b, Err(e) => { resp.send(Err(e)).unwrap(); return; } }
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added a helper function query_link_at (above start_rendering that encapsulates the MuPDF link lookup and scaling math. It returns Result<Option<LinkTarget>, mupdf::error::Error>.
src/renderer.rs
Outdated
| Ok(Some(t)) => { let _ = resp.send(Some(t)); }, | ||
| Ok(None) => { let _ = resp.send(None); }, | ||
| Err(e) => { | ||
| log::error!("Failed to query links: {e}"); | ||
| let _ = resp.send(None); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Failure to send on this channel should probably result in a panic, since it means that our main thread has exited. But I think we should also try to report errors through the channel so that they can be reported to the user, thus making the channel a sender of Result<Option<LinkTarget>, String> (or whatever other error type would fit here)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
RenderNotif::QueryLinkAt now carries resp: Sender<Result<Option<LinkTarget>, String>>
The link-query handler now sends:
Ok(Some(LinkTarget)) when a link is found,
Ok(None) when none is found,
Err(String) when there was an internal error querying links.
src/renderer.rs
Outdated
| let page_dim = (bounds.x1 - bounds.x0, bounds.y1 - bounds.y0); | ||
| let scaled = scale_img_for_area(page_dim, (area_w, area_h), fit_or_fill); | ||
| let scale_factor = scaled.scale_factor; | ||
| let pdf_x = device_x_px / scale_factor; | ||
| let pdf_y = device_y_px / scale_factor; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll need to test out these conversions myself - converting between pixels and mupdf units and such has always been a horrible hassle that's given me a lot of trouble. Maybe I should take this opportunity to make it a bit easier and safer to do.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I totally get that those conversions are always tricky and easy to get wrong.
src/tui.rs
Outdated
| .map(|p| p.img.as_ref().map(|img| (img.w_h().0, img))) | ||
| .filter_map(|o| o) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| .map(|p| p.img.as_ref().map(|img| (img.w_h().0, img))) | |
| .filter_map(|o| o) | |
| .filter_map(|p| p.img.as_ref().map(|img| (img.w_h().0, img))) |
Or maybe you could do
| .map(|p| p.img.as_ref().map(|img| (img.w_h().0, img))) | |
| .filter_map(|o| o) | |
| .filter_map(|p| p.img.as_ref()) | |
| .map(|o| (img.w_h().0, img)) |
But I can't remember if the borrow checker will allow that
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe its better to leave as is because the two step:
.map(|p| p.img.as_ref().map(|img| (img.w_h().0, img))) .filter_map(|o| o)
is semantically the same as
.filter_map(|p| p.img.as_ref().map(|img| (img.w_h().0, img))) The compiler treats them equivalently
src/tui.rs
Outdated
| .take_while(|p| p.img.is_some()) | ||
| .map(|p| p.img.as_ref().map(|img| (img.w_h().0, img))) | ||
| .filter_map(|o| o) | ||
| .collect::<Vec<_>>(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wait, now that I'm looking at this, I don't think we need to recalculate this each time - last_render already contains unused_width, so we don't need to calculate it down below, and then we can just iterate through the pages one at a time without needing to collect them into an intermediate vec. So if you'd be able to change that, that would be great.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
kept a small intermediate vector I tried to render pages one-at-a-time without collecting, but that produced borrow-check errors because the code needs to hold multiple mutable references to images while constructing the final KittyDisplayreturn value.
src/tui.rs
Outdated
| let local_row = row - area.y; | ||
| let device_x = f32::from(local_col) * f32::from(font_size.0); | ||
| let device_y = f32::from(local_row) * f32::from(font_size.1); | ||
| return Some((page_num, device_x, device_y)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe it would be clearer to refer to these as 'mupdf' coordinates instead of 'device' coordinates? When I read 'device', I assumed it was referring to the terminal emulator as the 'device', but it seems it's referring to the mupdf rendering machine. Maybe you disagree, though; either's fine, just something to consider.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Made changes ans changed to mupdf
src/tui.rs
Outdated
| Invert, | ||
| Fullscreen, | ||
| SwitchRenderZoom(crate::FitOrFill) | ||
| Redraw, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Weird that these got reformatted to use spaces... did you cargo fmt this? (if not, you should)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
just did in my latest update
|
Added new updates, please review. |
itsjunetime
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think there are a still a few things that need to be cleaned up, but I do think it's an improvement.
Were you able to get this working with multiple pages showing? I think that's still the biggest roadblock if it's not functional yet.
Also, if you'd make sure the lockfile is in sync (just run cargo build, then add, commit, and push the Cargo.lock), that'll get CI going again.
| Ok(Ok(Some(LinkTarget::Uri(uri)))) => { | ||
| if let Err(e) = webbrowser::open(&uri) { | ||
| tui.set_msg(MessageSetting::Some(BottomMessage::Error( | ||
| format!("Failed to open uri {}: {e}", uri) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| format!("Failed to open uri {}: {e}", uri) | |
| format!("Failed to open uri {uri}: {e}") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did a build, I've looked at it for the past week Its working right now attached a screen recording. let me know the feedback
https://github.com/user-attachments/assets/69c6b3c3-b82d-4f25-936c-14750b2631fa
src/main.rs
Outdated
| if let Err(e) = to_renderer.send(RenderNotif::JumpToPage(page_index)) { | ||
| tui.set_msg(MessageSetting::Some(BottomMessage::Error( | ||
| format!("Failed to send jump-to-page to renderer: {e}") | ||
| ))); | ||
| } | ||
|
|
||
| if let Err(e) = to_converter.send(ConverterMsg::GoToPage(page_index)) { | ||
| tui.set_msg(MessageSetting::Some(BottomMessage::Error( | ||
| format!("Failed to send jump-to-page to converter: {e}") | ||
| ))); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I may be kinda going back on earlier suggestions, but I think these two sends specifically should short-circuit and cause the app to exit if they fail. We already do this below in the InputAction::JumpingToPage branch, and I think it still makes sense since these can only fail if the receiving thread exited (which would mean that a crucial part of the app is now inoperable and we should just force a restart).
| if let Err(e) = to_renderer.send(RenderNotif::JumpToPage(page_index)) { | |
| tui.set_msg(MessageSetting::Some(BottomMessage::Error( | |
| format!("Failed to send jump-to-page to renderer: {e}") | |
| ))); | |
| } | |
| if let Err(e) = to_converter.send(ConverterMsg::GoToPage(page_index)) { | |
| tui.set_msg(MessageSetting::Some(BottomMessage::Error( | |
| format!("Failed to send jump-to-page to converter: {e}") | |
| ))); | |
| } | |
| to_renderer.send(RenderNotif::JumpToPage(page_index))?; | |
| to_converter.send(ConverterMsg::GoToPage(page_index))?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah if the renderer or converter thread exits, the app is fundamentally broken and should restart. I'll replace those two error-handling blocks
src/renderer.rs
Outdated
| let pdf_y = mupdf_y_px / scale_factor; | ||
|
|
||
| if let Ok(mut links) = page.links() { | ||
| while let Some(link) = links.next() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any reason to use while let Some(_) = ... here instead of just for link in links ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've done updates on this
| } | ||
| if let Some(dest) = link.dest { | ||
| return Ok(Some(LinkTarget::GoTo { page_index: dest.loc.page_number as usize })); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This now makes me wonder what we should do if we encounter a link that has no destination and an empty URI - do you think we should report that to the user? Maybe that would just be a useless notification, since they probably don't care about links that effectively aren't links... idk, this is probably fine as-is, just something to think about in case you think something should be done there.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's fine as-is current behavior is intuitive: The click just has no effect, which is what users expect from a non-functional link. No cognitive load. also silent skip is the right behavior,Silently doing nothing is better than showing an error message for something that isn't really an error
| match query_link_at(&doc, qpage, mupdf_x_px, mupdf_y_px, area_w, area_h, fit_or_fill) { | ||
| Ok(Some(t)) => { | ||
| resp.send(Ok(Some(t))).unwrap_or_else(|e| { | ||
| panic!("Renderer failed to send link query response: {e}") | ||
| }); | ||
| } | ||
| Ok(None) => { | ||
| resp.send(Ok(None)).unwrap_or_else(|e| { | ||
| panic!("Renderer failed to send link query response: {e}") | ||
| }); | ||
| } | ||
| Err(e) => { | ||
| let err_str = format!("Failed to query links: {e}"); | ||
| resp.send(Err(err_str)).unwrap_or_else(|e| { | ||
| panic!("Renderer failed to send link-query error: {e}") | ||
| }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can be simplified a good bit, I think:
| match query_link_at(&doc, qpage, mupdf_x_px, mupdf_y_px, area_w, area_h, fit_or_fill) { | |
| Ok(Some(t)) => { | |
| resp.send(Ok(Some(t))).unwrap_or_else(|e| { | |
| panic!("Renderer failed to send link query response: {e}") | |
| }); | |
| } | |
| Ok(None) => { | |
| resp.send(Ok(None)).unwrap_or_else(|e| { | |
| panic!("Renderer failed to send link query response: {e}") | |
| }); | |
| } | |
| Err(e) => { | |
| let err_str = format!("Failed to query links: {e}"); | |
| resp.send(Err(err_str)).unwrap_or_else(|e| { | |
| panic!("Renderer failed to send link-query error: {e}") | |
| }); | |
| } | |
| match query_link_at(&doc, qpage, mupdf_x_px, mupdf_y_px, area_w, area_h, fit_or_fill) { | |
| Ok(t) => resp.send(Ok(t)), | |
| Err(e) => { | |
| let err_str = format!("Failed to query links: {e}"); | |
| resp.send(Err(err_str)) | |
| } | |
| }.unwrap_or_else(|e| { | |
| panic!("Renderer failed to send link-query error: {e}") | |
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're right
src/tui.rs
Outdated
| // We do this in two passes so we don't need to collect the images into an | ||
| // intermediate vector: first pass sums widths to determine centering, second | ||
| // pass renders pages one-at-a-time. We still avoid keeping references across | ||
| // the two passes. | ||
| let mut total_width: u16 = 0; | ||
| let mut pages_shown: usize = 0; | ||
|
|
||
| for (idx, page) in self.rendered[self.page..].iter_mut().enumerate() { | ||
| // only consider pages that have an image ready | ||
| if page.img.is_none() { | ||
| break; | ||
| } | ||
|
|
||
| if let Some(max) = self.page_constraints.max_wide { | ||
| if idx >= max.get() { | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| let width = page.img.as_mut().map(|img| img.w_h().0).unwrap_or(0); | ||
|
|
||
| // stop if this would overflow the available area | ||
| match total_width.checked_add(width) { | ||
| Some(new_total) if new_total <= img_area.width => { | ||
| total_width = new_total; | ||
| pages_shown += 1; | ||
| } | ||
| _ => break | ||
| } | ||
| } | ||
|
|
||
| if pages_shown == 0 { | ||
| // If none are ready to render, just show the loading thing | ||
| Self::render_loading_in(frame, img_area); | ||
| return KittyDisplay::ClearImages; | ||
| } | ||
|
|
||
| execute!(stdout(), BeginSynchronizedUpdate).unwrap(); | ||
|
|
||
| self.last_render.pages_shown = pages_shown; | ||
|
|
||
| let unused_width = img_area.width - total_width; | ||
| self.last_render.unused_width = unused_width; | ||
| img_area.x += unused_width / 2; | ||
|
|
||
| // Collect mutable references to the images for rendering in a single pass so | ||
| // we satisfy the borrow checker, then render them below. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, but I'm confused as to why this whole section is necessary - it seems to just be duplicating the work done below. Is there a reason that we need to do it twice, or is this doing something different?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Eliminated the double-pass in renderer refactored code now collects pages in a single filtering pass
src/tui.rs
Outdated
| // First, compute how many pages will be shown and their total width using the same | ||
| // greedy logic as `render` so our positioning matches exactly. | ||
| let mut total_width: u16 = 0; | ||
| let mut pages_shown: usize = 0; | ||
|
|
||
| for (idx, page) in self.rendered[self.page..].iter().enumerate() { | ||
| // only consider pages that have an image ready | ||
| if page.img.is_none() { | ||
| break; | ||
| } | ||
|
|
||
| if let Some(max) = self.page_constraints.max_wide { | ||
| if idx >= max.get() { | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| let width = page.img.as_ref().map(|img| img.w_h().0).unwrap_or(0); | ||
|
|
||
| match total_width.checked_add(width) { | ||
| Some(new_total) if new_total <= img_area.width => { | ||
| total_width = new_total; | ||
| pages_shown += 1; | ||
| } | ||
| _ => break, | ||
| } | ||
| } | ||
|
|
||
| if pages_shown == 0 { | ||
| return None; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should be able to trust self.last_render to still be correct, so we don't need to recompute all of this - we can just pull the pages_shown and total_width info directly from that field for this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done some optimization and now reusing values instead of recomputing them
|
Made updates and removed redundant code blocks |
Overview
Added click-to-open links inside PDFs — External URIs now open in the system browser.
Addresses #68.
Problem
Solution
LinkTargetdescribing a URI or an internal GoTo.webbrowsercrate.Changelog
LinkTargetenum (renderer) to represent found link actions:Uri(String)— external link to open in browser.GoTo { page_index: usize, optional x/y }— internal document destination.RenderNotif::QueryLinkAtmessage and one-shot reply channel so the UI can query the renderer for a link at given device pixel coordinates.QueryLinkAthandling in renderer:mupdf-sysfallback.QueryLinkAtand await the reply.Uri→ open withwebbrowser::open(url).GoTo→ sendRenderNotif::JumpToPageandConverterMsg::GoToPagefor navigation.webbrowserdependency toCargo.tomlto open external links cross-platform.Notes / Future Work
JumpToPageWithOffsetmessage so the viewer can center or scroll to the exact anchor.Disclaimer
Demo
tfd.mp4