Skip to main content

feed/
display.rs

1use chrono::{DateTime, Utc};
2use terminal_size::{terminal_size, Width};
3use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
4
5use crate::config::Config;
6
7pub struct DisplayItem<'a> {
8    pub title: &'a str,
9    pub url: &'a str,
10    pub published: Option<DateTime<Utc>>,
11}
12
13pub(crate) fn term_width() -> usize {
14    terminal_size()
15        .map(|(Width(w), _)| w as usize)
16        .unwrap_or(80)
17}
18
19/// Wraps displayed text in an OSC 8 hyperlink escape sequence.
20/// The terminal shows `text` but clicking it opens `url`.
21pub(crate) fn osc8_hyperlink(url: &str, text: &str) -> String {
22    format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\", url, text)
23}
24
25pub fn render_article_list(title: &str, entries: &[DisplayItem]) -> String {
26    let mut lines = Vec::new();
27
28    lines.push(format!("\n {}\n", title));
29
30    if entries.is_empty() {
31        lines.push(" (no entries)".to_string());
32        return lines.join("\n");
33    }
34
35    let width = term_width();
36    let fixed = 15;
37    let available = width.saturating_sub(fixed);
38
39    let actual_max_title = entries
40        .iter()
41        .map(|e| display_width(e.title))
42        .max()
43        .unwrap_or(0);
44    let max_title_width = actual_max_title.min(available / 2);
45
46    for entry in entries {
47        let date = entry
48            .published
49            .map(|dt| dt.format("%Y-%m-%d").to_string())
50            .unwrap_or_else(|| "          ".to_string());
51        let title_w = display_width(entry.title);
52        let title_text = if title_w > max_title_width {
53            truncate(entry.title, max_title_width)
54        } else {
55            entry.title.to_string()
56        };
57        let actual_title_w = display_width(&title_text);
58        let title = osc8_hyperlink(entry.url, &title_text);
59        let padding: String = " ".repeat(max_title_width.saturating_sub(actual_title_w));
60        let url_budget = width.saturating_sub(fixed + max_title_width);
61        let url_text = if display_width(entry.url) > url_budget && url_budget > 0 {
62            truncate(entry.url, url_budget)
63        } else {
64            entry.url.to_string()
65        };
66        let url = osc8_hyperlink(entry.url, &url_text);
67        lines.push(format!(" {}  {}{}  {}", date, title, padding, url));
68    }
69
70    lines.join("\n")
71}
72
73pub fn render_feed_list(config: &Config) -> String {
74    let feeds: Vec<(String, String, Vec<String>)> = config
75        .feeds
76        .iter()
77        .map(|f| (f.name.clone(), f.url.clone(), f.tags.clone()))
78        .collect();
79
80    if feeds.is_empty() {
81        return " No feeds registered. Use `feed add <url>` to add one.".to_string();
82    }
83
84    let width = term_width();
85    let available = width.saturating_sub(3);
86
87    let actual_max_name = feeds
88        .iter()
89        .map(|(n, _, _)| display_width(n))
90        .max()
91        .unwrap_or(0);
92    let max_name = actual_max_name.min(available * 3 / 10);
93    let actual_max_url = feeds
94        .iter()
95        .map(|(_, u, _)| display_width(u))
96        .max()
97        .unwrap_or(0);
98    let max_url = actual_max_url.min(available * 6 / 10);
99
100    feeds
101        .iter()
102        .map(|(name, url, tags)| {
103            let padded_name = pad_or_truncate(name, max_name);
104            let url_truncated = truncate(url, max_url);
105            let url_w = display_width(&url_truncated);
106            let linked_url = osc8_hyperlink(url, &url_truncated);
107            let url_padding = " ".repeat(max_url.saturating_sub(url_w));
108            let tag_str = if tags.is_empty() {
109                String::new()
110            } else {
111                format!("  [{}]", tags.join(", "))
112            };
113            format!(" {}  {}{}{}", padded_name, linked_url, url_padding, tag_str)
114        })
115        .collect::<Vec<_>>()
116        .join("\n")
117}
118
119pub fn render_tag_list(config: &Config) -> String {
120    let tags = config.all_tags();
121    if tags.is_empty() {
122        return " No tags found.".to_string();
123    }
124    tags.iter()
125        .map(|t| format!(" {}", t))
126        .collect::<Vec<_>>()
127        .join("\n")
128}
129
130pub fn display_width(s: &str) -> usize {
131    UnicodeWidthStr::width(s)
132}
133
134pub fn pad_or_truncate(s: &str, width: usize) -> String {
135    let current_width = display_width(s);
136    if current_width > width {
137        let ellipsis_width = UnicodeWidthChar::width('…').unwrap_or(1);
138        let mut result = String::new();
139        let mut w = 0;
140        for c in s.chars() {
141            let cw = UnicodeWidthChar::width(c).unwrap_or(0);
142            if w + cw + ellipsis_width > width {
143                break;
144            }
145            result.push(c);
146            w += cw;
147        }
148        result.push('…');
149        w += ellipsis_width;
150        for _ in w..width {
151            result.push(' ');
152        }
153        result
154    } else {
155        let mut result = s.to_string();
156        for _ in current_width..width {
157            result.push(' ');
158        }
159        result
160    }
161}
162
163/// Truncate string to fit within width, adding ellipsis if needed. No padding.
164pub fn truncate(s: &str, width: usize) -> String {
165    let current_width = display_width(s);
166    if current_width <= width {
167        return s.to_string();
168    }
169    let ellipsis_width = UnicodeWidthChar::width('…').unwrap_or(1);
170    let mut result = String::new();
171    let mut w = 0;
172    for c in s.chars() {
173        let cw = UnicodeWidthChar::width(c).unwrap_or(0);
174        if w + cw + ellipsis_width > width {
175            break;
176        }
177        result.push(c);
178        w += cw;
179    }
180    result.push('…');
181    result
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    // osc8_hyperlink wraps text in OSC 8 escape sequences with the correct URL.
189    #[test]
190    fn test_osc8_hyperlink_format() {
191        let result = osc8_hyperlink("https://example.com", "Click here");
192        assert!(result.contains("https://example.com"));
193        assert!(result.contains("Click here"));
194        assert!(result.starts_with("\x1b]8;;"));
195        assert!(result.ends_with("\x1b]8;;\x1b\\"));
196    }
197}