Skip to content

Conversation

@perhurt
Copy link
Contributor

@perhurt perhurt commented Aug 21, 2025

Implements basic link support for tdf

  • shows the number of links in the footer: Tab: X links
  • pressing opens a menu with the links on the current page
  • the menu is traversed using
  • links in the menu can be opened () or copied ('c').
  • 'q' is used to close the link menu
  • if the page is changed when the link menu is open, the links for that page is shown

The link support is intended to be very easy to use when using the keyboard. Just yourself to the right link and open or copy it.

Things in need of consideration:

  • Testing: only tested on macOS. Should work on other platforms as cross-platform crates are used for copying and link-opening
  • Is this enough? Currently only url links are supported. One could implement support for all kinds of links
  • Is the approach ok? Personally (of course), I think this solution is preferable to having links highlighted and clickable in the document (as tdf is a terminal-based viewer) 😄

  - Extract links from PDF pages
  - Add interactive links menu with Tab key navigation
  - Support link actions: Enter (open with system app), c (copy URL)
  - Display footer status showing link count when present
  - Implement URL opening and clipboard functionality
  - Add visual link selection with ">" indicator and truncation for long URLs
  - Add copypasta 0.10.2 dependency for cross-platform clipboard support
  - Add open 5.3.2 dependency for cross-platform URL opening
  - removed remains of an early attempt using annotated links
  - removed clippy errors/warnings
  removed leftover code trying to handle internal links. If that is a
  desirable feature, it can be added separately.
Copy link
Owner

@itsjunetime itsjunetime left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, I really do like this. Thank you so much for working on it, it looks really good so far and I really appreciate your time.

There are a few questions and nits I would like resolved before we merge this in. Most of those are included in my linked comments, but for those that aren't:

  1. You mentioned in your comment above that q should quit the link menu, but it seems that only esc quits that menu, while q still quits the whole app. Is that intended?
  2. In my quick test, it didn't show the links for the page that was currently displayed - it showed them for the next page. Afaict, your code to load the links looks correct, so I'd guess this is a weird mishandling of the mupdf C api? I don't really know for sure, so I'll try to look into that soon.

Also, I think it could be nice to maybe make this instead highlight the links on the page (instead of just listing them) and show the whole link at the bottom of the page when you have it highlighted, but that's definitely more complicated, so I think that should be a project for the future. And it'll definitely be much easier to do once we get this infrastructure merged in.


fn extract_page_links(page: &Page) -> Result<Vec<Link>, mupdf::error::Error> {
let links = page.links()?;
let mut unique_links = Vec::new();
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imo if a page has multiple of the same link, it's still useful to show them.

For example, imagine if a page has 3 links, where the first and third link to the same site, but the document has the hyperlinks hidden behind normal text so people can't see exactly where each link links to, they can just see that it's a link. In this case, it would be better to display all three so that people can easily correlate what link (on the link menu) corresponds to what text (on the displayed page).

If only two links are shown, that could be confusing and they may not be sure which link they actually want to click on, since they can't tell what link corresponds to what bit of text.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I made my initial "link menu design", the repetition of links didn't seem logical to me. However, if we're going from menu -> highlighting of links in the document, this makes total sense.


for link in links {
// Only include HTTP/HTTPS URLs, skip internal links and empty URIs
if link.uri.starts_with("http") {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the future, we probably want to extend this to support jumping to a different part of the doc, but for now I think this is good.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. When we have a good implementation of the basic http-functionality, I'll try to make a pass over internal links


if self.showing_links_menu {
self.render_links_menu(frame);
return KittyDisplay::ClearImages;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly imo it would look better to not clear the images, but rather just show the link menu on top of the doc, clearing the part of the image that's underneath it. I'm not too opinionated, though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this will go away if we remove the menu

Comment on lines +332 to +350
let mut to_display = Vec::new();

for (idx, (width, img)) in page_widths.into_iter().enumerate() {
let page_rect = Rect { width, ..img_area };
let page_num = idx + self.page;

// Render the page
let maybe_img = Self::render_single_page(frame, img, page_rect);

img_area.x += width;
if let Some((img, pos)) = maybe_img {
to_display.push(KittyReadyToDisplay {
img,
page_num: idx + self.page,
page_num,
pos,
display_loc: DisplayLocation::default()
})
})
.collect::<Vec<_>>();
});
}
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason for this change? Or is it just stylistic?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me check this more thoroughyl. I think there is a reason, but I have forgotten it. Should've answered this immediately when you wrote the question:P

Comment on lines +539 to +542
spans.push(Span::styled(
format!("Tab: {} links", rendered[page_num].links.len()),
Style::new().fg(Color::Yellow)
));
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love how this bottom section looks, great job there.

Comment on lines +769 to +775
if let Some(rendered_info) = self.rendered.get(self.page) {
if let Some(link) = rendered_info.links.get(self.selected_link_index) {
if link.uri.starts_with("http") {
let _ = open::that(&link.uri);
}
}
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if let Some(rendered_info) = self.rendered.get(self.page) {
if let Some(link) = rendered_info.links.get(self.selected_link_index) {
if link.uri.starts_with("http") {
let _ = open::that(&link.uri);
}
}
}
if let Some(rendered_info) = self.rendered.get(self.page) &&
let Some(link) = rendered_info.links.get(self.selected_link_index) &&
link.uri.starts_with("http")
{
let _ = open::that(&link.uri);
}

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also think the error should be bubbled up to the user here if possible, I just can't remember how to do that off the top of my head

KeyCode::Tab => {
if self.showing_links_menu {
// Cycle through links when menu is open
if let Some(rendered_info) = self.rendered.get(self.page) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I won't add another suggestion since they'll probably need to be fixed by rustfmt anyways, but I think collapsing the ifs here with let-chains would be nice. I know they were stabilized after the current msrv, but I'm more than happy to bump that so we can use let chains.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And also, since this if let and if !rendered_info.links.is_empty is present in both branches, maybe we should pull that outside of the if self.showing_links_menu

frame.render_widget(Clear, frame_area);

let block = Block::new()
.title("Links in Current Page (Tab: cycle, Enter: open, c: copy, Esc: close)")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm kinda conflicted on having the help text here in the title. I think it would be better to either:

  1. Move it to the footer (so that it doesn't make the block so wide and doesn't create as much visual noise for someone who already knows the actions), OR
  2. Make another tab on the help view that just shows these keybindings

What do you think?

Comment on lines +1013 to +1015
format!("> {}. ", i + 1) // Selected item gets ">" prefix
} else {
format!(" {}. ", i + 1) // Normal items get spaces
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we use the fill/alignment formatting specifiers to left-pad the number to ensure they're all aligned if there's 10+ links?

.map(str::len)
.max()
.unwrap_or(30)
.min(80)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'm overengineering this, but I think it would be nice to have this be dynamically computed - e.g.

const MAX_ALLOWED_LINK_LEN: usize = 70;
// ... later on
// 5 for `...` and `> .` at beginning, `log(10)` for longest number display, 4 for padding
.min(MAX_ALLOWED_LINK_LEN + 3 + 2 + links_text.len().log(10) + 4)

I don't know if log(10) is actually exactly the calculation we want, but I think you get the idea.

@perhurt
Copy link
Contributor Author

perhurt commented Aug 22, 2025

Let me answer your two main comments before going into the code :)

Overall, I really do like this. Thank you so much for working on it, it looks really good so far and I really appreciate your time.

There are a few questions and nits I would like resolved before we merge this in. Most of those are included in my linked comments, but for those that aren't:

  1. You mentioned in your comment above that q should quit the link menu, but it seems that only esc quits that menu, while q still quits the whole app. Is that intended?

I actually don't remember, but would guess it's an "advanced typo". It would be silly to flip the key binding logic for this specific feature. Let's rewrite the menu text.

  1. In my quick test, it didn't show the links for the page that was currently displayed - it showed them for the next page. Afaict, your code to load the links looks correct, so I'd guess this is a weird mishandling of the mupdf C api? I don't really know for sure, so I'll try to look into that soon.

When I did my tests I didn't notice any such problems. I had the correct page<->links mapping the whole time, even when the reader was wide enough to display 3-4 pages in parallell.

What operating system do you use? I did my tests on macOS. I guess the debugging has to start on this level :)

Also, I think it could be nice to maybe make this instead highlight the links on the page (instead of just listing them) and show the whole link at the bottom of the page when you have it highlighted, but that's definitely more complicated, so I think that should be a project for the future. And it'll definitely be much easier to do once we get this infrastructure merged in.

That seems like a good idea! I fumbled around testing different approaches and chose the current one for some reason. Let's try your approach!

I will address the rest of your comments after making a pass at these high-level issues. Maybe some of them will automagically solve themselves

@itsjunetime
Copy link
Owner

I actually don't remember, but would guess it's an "advanced typo". It would be silly to flip the key binding logic for this specific feature. Let's rewrite the menu text.

Sounds good

When I did my tests I didn't notice any such problems. I had the correct page<->links mapping the whole time, even when the reader was wide enough to display 3-4 pages in parallell.

What operating system do you use? I did my tests on macOS. I guess the debugging has to start on this level :)

Sorry, this one error is on me - I downloaded a test doc that had a confusing mix of intra-doc and internet links and thought it wasn't working correctly. This code seems to do what it was intended to do, so you're all good there.

That seems like a good idea! I fumbled around testing different approaches and chose the current one for some reason. Let's try your approach!

Sounds good, then - if you'd like to refactor this to do that instead, I'm super chill with it. If you'd rather leave that off for later, though, I totally understand. I think the menu you've added in this PR is really good as it is.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants