1use ratatui::{
2 style::{Color, Modifier, Style},
3 text::{Line, Span},
4};
5use stillo_core::document::{ExtractedContent, ExtractedLink};
6use url::Url;
7
8pub struct ContentView {
10 pub lines: Vec<Line<'static>>,
11 pub link_positions: Vec<(usize, usize)>,
13 pub scroll_offset: usize,
14 pub selected_link: Option<usize>,
15}
16
17impl ContentView {
18 pub fn from_content(content: &ExtractedContent) -> Self {
19 let mut converter = HtmlToLines::new(&content.links);
20 converter.convert(&content.body_html);
21
22 Self {
23 lines: converter.lines,
24 link_positions: converter.link_positions,
25 scroll_offset: 0,
26 selected_link: None,
27 }
28 }
29
30 pub fn total_lines(&self) -> usize {
31 self.lines.len()
32 }
33
34 pub fn scroll_down(&mut self, n: usize, viewport_height: usize) {
35 let max = self.lines.len().saturating_sub(viewport_height);
36 self.scroll_offset = (self.scroll_offset + n).min(max);
37 }
38
39 pub fn scroll_up(&mut self, n: usize) {
40 self.scroll_offset = self.scroll_offset.saturating_sub(n);
41 }
42
43 pub fn scroll_to_top(&mut self) {
44 self.scroll_offset = 0;
45 }
46
47 pub fn scroll_to_bottom(&mut self, viewport_height: usize) {
48 self.scroll_offset = self.lines.len().saturating_sub(viewport_height);
49 }
50
51 pub fn next_link(&mut self) {
52 if self.link_positions.is_empty() {
53 return;
54 }
55 self.selected_link = Some(match self.selected_link {
56 None => 0,
57 Some(i) => (i + 1).min(self.link_positions.len() - 1),
58 });
59 self.scroll_to_selected_link();
60 self.rebuild_link_highlights();
61 }
62
63 pub fn prev_link(&mut self) {
64 if self.link_positions.is_empty() {
65 return;
66 }
67 self.selected_link = Some(match self.selected_link {
68 None => 0,
69 Some(i) => i.saturating_sub(1),
70 });
71 self.scroll_to_selected_link();
72 self.rebuild_link_highlights();
73 }
74
75 pub fn selected_link_url<'a>(&self, links: &'a [ExtractedLink]) -> Option<&'a Url> {
76 let sel = self.selected_link?;
77 let (_, link_idx) = self.link_positions.get(sel)?;
78 links.get(*link_idx).map(|l| &l.href)
79 }
80
81 fn scroll_to_selected_link(&mut self) {
83 if let Some(sel) = self.selected_link {
84 if let Some(&(line_idx, _)) = self.link_positions.get(sel) {
85 if line_idx < self.scroll_offset {
86 self.scroll_offset = line_idx;
87 }
88 }
89 }
90 }
91
92 fn rebuild_link_highlights(&mut self) {
94 for (pos_idx, &(line_idx, _)) in self.link_positions.iter().enumerate() {
96 let is_selected = self.selected_link == Some(pos_idx);
97 if let Some(line) = self.lines.get_mut(line_idx) {
98 let style = if is_selected {
99 Style::default().fg(Color::Black).bg(Color::Cyan)
100 } else {
101 Style::default().fg(Color::Cyan)
102 };
103 *line = Line::styled(
105 line.spans
106 .iter()
107 .map(|s| s.content.as_ref().to_owned())
108 .collect::<Vec<_>>()
109 .join(""),
110 style,
111 );
112 }
113 }
114 }
115
116 pub fn search(&self, query: &str) -> Vec<usize> {
118 if query.is_empty() {
119 return vec![];
120 }
121 let q = query.to_lowercase();
122 self.lines
123 .iter()
124 .enumerate()
125 .filter(|(_, line)| {
126 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
127 text.to_lowercase().contains(&q)
128 })
129 .map(|(i, _)| i)
130 .collect()
131 }
132}
133
134struct HtmlToLines<'a> {
136 links: &'a [ExtractedLink],
137 pub lines: Vec<Line<'static>>,
138 pub link_positions: Vec<(usize, usize)>,
139 current_spans: Vec<Span<'static>>,
140 bold: bool,
141 italic: bool,
142 link_stack: Option<(usize, String)>,
144 list_depth: usize,
145 link_counter: usize,
146}
147
148impl<'a> HtmlToLines<'a> {
149 fn new(links: &'a [ExtractedLink]) -> Self {
150 Self {
151 links,
152 lines: Vec::new(),
153 link_positions: Vec::new(),
154 current_spans: Vec::new(),
155 bold: false,
156 italic: false,
157 link_stack: None,
158 list_depth: 0,
159 link_counter: 0,
160 }
161 }
162
163 fn convert(&mut self, html: &str) {
164 let mut pos = 0;
165 let bytes = html.as_bytes();
166
167 while pos < html.len() {
168 if bytes[pos] == b'<' {
169 if let Some(close) = html[pos..].find('>') {
170 let inner = &html[pos + 1..pos + close];
171 let (tag, attrs, is_closing, _) = parse_tag(inner);
172 self.handle_tag(&tag, attrs, is_closing);
173 pos += close + 1;
174 continue;
175 }
176 }
177 let next = html[pos..].find('<').map(|i| pos + i).unwrap_or(html.len());
178 let text = html_decode(&html[pos..next]);
179 if !text.is_empty() {
180 self.push_text(&text);
181 }
182 pos = next;
183 }
184
185 self.flush_line();
187 }
188
189 fn handle_tag(&mut self, tag: &str, attrs: &str, is_closing: bool) {
190 match (tag, is_closing) {
191 ("h1", false) => { self.flush_line(); self.push_text("# "); self.bold = true; }
192 ("h2", false) => { self.flush_line(); self.push_text("## "); self.bold = true; }
193 ("h3", false) => { self.flush_line(); self.push_text("### "); self.bold = true; }
194 ("h4" | "h5" | "h6", false) => { self.flush_line(); self.bold = true; }
195 ("h1" | "h2" | "h3" | "h4" | "h5" | "h6", true) => {
196 self.bold = false;
197 self.flush_line();
198 self.push_empty_line();
199 }
200 ("p", false) => { self.flush_line(); }
201 ("p", true) => { self.flush_line(); self.push_empty_line(); }
202 ("br", _) => { self.flush_line(); }
203 ("hr", _) => {
204 self.flush_line();
205 self.lines.push(Line::from(Span::styled(
206 "─".repeat(60),
207 Style::default().fg(Color::DarkGray),
208 )));
209 }
210 ("strong" | "b", false) => { self.bold = true; }
211 ("strong" | "b", true) => { self.bold = false; }
212 ("em" | "i", false) => { self.italic = true; }
213 ("em" | "i", true) => { self.italic = false; }
214 ("a", false) => {
215 let href = extract_attr(attrs, "href").unwrap_or_default();
217 let link_idx = self.links.iter().position(|l| l.href.as_str() == href
218 || l.href.as_str().trim_end_matches('/') == href.trim_end_matches('/'));
219 let idx = link_idx.unwrap_or(self.link_counter);
220 self.link_stack = Some((idx, String::new()));
221 self.link_counter += 1;
222 }
223 ("a", true) => {
224 if let Some((link_idx, text)) = self.link_stack.take() {
225 let display = format!("[{}] {}", link_idx + 1, text.trim());
226 let line_idx = self.lines.len();
227 self.link_positions.push((line_idx, link_idx));
228 self.current_spans.push(Span::styled(
229 display,
230 Style::default().fg(Color::Cyan),
231 ));
232 self.flush_line();
233 }
234 }
235 ("li", false) => {
236 self.flush_line();
237 let indent = " ".repeat(self.list_depth.saturating_sub(1));
238 self.push_text(&format!("{}• ", indent));
239 }
240 ("ul" | "ol", false) => { self.list_depth += 1; }
241 ("ul" | "ol", true) => {
242 self.list_depth = self.list_depth.saturating_sub(1);
243 self.flush_line();
244 }
245 ("pre", false) => {
246 self.flush_line();
247 self.lines.push(Line::from(Span::styled(
248 "```",
249 Style::default().fg(Color::DarkGray),
250 )));
251 }
252 ("pre", true) => {
253 self.flush_line();
254 self.lines.push(Line::from(Span::styled(
255 "```",
256 Style::default().fg(Color::DarkGray),
257 )));
258 }
259 ("script" | "style" | "noscript" | "iframe", _) => {}
260 _ => {}
261 }
262 }
263
264 fn current_style(&self) -> Style {
265 let mut style = Style::default();
266 if self.bold {
267 style = style.add_modifier(Modifier::BOLD);
268 }
269 if self.italic {
270 style = style.add_modifier(Modifier::ITALIC);
271 }
272 style
273 }
274
275 fn push_text(&mut self, text: &str) {
276 if let Some((_, ref mut link_text)) = self.link_stack {
277 link_text.push_str(text);
278 } else if !text.is_empty() {
279 let style = self.current_style();
280 self.current_spans.push(Span::styled(text.to_owned(), style));
281 }
282 }
283
284 fn flush_line(&mut self) {
285 if !self.current_spans.is_empty() {
286 self.lines.push(Line::from(std::mem::take(&mut self.current_spans)));
287 }
288 }
289
290 fn push_empty_line(&mut self) {
291 self.lines.push(Line::from(""));
292 }
293}
294
295fn parse_tag(inner: &str) -> (String, &str, bool, bool) {
296 let is_self_closing = inner.ends_with('/');
297 let trimmed = if is_self_closing { &inner[..inner.len() - 1] } else { inner };
298 let is_closing = trimmed.starts_with('/');
299 let body = if is_closing { &trimmed[1..] } else { trimmed }.trim();
300 let (tag_name, attrs) = body
301 .split_once(|c: char| c.is_whitespace())
302 .unwrap_or((body, ""));
303 (tag_name.to_lowercase(), attrs.trim(), is_closing, is_self_closing)
304}
305
306fn extract_attr(attrs: &str, name: &str) -> Option<String> {
307 for quote in &['"', '\''] {
308 let search = format!("{}={}", name, quote);
309 if let Some(start_idx) = attrs.find(&search) {
310 let value_start = start_idx + search.len();
311 if let Some(end_offset) = attrs[value_start..].find(*quote) {
312 return Some(attrs[value_start..value_start + end_offset].to_owned());
313 }
314 }
315 }
316 None
317}
318
319fn html_decode(s: &str) -> String {
320 s.replace("&", "&")
321 .replace("<", "<")
322 .replace(">", ">")
323 .replace(""", "\"")
324 .replace("'", "'")
325 .replace(" ", " ")
326}