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