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
19pub(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
163pub 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 #[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}