1use std::mem::take;
2use ratatui::{
3 style::{Color, Modifier, Style},
4 text::{Line, Span},
5};
6use stillo_core::{Block, Document, Inline, document::{ExtractedContent, ExtractedLink}}; use url::Url;
8
9const WRAP_WIDTH: usize = 80;
10const CODE_WIDTH: usize = 76;
11
12pub struct ContentView {
15 pub lines: Vec<Line<'static>>,
16 pub scroll_offset: usize,
17 pub selected_link: Option<usize>,
18 pub link_positions: Vec<(usize, usize)>,
20 link_span_ranges: Vec<(usize, usize, usize)>,
22}
23
24impl ContentView {
25 pub fn from_document(doc: &Document, links: &[ExtractedLink]) -> Self {
27 let mut renderer = DocRenderer::new(links);
28 renderer.render(doc);
29 Self {
30 lines: renderer.lines,
31 link_positions: renderer.link_positions,
32 link_span_ranges: renderer.link_span_ranges,
33 scroll_offset: 0,
34 selected_link: None,
35 }
36 }
37
38 pub fn from_content(content: &ExtractedContent) -> Self {
40 let doc = stillo_core::parse_html_to_ast(&content.body_html, &content.url);
41 Self::from_document(&doc, &content.links)
42 }
43
44 pub fn total_lines(&self) -> usize {
45 self.lines.len()
46 }
47
48 pub fn scroll_down(&mut self, n: usize, viewport_height: usize) {
49 let max = self.lines.len().saturating_sub(viewport_height);
50 self.scroll_offset = (self.scroll_offset + n).min(max);
51 }
52
53 pub fn scroll_up(&mut self, n: usize) {
54 self.scroll_offset = self.scroll_offset.saturating_sub(n);
55 }
56
57 pub fn scroll_to_top(&mut self) {
58 self.scroll_offset = 0;
59 }
60
61 pub fn scroll_to_bottom(&mut self, viewport_height: usize) {
62 self.scroll_offset = self.lines.len().saturating_sub(viewport_height);
63 }
64
65 pub fn next_link(&mut self) {
66 if self.link_positions.is_empty() {
67 return;
68 }
69 self.selected_link = Some(match self.selected_link {
70 None => 0,
71 Some(i) => (i + 1).min(self.link_positions.len() - 1),
72 });
73 self.scroll_to_selected_link();
74 self.rebuild_link_highlights();
75 }
76
77 pub fn prev_link(&mut self) {
78 if self.link_positions.is_empty() {
79 return;
80 }
81 self.selected_link = Some(match self.selected_link {
82 None => 0,
83 Some(i) => i.saturating_sub(1),
84 });
85 self.scroll_to_selected_link();
86 self.rebuild_link_highlights();
87 }
88
89 pub fn selected_link_url<'a>(&self, links: &'a [ExtractedLink]) -> Option<&'a Url> {
90 let sel = self.selected_link?;
91 let (_, link_idx) = self.link_positions.get(sel)?;
92 links.get(*link_idx).map(|l| &l.href)
93 }
94
95 fn scroll_to_selected_link(&mut self) {
97 if let Some(sel) = self.selected_link {
98 if let Some(&(line_idx, _)) = self.link_positions.get(sel) {
99 if line_idx < self.scroll_offset {
100 self.scroll_offset = line_idx;
101 }
102 }
103 }
104 }
105
106 fn rebuild_link_highlights(&mut self) {
110 for (pos_idx, (&(line_idx, _), &(_, span_start, span_end))) in
111 self.link_positions.iter().zip(self.link_span_ranges.iter()).enumerate()
112 {
113 let is_selected = self.selected_link == Some(pos_idx);
114 let style = if is_selected {
115 Style::default().fg(Color::Black).bg(Color::Cyan)
116 } else {
117 Style::default().fg(Color::Cyan)
118 };
119 if let Some(line) = self.lines.get_mut(line_idx) {
120 for span in line.spans[span_start..span_end].iter_mut() {
121 span.style = style;
122 }
123 }
124 }
125 }
126
127 pub fn search(&self, query: &str) -> Vec<usize> {
129 if query.is_empty() {
130 return vec![];
131 }
132 let q = query.to_lowercase();
133 self.lines
134 .iter()
135 .enumerate()
136 .filter(|(_, line)| {
137 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
138 text.to_lowercase().contains(&q)
139 })
140 .map(|(i, _)| i)
141 .collect()
142 }
143}
144
145struct DocRenderer<'a> {
151 links: &'a [ExtractedLink],
152 pub lines: Vec<Line<'static>>,
153 pub link_positions: Vec<(usize, usize)>,
154 pub link_span_ranges: Vec<(usize, usize, usize)>,
155 link_counter: usize,
156 current_spans: Vec<Span<'static>>,
157 current_len: usize,
158 pending_links: Vec<(usize, usize, usize)>,
160}
161
162impl<'a> DocRenderer<'a> {
163 fn new(links: &'a [ExtractedLink]) -> Self {
164 Self {
165 links,
166 lines: Vec::new(),
167 link_positions: Vec::new(),
168 link_span_ranges: Vec::new(),
169 link_counter: 0,
170 current_spans: Vec::new(),
171 current_len: 0,
172 pending_links: Vec::new(),
173 }
174 }
175
176 fn render(&mut self, doc: &Document) {
177 for block in &doc.blocks {
178 match block {
179 Block::Heading { level, inlines } => self.render_heading(*level, inlines),
180 Block::Paragraph(inlines) => self.render_paragraph(inlines),
181 Block::ListItem { depth, ordered, number, inlines } => {
182 self.render_list_item(*depth, *ordered, *number, inlines);
183 }
184 Block::CodeBlock { lang, content } => self.render_code_block(lang.as_deref(), content),
185 Block::Blockquote(inlines) => self.render_blockquote(inlines),
186 Block::Rule => self.render_rule(),
187 }
188 }
189 self.flush_line();
191 }
192
193 fn render_heading(&mut self, level: u8, inlines: &[Inline]) {
194 let text = inlines_to_text(inlines);
195 self.push_empty_line();
196 match level {
197 1 => {
198 let title_line = Line::from(Span::styled(
200 text.clone(),
201 Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
202 ));
203 self.lines.push(title_line);
204 let underline = "═".repeat(text.chars().count());
206 self.lines.push(Line::from(Span::styled(
207 underline,
208 Style::default().fg(Color::Yellow),
209 )));
210 }
211 2 => {
212 let prefix = "── ";
214 let suffix = " ";
215 let inner = format!("{}{}{}", prefix, text, suffix);
216 let pad_count = 60usize.saturating_sub(inner.chars().count());
217 let pad = "─".repeat(pad_count);
218 let full = format!("{}{}", inner, pad);
219 self.lines.push(Line::from(Span::styled(
220 full,
221 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
222 )));
223 }
224 3 => {
225 let full = format!("▸ {}", text);
226 self.lines.push(Line::from(Span::styled(
227 full,
228 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
229 )));
230 }
231 _ => {
232 let full = format!(" § {}", text);
233 self.lines.push(Line::from(Span::styled(
234 full,
235 Style::default().add_modifier(Modifier::BOLD),
236 )));
237 }
238 }
239 self.push_empty_line();
240 }
241
242 fn render_paragraph(&mut self, inlines: &[Inline]) {
243 self.push_empty_line();
244 self.render_inlines(inlines, Style::default(), WRAP_WIDTH);
245 self.flush_line();
246 }
247
248 fn render_list_item(&mut self, depth: usize, ordered: bool, number: usize, inlines: &[Inline]) {
249 self.flush_line();
250 let prefix = if ordered {
251 format!("{}{number}. ", " ".repeat(depth))
252 } else {
253 format!("{}• ", " ".repeat(depth.saturating_sub(1)))
254 };
255 let prefix_len = prefix.chars().count();
256 self.current_spans.push(Span::styled(
257 prefix,
258 Style::default().fg(Color::DarkGray),
259 ));
260 self.current_len += prefix_len;
261 let remaining = WRAP_WIDTH.saturating_sub(prefix_len);
262 self.render_inlines(inlines, Style::default(), remaining);
263 self.flush_line();
264 }
265
266 fn render_code_block(&mut self, lang: Option<&str>, content: &str) {
267 self.flush_line();
268 self.push_empty_line();
269
270 let lang_label = lang.map(|l| format!(" {} ", l)).unwrap_or_else(|| " ".to_owned());
272 let border_inner_len = CODE_WIDTH + 2; let lang_pad = border_inner_len.saturating_sub(lang_label.chars().count() + 1);
274 let top = format!("╭─{}{}╮", lang_label, "─".repeat(lang_pad));
275 self.lines.push(Line::from(Span::styled(top, Style::default().fg(Color::DarkGray))));
276
277 for line in content.lines() {
279 let line_len = line.chars().count();
280 let pad = CODE_WIDTH.saturating_sub(line_len);
281 let padded = format!("{}{}", line, " ".repeat(pad));
282 self.lines.push(Line::from(vec![
283 Span::styled("│ ", Style::default().fg(Color::DarkGray)),
284 Span::styled(padded, Style::default().fg(Color::Yellow)),
285 Span::styled(" │", Style::default().fg(Color::DarkGray)),
286 ]));
287 }
288
289 let bottom = format!("╰{}╯", "─".repeat(CODE_WIDTH + 2));
291 self.lines.push(Line::from(Span::styled(bottom, Style::default().fg(Color::DarkGray))));
292
293 self.push_empty_line();
294 }
295
296 fn render_blockquote(&mut self, inlines: &[Inline]) {
297 self.push_empty_line();
298 self.current_spans.push(Span::styled("▎ ", Style::default().fg(Color::Cyan)));
300 self.current_len += 2;
301 let italic_style = Style::default().add_modifier(Modifier::ITALIC);
302 self.render_inlines(inlines, italic_style, WRAP_WIDTH.saturating_sub(2));
303 self.flush_line();
304 self.push_empty_line();
305 }
306
307 fn render_rule(&mut self) {
308 self.flush_line();
309 self.lines.push(Line::from(Span::styled(
310 "─".repeat(60),
311 Style::default().fg(Color::DarkGray),
312 )));
313 self.push_empty_line();
314 }
315
316 fn render_inlines(&mut self, inlines: &[Inline], base_style: Style, wrap_width: usize) {
318 for inline in inlines {
319 match inline {
320 Inline::Text(t) => self.push_words(t, base_style, wrap_width),
321 Inline::Bold(t) => {
322 self.push_words(t, base_style.add_modifier(Modifier::BOLD), wrap_width);
323 }
324 Inline::Italic(t) => {
325 self.push_words(t, base_style.add_modifier(Modifier::ITALIC), wrap_width);
326 }
327 Inline::BoldItalic(t) => {
328 self.push_words(
329 t,
330 base_style
331 .add_modifier(Modifier::BOLD)
332 .add_modifier(Modifier::ITALIC),
333 wrap_width,
334 );
335 }
336 Inline::Code(t) => {
337 let display = format!("`{}`", t);
338 let display_len = display.chars().count();
339 let need_space = self.current_len > 0;
340 let total = display_len + if need_space { 1 } else { 0 };
341 if self.current_len > 0 && self.current_len + total > wrap_width {
342 self.flush_line();
343 } else if need_space {
344 self.current_spans.push(Span::raw(" "));
345 self.current_len += 1;
346 }
347 self.current_spans.push(Span::styled(
348 display,
349 Style::default().fg(Color::Yellow),
350 ));
351 self.current_len += display_len;
352 }
353 Inline::Link { text, href } => self.push_link(text, href, wrap_width),
354 Inline::SoftBreak => self.flush_line(),
355 }
356 }
357 }
358
359 fn push_words(&mut self, text: &str, style: Style, wrap_width: usize) {
361 for word in text.split_whitespace() {
362 let need_space = self.current_len > 0;
363 let word_len = word.chars().count();
364 let total = word_len + if need_space { 1 } else { 0 };
365 if self.current_len > 0 && self.current_len + total > wrap_width {
366 self.flush_line();
367 self.current_spans.push(Span::styled(word.to_owned(), style));
368 self.current_len = word_len;
369 } else {
370 if need_space {
371 self.current_spans.push(Span::raw(" "));
372 self.current_len += 1;
373 }
374 self.current_spans.push(Span::styled(word.to_owned(), style));
375 self.current_len += word_len;
376 }
377 }
378 }
379
380 fn push_link(&mut self, text: &str, href: &str, wrap_width: usize) {
382 let link_idx = self.links.iter().position(|l| {
384 l.href.as_str() == href
385 || l.href.as_str().trim_end_matches('/') == href.trim_end_matches('/')
386 });
387
388 let display_num = self.link_counter + 1;
389 self.link_counter += 1;
390
391 let label = format!("[{}]", display_num);
392 let trimmed_text = text.trim();
393 let display_text = if trimmed_text.is_empty() {
394 label.clone()
395 } else {
396 format!("{} {}", label, trimmed_text)
397 };
398 let display_len = display_text.chars().count();
399 let need_space = self.current_len > 0;
400 let total = display_len + if need_space { 1 } else { 0 };
401
402 if self.current_len > 0 && self.current_len + total > wrap_width {
403 self.flush_line();
404 } else if need_space {
405 self.current_spans.push(Span::raw(" "));
406 self.current_len += 1;
407 }
408
409 let span_start = self.current_spans.len();
410 self.current_spans.push(Span::styled(
411 label,
412 Style::default().fg(Color::Cyan),
413 ));
414 if !trimmed_text.is_empty() {
415 self.current_spans.push(Span::raw(" "));
416 self.current_spans.push(Span::styled(
417 trimmed_text.to_owned(),
418 Style::default().fg(Color::Cyan),
419 ));
420 }
421 let span_end = self.current_spans.len();
422 self.current_len += display_len;
423
424 if let Some(idx) = link_idx {
426 self.pending_links.push((idx, span_start, span_end));
427 }
428 }
429
430 fn flush_line(&mut self) {
432 let line_idx = self.lines.len();
433 for (link_idx, span_start, span_end) in self.pending_links.drain(..) {
434 self.link_positions.push((line_idx, link_idx));
435 self.link_span_ranges.push((line_idx, span_start, span_end));
436 }
437 if !self.current_spans.is_empty() {
438 self.lines.push(Line::from(take(&mut self.current_spans)));
439 }
440 self.current_len = 0;
441 }
442
443 fn push_empty_line(&mut self) {
445 self.flush_line();
446 self.lines.push(Line::from(""));
447 }
448}
449
450fn inlines_to_text(inlines: &[Inline]) -> String {
452 inlines
453 .iter()
454 .map(|i| match i {
455 Inline::Text(s)
456 | Inline::Bold(s)
457 | Inline::Italic(s)
458 | Inline::BoldItalic(s)
459 | Inline::Code(s) => s.as_str(),
460 Inline::Link { text, .. } => text.as_str(),
461 Inline::SoftBreak => " ",
462 })
463 .collect()
464}