1use crate::theme::Theme;
2use ratatui::prelude::*;
3use ratatui::widgets::*;
4use std::collections::HashSet;
5
6#[derive(Debug, Clone)]
7pub struct UrlEntry {
8 pub label: String,
9 pub url: String,
10}
11
12#[derive(Debug, Clone)]
13pub struct UrlModalState {
14 pub urls: Vec<UrlEntry>,
15 pub selected: usize,
16}
17
18impl UrlModalState {
19 pub fn new(urls: Vec<UrlEntry>) -> Self {
20 Self { urls, selected: 0 }
21 }
22
23 pub fn next(&mut self) {
24 if !self.urls.is_empty() {
25 self.selected = (self.selected + 1).min(self.urls.len() - 1);
26 }
27 }
28
29 pub fn prev(&mut self) {
30 self.selected = self.selected.saturating_sub(1);
31 }
32
33 pub fn selected_url(&self) -> Option<&str> {
34 self.urls.get(self.selected).map(|e| e.url.as_str())
35 }
36}
37
38pub fn draw(frame: &mut Frame, area: Rect, state: Option<&UrlModalState>, theme: &Theme) {
39 let Some(state) = state else {
40 return;
41 };
42
43 let popup = centered_rect(60, 55, area);
44 frame.render_widget(Clear, popup);
45
46 let title = format!(" Links ({}) ", state.urls.len());
47 let block = Block::bordered()
48 .title(title)
49 .border_type(BorderType::Rounded)
50 .border_style(Style::default().fg(theme.accent))
51 .style(Style::default().bg(theme.modal_bg));
52
53 let inner = block.inner(popup);
54 frame.render_widget(block, popup);
55
56 let chunks = Layout::default()
57 .direction(Direction::Vertical)
58 .constraints([Constraint::Min(4), Constraint::Length(2)])
59 .split(inner);
60
61 let items: Vec<ListItem> = state
62 .urls
63 .iter()
64 .enumerate()
65 .map(|(i, entry)| {
66 let is_selected = i == state.selected;
67 if entry.label == entry.url {
68 let style = if is_selected {
69 Style::default()
70 .fg(theme.link_fg)
71 .add_modifier(Modifier::UNDERLINED | Modifier::BOLD)
72 } else {
73 Style::default()
74 .fg(theme.link_fg)
75 .add_modifier(Modifier::UNDERLINED)
76 };
77 ListItem::new(Line::from(Span::styled(entry.url.clone(), style)))
78 } else {
79 let label_style = if is_selected {
80 Style::default().fg(theme.text_primary).bold()
81 } else {
82 Style::default().fg(theme.text_secondary)
83 };
84 let url_style = if is_selected {
85 Style::default()
86 .fg(theme.link_fg)
87 .add_modifier(Modifier::UNDERLINED)
88 } else {
89 Style::default().fg(theme.text_muted)
90 };
91 ListItem::new(vec![
92 Line::from(Span::styled(entry.label.clone(), label_style)),
93 Line::from(Span::styled(format!(" {}", entry.url), url_style)),
94 ])
95 }
96 })
97 .collect();
98
99 let list = List::new(items).highlight_style(theme.highlight_style());
100 let mut list_state = ListState::default().with_selected(Some(state.selected));
101 frame.render_stateful_widget(list, chunks[0], &mut list_state);
102
103 let list_height = chunks[0].height as usize;
104 if state.urls.len() > list_height {
105 let mut scrollbar_state = ScrollbarState::new(state.urls.len().saturating_sub(list_height))
106 .position(state.selected);
107 frame.render_stateful_widget(
108 Scrollbar::default()
109 .orientation(ScrollbarOrientation::VerticalRight)
110 .thumb_style(Style::default().fg(theme.accent)),
111 chunks[0],
112 &mut scrollbar_state,
113 );
114 }
115
116 let footer = "Enter/o open j/k move y copy Esc close";
117 frame.render_widget(
118 Paragraph::new(footer).style(Style::default().fg(theme.text_secondary)),
119 chunks[1],
120 );
121}
122
123pub fn extract_urls(text_plain: Option<&str>, text_html: Option<&str>) -> Vec<UrlEntry> {
127 let mut urls = Vec::new();
128 let mut seen = HashSet::new();
129
130 if let Some(html) = text_html {
132 let mut rest = html;
133 while let Some(href_start) = rest.find("href=\"") {
134 let after_href = &rest[href_start + 6..];
135 if let Some(href_end) = after_href.find('"') {
136 let url = &after_href[..href_end];
137 let after_tag = &after_href[href_end..];
138 let label = if let Some(gt) = after_tag.find('>') {
139 let after_gt = &after_tag[gt + 1..];
140 if let Some(close) = after_gt.find("</a>") {
141 let label_text = after_gt[..close].trim();
142 let clean = strip_html_tags(label_text);
143 if clean.is_empty() {
144 url.to_string()
145 } else {
146 clean
147 }
148 } else {
149 url.to_string()
150 }
151 } else {
152 url.to_string()
153 };
154
155 if url.starts_with("http") && seen.insert(url.to_string()) {
156 urls.push(UrlEntry {
157 label,
158 url: url.to_string(),
159 });
160 }
161 }
162 rest = &rest[href_start + 6..];
163 }
164 }
165
166 for text in [text_plain, text_html].into_iter().flatten() {
168 extract_plain_urls(text, &mut urls, &mut seen);
169 }
170
171 urls
172}
173
174fn extract_plain_urls(text: &str, urls: &mut Vec<UrlEntry>, seen: &mut HashSet<String>) {
175 let mut rest = text;
176 while let Some(start) = next_url_start(rest) {
177 let url_rest = &rest[start..];
178 let end = url_rest
179 .find(|c: char| {
180 c.is_whitespace()
181 || c == '>'
182 || c == ')'
183 || c == ']'
184 || c == '"'
185 || c == '<'
186 || c == '\''
187 })
188 .unwrap_or(url_rest.len());
189 let url = url_rest[..end].trim_end_matches(['.', ',', ';', ':', '!', '?']);
190
191 if seen.insert(url.to_string()) {
192 urls.push(UrlEntry {
193 label: url.to_string(),
194 url: url.to_string(),
195 });
196 }
197 rest = &rest[start + end..];
198 }
199}
200
201fn next_url_start(text: &str) -> Option<usize> {
202 match (text.find("https://"), text.find("http://")) {
203 (Some(https), Some(http)) => Some(https.min(http)),
204 (Some(https), None) => Some(https),
205 (None, Some(http)) => Some(http),
206 (None, None) => None,
207 }
208}
209
210fn strip_html_tags(text: &str) -> String {
211 let mut result = String::new();
212 let mut in_tag = false;
213 for c in text.chars() {
214 match c {
215 '<' => in_tag = true,
216 '>' => in_tag = false,
217 _ if !in_tag => result.push(c),
218 _ => {}
219 }
220 }
221 result.trim().to_string()
222}
223
224fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
225 let vertical = Layout::default()
226 .direction(Direction::Vertical)
227 .constraints([
228 Constraint::Percentage((100 - percent_y) / 2),
229 Constraint::Percentage(percent_y),
230 Constraint::Percentage((100 - percent_y) / 2),
231 ])
232 .split(area);
233
234 Layout::default()
235 .direction(Direction::Horizontal)
236 .constraints([
237 Constraint::Percentage((100 - percent_x) / 2),
238 Constraint::Percentage(percent_x),
239 Constraint::Percentage((100 - percent_x) / 2),
240 ])
241 .split(vertical[1])[1]
242}
243
244pub fn open_url(url: &str) {
245 #[cfg(target_os = "macos")]
246 {
247 let _ = std::process::Command::new("open").arg(url).spawn();
248 }
249 #[cfg(target_os = "linux")]
250 {
251 let _ = std::process::Command::new("xdg-open").arg(url).spawn();
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn extract_plain_text_urls() {
261 let urls = extract_urls(
262 Some("Check out https://example.com and http://test.org/page"),
263 None,
264 );
265 assert_eq!(urls.len(), 2);
266 assert_eq!(urls[0].url, "https://example.com");
267 assert_eq!(urls[1].url, "http://test.org/page");
268 }
269
270 #[test]
271 fn extract_html_anchor_urls() {
272 let html = r#"<a href="https://example.com">Example Site</a>"#;
273 let urls = extract_urls(None, Some(html));
274 assert_eq!(urls.len(), 1);
275 assert_eq!(urls[0].url, "https://example.com");
276 assert_eq!(urls[0].label, "Example Site");
277 }
278
279 #[test]
280 fn deduplicates_urls() {
281 let plain = "Visit https://example.com for more";
282 let html = r#"<a href="https://example.com">Example</a>"#;
283 let urls = extract_urls(Some(plain), Some(html));
284 assert_eq!(urls.len(), 1);
285 assert_eq!(urls[0].label, "Example");
287 }
288
289 #[test]
290 fn strips_trailing_punctuation() {
291 let urls = extract_urls(Some("See https://example.com."), None);
292 assert_eq!(urls[0].url, "https://example.com");
293 }
294
295 #[test]
296 fn modal_state_navigation() {
297 let mut state = UrlModalState::new(vec![
298 UrlEntry {
299 label: "A".into(),
300 url: "https://a.com".into(),
301 },
302 UrlEntry {
303 label: "B".into(),
304 url: "https://b.com".into(),
305 },
306 UrlEntry {
307 label: "C".into(),
308 url: "https://c.com".into(),
309 },
310 ]);
311 assert_eq!(state.selected, 0);
312 state.next();
313 assert_eq!(state.selected, 1);
314 state.next();
315 assert_eq!(state.selected, 2);
316 state.next();
317 assert_eq!(state.selected, 2); state.prev();
319 assert_eq!(state.selected, 1);
320 state.prev();
321 assert_eq!(state.selected, 0);
322 state.prev();
323 assert_eq!(state.selected, 0); }
325}