Skip to main content

mxr_tui/ui/
url_modal.rs

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
123/// Extract URLs from message body text (plain text and/or HTML).
124/// HTML anchor tags get label extraction; plain-text URLs are also captured.
125/// Deduplicates by URL, preferring labeled entries from HTML anchors.
126pub 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    // Extract from HTML anchor tags first (they have labels)
131    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    // Extract plain text URLs from both plain text and HTML (for URLs not in anchors)
167    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        // HTML anchor version wins (has label)
286        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); // clamped
318        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); // clamped
324    }
325}