Skip to main content

tuika_html/
view.rs

1//! The [`Html`] view: a fragment placed in a layout.
2
3use ratatui_core::layout::Rect;
4use tuika::components::text::line_width;
5use tuika::geometry::Size;
6use tuika::style::{StyleSheet, Theme};
7use tuika::surface::Surface;
8use tuika::view::{RenderCtx, View};
9
10use crate::{Limits, to_lines_with_limits};
11
12/// A view that renders an HTML fragment to its area.
13///
14/// ![Html view demo](https://raw.githubusercontent.com/everruns/tuika/main/crates/tuika-html/examples/html_view/html_view.png)
15///
16/// The standalone counterpart to attaching an
17/// [`HtmlRenderer`](crate::HtmlRenderer) to markdown: same engine, but the whole
18/// area is HTML. Prose wraps to the width, `<pre>` stays verbatim, and tables
19/// are fitted — recomputed each frame, so one fragment covers every terminal
20/// size.
21///
22/// ```no_run
23/// use tuika::prelude::*;
24/// use tuika_html::Html;
25/// let page = Html::new("<h1>Release notes</h1><ul><li>Faster</li></ul>");
26/// // `page` is a `View`: render it via `tuika::paint` or embed it in a `Flex`.
27/// # let _ = page;
28/// ```
29///
30/// `cargo run -p tuika-html --example html_view` is the scene above.
31pub struct Html {
32    source: String,
33    limits: Limits,
34}
35
36impl Html {
37    /// A view over `source`.
38    pub fn new(source: impl Into<String>) -> Self {
39        Self {
40            source: source.into(),
41            limits: Limits::default(),
42        }
43    }
44
45    /// Bound this view's rendering; see [`Limits`].
46    pub fn limits(mut self, limits: Limits) -> Self {
47        self.limits = limits;
48        self
49    }
50
51    fn lines(
52        &self,
53        width: u16,
54        theme: &Theme,
55        sheet: &StyleSheet,
56    ) -> Vec<ratatui_core::text::Line<'static>> {
57        to_lines_with_limits(&self.source, width, theme, sheet, self.limits).unwrap_or_default()
58    }
59}
60
61impl View for Html {
62    fn measure(&self, available: Size, ctx: &RenderCtx) -> Size {
63        let lines = self.lines(available.width, ctx.theme, &ctx.sheet);
64        let width = lines.iter().map(line_width).max().unwrap_or(0);
65        Size::new(width.min(available.width), lines.len() as u16)
66    }
67
68    fn render(&self, area: Rect, surface: &mut Surface, ctx: &RenderCtx) {
69        for (row, line) in self
70            .lines(area.width, ctx.theme, &ctx.sheet)
71            .iter()
72            .enumerate()
73        {
74            let y = area.y.saturating_add(row as u16);
75            if y >= area.bottom() {
76                break;
77            }
78            let mut x = area.x;
79            for span in &line.spans {
80                if x >= area.right() {
81                    break;
82                }
83                x = surface.set_string(x, y, span.content.as_ref(), span.style);
84            }
85        }
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use ratatui_core::style::Color;
93    use tuika::style::StyleBundle;
94    use tuika::testing::{grid, render, render_with_sheet};
95
96    #[test]
97    fn the_view_paints_its_fragment() {
98        let view = Html::new("<h1>Hi</h1><p>there</p>");
99        let painted = grid(&render(&view, 10, 3, &Theme::default()));
100        assert_eq!(painted, "Hi        \n          \nthere     ");
101    }
102
103    #[test]
104    fn the_view_measures_its_rendered_size() {
105        let view = Html::new("<h1>Title</h1><p>body</p>");
106        let theme = Theme {
107            accent: Color::Cyan,
108            ..Theme::default()
109        };
110        let sheet = StyleSheet {
111            heading: StyleBundle::new().fg(Color::Magenta),
112            ..StyleSheet::from_theme(&theme)
113        };
114        let ctx = RenderCtx::new(&theme).with_sheet(sheet);
115        let size = view.measure(Size::new(12, 10), &ctx);
116
117        assert_eq!(size, Size::new(5, 3));
118        let painted = render_with_sheet(&view, 12, size.height, &theme, sheet);
119        assert_eq!(grid(&painted), "Title       \n            \nbody        ");
120        assert_eq!(painted[(0, 0)].fg, Color::Magenta);
121    }
122
123    #[test]
124    fn a_tiny_area_neither_panics_nor_overflows() {
125        for (w, h) in [(0, 0), (1, 1), (3, 2), (80, 1)] {
126            let view = Html::new("<table><tr><th>a</th><th>b</th></tr></table>");
127            let _ = render(&view, w, h, &Theme::default());
128        }
129    }
130}