use std::io; use crossterm::style::Stylize; use linkify::{LinkFinder, LinkKind}; use toss::widgets::{BoxedAsync, Text}; use toss::{Style, Styled, WidgetExt}; use crate::ui::input::{key, InputEvent, KeyBindingsList}; use crate::ui::widgets::{ListState, Popup}; use crate::ui::UiError; pub struct LinksState { links: Vec, list: ListState, } pub enum EventResult { NotHandled, Handled, Close, ErrorOpeningLink { link: String, error: io::Error }, } const NUMBER_KEYS: [char; 10] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']; impl LinksState { pub fn new(content: &str) -> Self { let links = LinkFinder::new() .url_must_have_scheme(false) .kinds(&[LinkKind::Url]) .links(content) .map(|l| l.as_str().to_string()) .collect(); Self { links, list: ListState::new(), } } pub fn widget(&mut self) -> BoxedAsync<'_, UiError> { let style_selected = Style::new().black().on_white(); let mut list = self.list.widget(); if self.links.is_empty() { list.add_unsel(Text::new(("No links found", Style::new().grey().italic()))) } for (id, link) in self.links.iter().enumerate() { #[allow(clippy::collapsible_else_if)] let text = if list.state().selected() == Some(&id) { if let Some(number_key) = NUMBER_KEYS.get(id) { Styled::new(format!("[{number_key}]"), style_selected.bold()) .then(" ", style_selected) .then(link, style_selected) } else { Styled::new(format!(" {link}"), style_selected) } } else { if let Some(number_key) = NUMBER_KEYS.get(id) { Styled::new(format!("[{number_key}]"), Style::new().dark_grey().bold()) .then_plain(" ") .then_plain(link) } else { Styled::new_plain(format!(" {link}")) } }; list.add_sel(id, Text::new(text)); } Popup::new(list, "Links").boxed_async() } fn open_link_by_id(&self, id: usize) -> EventResult { if let Some(link) = self.links.get(id) { // The `http://` or `https://` schema is necessary for open::that to // successfully open the link in the browser. let link = if link.starts_with("http://") || link.starts_with("https://") { link.clone() } else { format!("https://{link}") }; if let Err(error) = open::that(&link) { return EventResult::ErrorOpeningLink { link, error }; } } EventResult::Handled } fn open_link(&self) -> EventResult { if let Some(id) = self.list.selected() { self.open_link_by_id(*id) } else { EventResult::Handled } } pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { bindings.binding("esc", "close links popup"); bindings.binding("j/k, ↓/↑", "move cursor up/down"); bindings.binding("g, home", "move cursor to top"); bindings.binding("G, end", "move cursor to bottom"); bindings.binding("ctrl+y/e", "scroll up/down"); bindings.empty(); bindings.binding("enter", "open selected link"); bindings.binding("1,2,...", "open link by position"); } pub fn handle_input_event(&mut self, event: &InputEvent) -> EventResult { match event { key!(Esc) => return EventResult::Close, key!('k') | key!(Up) => self.list.move_cursor_up(), key!('j') | key!(Down) => self.list.move_cursor_down(), key!('g') | key!(Home) => self.list.move_cursor_to_top(), key!('G') | key!(End) => self.list.move_cursor_to_bottom(), key!(Ctrl + 'y') => self.list.scroll_up(1), key!(Ctrl + 'e') => self.list.scroll_down(1), key!(Enter) => return self.open_link(), key!('1') => return self.open_link_by_id(0), key!('2') => return self.open_link_by_id(1), key!('3') => return self.open_link_by_id(2), key!('4') => return self.open_link_by_id(3), key!('5') => return self.open_link_by_id(4), key!('6') => return self.open_link_by_id(5), key!('7') => return self.open_link_by_id(6), key!('8') => return self.open_link_by_id(7), key!('9') => return self.open_link_by_id(8), key!('0') => return self.open_link_by_id(9), _ => return EventResult::NotHandled, } EventResult::Handled } }