1use log::debug;
3use ratatui::{
4 style::{Color, Modifier, Style},
5 text::{Line, Span, Text},
6 widgets::{Paragraph, Wrap},
7};
8use tl::{HTMLTag, Node, NodeHandle, VDom};
9
10const SCREEN_WIDTH: usize = 70;
11const TABLE_VERTICAL_BORDER: char = '─';
12const TABLE_MID_LEFT_BORDER: char = '├';
13const TABLE_MID_INTERSECT: char = '┼';
14const TABLE_MID_RIGHT_BORDER: char = '┤';
15const TABLE_TOP_LEFT_BORDER: char = '┌';
16const TABLE_TOP_INTERSECT: char = '┬';
17const TABLE_BOT_INTERSECT: char = '┴';
18const TABLE_TOP_RIGHT_BORDER: char = '┐';
19const TABLE_BOT_LEFT_BORDER: char = '└';
20const TABLE_BOT_RIGHT_BORDER: char = '┘';
21const TABLE_HORIZ_BORDER: char = '│';
22
23pub fn render(html: &str) -> (Paragraph<'static>, Vec<String>) {
26 let mut state = RenderState::new(html);
27 let (mut text, links) = state.render();
28
29 cleanup(&mut text);
30
31 (Paragraph::new(text).wrap(Wrap { trim: false }), links)
32}
33
34struct RenderState<'a> {
36 dom: VDom<'a>,
38}
39
40impl<'a> RenderState<'a> {
41 fn new(html: &'a str) -> RenderState<'a> {
43 let dom = tl::parse(html, tl::ParserOptions::default()).unwrap();
44 Self { dom }
45 }
46
47 fn render(&mut self) -> (Text<'static>, Vec<String>) {
49 let mut text = Text {
50 lines: vec![Line {
51 spans: vec![],
52 alignment: None,
53 }],
54 };
55 let mut links = vec![];
56 let mut out = RenderOutput::new(&mut text, &mut links);
57
58 for child in self.dom.children() {
59 self.render_internal(&mut out, child, Style::default());
60 }
61
62 (text, links)
63 }
64
65 fn render_internal(&self, out: &mut RenderOutput, handle: &NodeHandle, curr_style: Style) {
67 let node = handle.get(self.dom.parser()).unwrap();
68 match node {
69 Node::Tag(t) => {
70 let tag_name = &*t.name().as_utf8_str();
71 let c = t.children();
72 let children = c.top();
73 match tag_name {
74 "br" => out.newline(),
75
76 "h4" | "h5" | "h6" | "div" | "p" => {
78 let new_style = match tag_name {
79 "h4" => curr_style
80 .underline_color(Color::White)
81 .add_modifier(Modifier::BOLD),
82 "h5" | "h6" => curr_style.add_modifier(Modifier::BOLD),
83 "div" | "p" => curr_style,
84 _ => unreachable!(),
85 };
86
87 out.ensure_line_empty();
88 for child in children.iter() {
89 self.render_internal(out, child, new_style);
90 }
91 out.ensure_line_empty();
92 }
93
94 "span" | "strong" | "em" | "li" | "td" | "th" => {
97 let new_style = match tag_name {
98 "strong" => curr_style.add_modifier(Modifier::BOLD),
99 "em" => curr_style.add_modifier(Modifier::ITALIC),
100 _ => curr_style,
101 };
102
103 for child in children.iter() {
104 self.render_internal(out, child, new_style);
105 }
106 }
107
108 "a" => {
110 let new_style = curr_style.fg(Color::Blue);
111 for child in children.iter() {
112 self.render_internal(out, child, new_style);
113 }
114 if let Some(Some(b)) = t.attributes().get("href") {
115 let href = b.as_utf8_str().to_string();
116 let idx = out.add_link(href);
117
118 out.append(Span::styled(format!("[{idx}]"), new_style));
119 }
120 }
121
122 "ul" | "ol" => {
124 let mut next_item: Box<dyn FnMut() -> String> = match tag_name {
126 "ul" => Box::new(|| " - ".to_string()),
127 "ol" => {
128 let mut i = 0;
129 Box::new(move || {
130 i += 1;
131 format!("{}. ", i)
132 })
133 }
134 _ => unreachable!(),
135 };
136
137 for child in children.iter() {
138 let mut subtext = Text::raw("");
140 let mut suboutp = out.with_subtext(&mut subtext);
141 let child_node = child.get(self.dom.parser()).unwrap();
142 self.render_internal(&mut suboutp, child, curr_style);
143
144 if suboutp.empty_or_whitespace() {
145 continue;
146 }
147
148 match child_node {
149 Node::Tag(t)
151 if t.name().as_utf8_str() == "ul"
152 || t.name().as_utf8_str() == "ol" =>
153 {
154 subtext.lines.remove(0);
156 subtext.lines.pop();
157 subtext.lines.pop();
158
159 for i in 0..subtext.lines.len() {
161 subtext.lines[i].spans.insert(0, Span::raw(" "));
162 }
163 }
164 _ => {
165 subtext.lines[0].spans.insert(0, Span::raw(next_item()));
167 for i in 1..subtext.lines.len() {
168 subtext.lines[i].spans.insert(0, Span::raw(" "));
169 }
170 }
171 };
172
173 out.text.lines.extend(subtext.lines);
174 }
175
176 out.ensure_line_empty();
178 out.newline();
179 }
180
181 "table" => {
183 let mut subtexts: Vec<Vec<Text<'static>>> = vec![];
185 self.render_table_cells(out, t, &mut subtexts);
186
187 debug!("{:?}", subtexts);
188
189 let max_cols = subtexts.iter().map(Vec::len).max().unwrap_or(0);
191 subtexts
192 .iter_mut()
193 .for_each(|v| v.resize(max_cols, "".into()));
194
195 let mut col_widths = (0..max_cols)
197 .map(|col_idx| {
198 subtexts
199 .iter()
200 .map(|r| &r[col_idx])
201 .map(|t| t.width())
202 .max()
203 .unwrap_or(0)
204 })
205 .collect::<Vec<_>>();
206
207 let total_width = col_widths.iter().sum::<usize>() + col_widths.len() + 1;
208 let (widest_col_idx, &max_width) = col_widths
209 .iter()
210 .enumerate()
211 .max_by_key(|(_, w)| **w)
212 .unwrap_or((0, &0));
213 if total_width > SCREEN_WIDTH && max_width > (total_width - SCREEN_WIDTH) {
215 let new_width = max_width - (total_width - SCREEN_WIDTH);
216 col_widths[widest_col_idx] = new_width;
217
218 for row in subtexts.iter_mut() {
219 wrap_text_to_width(&mut row[widest_col_idx], new_width);
220 }
221 }
222
223 let row_heights = subtexts
224 .iter()
225 .map(|row| row.iter().map(|cell| cell.height()).max().unwrap_or(0))
226 .collect::<Vec<_>>();
227
228 out.ensure_line_empty();
230
231 out.append(table_vertical_border(
232 &col_widths,
233 TABLE_TOP_LEFT_BORDER,
234 TABLE_VERTICAL_BORDER,
235 TABLE_TOP_INTERSECT,
236 TABLE_TOP_RIGHT_BORDER,
237 ));
238 let n_rows = subtexts.len();
239 for (row_idx, row) in subtexts.into_iter().enumerate() {
240 let row_height = row_heights[row_idx];
242 let row_start_idx = out.text.lines.len();
243 (0..row_height).for_each(|_| {
244 out.text.lines.push(TABLE_HORIZ_BORDER.to_string().into())
245 });
246
247 for (col_idx, cell) in row.into_iter().enumerate() {
248 let col_width = col_widths[col_idx];
249 let added_to_lines = cell.lines.len();
250
251 for (line_idx, line) in cell.lines.into_iter().enumerate() {
253 let adding_width = line.width();
254 let add_to_line = &mut out.text.lines[row_start_idx + line_idx];
255 add_to_line.spans.extend(line.spans);
256 if adding_width < col_width {
257 add_to_line
258 .spans
259 .push(" ".repeat(col_width - adding_width).into());
260 }
261 }
262
263 for i in added_to_lines..row_height {
265 out.text.lines[row_start_idx + i]
266 .spans
267 .push(" ".repeat(col_width).into());
268 }
269
270 (0..row_height).for_each(|i| {
272 out.text.lines[row_start_idx + i]
273 .spans
274 .push(TABLE_HORIZ_BORDER.to_string().into())
275 });
276 }
277
278 if row_idx < n_rows - 1 {
279 out.ensure_line_empty();
280 out.append(table_vertical_border(
281 &col_widths,
282 TABLE_MID_LEFT_BORDER,
283 TABLE_VERTICAL_BORDER,
284 TABLE_MID_INTERSECT,
285 TABLE_MID_RIGHT_BORDER,
286 ));
287 }
288 }
289
290 out.ensure_line_empty();
291 out.append(table_vertical_border(
292 &col_widths,
293 TABLE_BOT_LEFT_BORDER,
294 TABLE_VERTICAL_BORDER,
295 TABLE_BOT_INTERSECT,
296 TABLE_BOT_RIGHT_BORDER,
297 ));
298 }
299
300 s => {
302 log::error!("unknown tag: {}", s);
303 t.children().top().iter().for_each(|child| {
304 self.render_internal(
305 out,
306 child,
307 curr_style.fg(Color::Red).underline_color(Color::Red),
308 )
309 })
310 }
311 }
312 }
313 Node::Raw(s) => {
315 let mut text = String::with_capacity(s.as_utf8_str().len());
316 html_escape::decode_html_entities_to_string(
317 collapse_whitespace(&s.as_utf8_str()),
318 &mut text,
319 );
320 if !text.contains('\n') {
321 out.append(Span::styled(text, curr_style));
322 } else {
323 for l in text.split('\n') {
324 out.append(Span::styled(l.to_string(), curr_style));
325 out.newline();
326 }
327 }
328 }
329 Node::Comment(_) => (),
330 }
331 }
332
333 fn render_table_cells(
334 &self,
335 out: &mut RenderOutput<'_>,
336 table: &HTMLTag<'_>,
337 cells: &mut Vec<Vec<Text<'static>>>,
338 ) {
339 for row_handle in table.children().top().iter() {
340 if let Node::Tag(row) = row_handle.get(self.dom.parser()).unwrap() {
341 match &*row.name().as_utf8_str() {
342 "thead" | "tbody" => {
343 self.render_table_cells(out, row, cells);
344 }
345 _ => {
346 let mut cols = vec![];
347 for cell in row.children().top().iter() {
348 let mut subtext = Text::default();
349 let mut suboutp = out.with_subtext(&mut subtext);
350 self.render_internal(&mut suboutp, cell, Style::new());
351
352 if subtext.width() == 0 || subtext.height() == 0 {
353 continue;
354 }
355 cleanup(&mut subtext);
356 cols.push(subtext);
357 }
358 if !cols.is_empty() {
359 cells.push(cols);
360 }
361 }
362 }
363 }
364 }
365 }
366}
367
368fn wrap_text_to_width(text: &mut Text<'_>, new_width: usize) {
369 let mut i = 0;
370 while i < text.lines.len() {
371 if text.lines[i].width() > new_width {
372 let new_line = chop_after(&mut text.lines[i], new_width);
373 text.lines.insert(i + 1, new_line);
374 } else {
375 i += 1;
376 }
377 }
378}
379
380fn chop_after<'a>(line: &mut Line<'a>, width: usize) -> Line<'a> {
381 let mut cum_width = 0;
382 for i in 0..line.spans.len() {
383 if cum_width + line.spans[i].width() > width {
384 let keep = width - cum_width;
386 let content = line.spans[i].content.clone();
387 line.spans[i].content = content.chars().take(keep).collect::<String>().into();
388
389 let mut new_line = vec![Span::styled(
390 content.chars().skip(keep).collect::<String>(),
391 line.spans[i].style,
392 )];
393 line.spans.drain(i + 1..).for_each(|s| new_line.push(s));
394 return new_line.into();
395 } else {
396 cum_width += line.spans[i].width();
397 }
398 }
399 vec![].into()
400}
401
402fn table_vertical_border(
403 col_widths: &[usize],
404 left: char,
405 straight: char,
406 intersect: char,
407 right: char,
408) -> Span<'static> {
409 let mut out = String::with_capacity(col_widths.iter().sum::<usize>() + col_widths.len() + 1);
410 out.push(left);
411 for (i, &col_width) in col_widths.iter().enumerate() {
412 (0..col_width).for_each(|_| out.push(straight));
413 if i < col_widths.len() - 1 {
414 out.push(intersect);
415 } else {
416 out.push(right);
417 }
418 }
419
420 out.into()
421}
422
423struct RenderOutput<'a> {
424 text: &'a mut Text<'static>,
425 links: &'a mut Vec<String>,
426}
427
428impl<'a> RenderOutput<'a> {
429 fn new(text: &'a mut Text<'static>, links: &'a mut Vec<String>) -> Self {
430 Self { text, links }
431 }
432
433 fn newline(&mut self) {
435 self.text.lines.push(Line {
436 spans: vec![],
437 alignment: None,
438 });
439 }
440
441 fn ensure_line_empty(&mut self) {
443 if !self.currline_empty() {
444 self.newline();
445 }
446 }
447 fn append(&mut self, span: Span<'static>) {
449 match self.text.lines.last_mut() {
450 Some(l) => l.spans.push(span),
451 None => self.text.lines.push(span.into()),
452 };
453 }
454
455 fn currline_empty(&mut self) -> bool {
457 self.text.lines.is_empty() || self.text.lines[self.text.lines.len() - 1].spans.is_empty()
458 }
459
460 fn empty_or_whitespace(&mut self) -> bool {
462 self.text
463 .lines
464 .iter()
465 .all(|l| l.spans.iter().all(|s| s.content.is_empty()))
466 }
467
468 fn add_link(&mut self, href: String) -> usize {
470 self.links.push(href);
471 self.links.len() - 1
472 }
473
474 fn with_subtext<'b>(&'b mut self, subtext: &'b mut Text<'static>) -> RenderOutput<'b>
475 where
476 'a: 'b,
477 {
478 RenderOutput {
479 text: subtext,
480 links: self.links,
481 }
482 }
483}
484
485fn collapse_whitespace(s: &str) -> String {
487 let s = s.trim();
488 let mut collapsed = String::with_capacity(s.len());
489 let mut last = ' ';
490
491 for c in s.chars() {
492 if c.is_whitespace() && last.is_whitespace() {
493 continue;
494 }
495
496 collapsed.push(c);
497 last = c;
498 }
499
500 collapsed
501}
502
503fn cleanup(text: &mut Text<'static>) {
505 text.lines
506 .iter_mut()
507 .for_each(|l| l.spans.retain(|s| !s.content.is_empty()));
508 if !text.lines.is_empty() && text.lines[0].spans.is_empty() {
509 text.lines.remove(0);
510 }
511
512 if !text.lines.is_empty() && text.lines.last().unwrap().spans.is_empty() {
513 text.lines.remove(text.lines.len() - 1);
514 }
515}