Skip to main content

tuika/components/
code_block.rs

1//! [`CodeBlock`] — a themed, framed code block with pluggable syntax
2//! highlighting.
3//!
4//! The component owns *presentation* — a language label, a left rail, a code
5//! background, verbatim (never-reflowed) body lines — while token colors come
6//! from a [`Highlighter`](crate::highlight::Highlighter) the host supplies (see
7//! [`CodeHighlighter`]). With no highlighter, or for an unknown language, the
8//! body renders as plain [`Theme::code`](crate::style::CodeTheme)-colored text.
9//!
10//! Code lines are drawn faithfully (leading whitespace preserved, clipped to
11//! width, never word-wrapped) because indentation is meaningful in code — unlike
12//! prose, which [`Markdown`](crate::components::Markdown) word-wraps.
13
14use ratatui_core::layout::Rect;
15use ratatui_core::style::{Modifier, Style};
16use ratatui_core::text::{Line, Span};
17
18use crate::components::text::line_width;
19use crate::geometry::Size;
20use crate::highlight::CodeHighlighter;
21use crate::style::Theme;
22use crate::surface::Surface;
23use crate::view::{RenderCtx, View};
24
25/// The left rail glyph + trailing pad that frames every code line.
26const RAIL: &str = "▏ ";
27
28/// Build the styled lines for a fenced code block: an optional language-label
29/// row followed by one verbatim row per source line.
30///
31/// `highlighter` colors the body; when it declines (unknown language / parse
32/// failure) every line falls back to plain [`CodeTheme::text`] over the code
33/// background. `gutter` is the starting line number when a right-aligned
34/// line-number column should precede the rail, or `None` for no gutter. The
35/// returned lines carry no outer indent — callers ([`CodeBlock`] and
36/// [`Markdown`]) add their own — and must be drawn **without** word-wrapping so
37/// code indentation survives.
38///
39/// [`CodeTheme::text`]: crate::style::CodeTheme::text
40pub(crate) fn code_block_lines(
41    lang: &str,
42    body: &[&str],
43    theme: &Theme,
44    highlighter: CodeHighlighter,
45    show_label: bool,
46    gutter: Option<usize>,
47) -> Vec<Line<'static>> {
48    let code = &theme.code;
49    let rail_style = Style::default().fg(code.label).bg(code.background);
50    let plain = Style::default().fg(code.text).bg(code.background);
51    let gutter_style = Style::default().fg(code.label).bg(code.background);
52
53    // Width of the numeric column: enough digits for the last line, plus a
54    // leading and trailing space, shared by every row so the rail stays aligned.
55    let gutter_width = gutter.map(|start| {
56        let last = start + body.len().saturating_sub(1);
57        let digits = last.to_string().len();
58        digits + 2
59    });
60
61    let mut out = Vec::new();
62
63    let label = lang.trim();
64    if show_label && !label.is_empty() {
65        let mut spans = Vec::new();
66        if let Some(w) = gutter_width {
67            spans.push(Span::styled(" ".repeat(w), gutter_style));
68        }
69        spans.push(Span::styled(
70            format!("{RAIL}{label}"),
71            Style::default()
72                .fg(code.label)
73                .add_modifier(Modifier::ITALIC),
74        ));
75        out.push(Line::from(spans));
76    }
77
78    // Highlighted spans (one vector per body line) or the plain fallback.
79    let highlighted = highlighter.highlight(label, body, theme);
80
81    for (row, source) in body.iter().enumerate() {
82        let mut spans = Vec::new();
83        if let (Some(start), Some(w)) = (gutter, gutter_width) {
84            // Right-align the number within `w-1` cells, then a trailing space.
85            spans.push(Span::styled(
86                format!(" {:>width$} ", start + row, width = w - 2),
87                gutter_style,
88            ));
89        }
90        spans.push(Span::styled(RAIL.to_string(), rail_style));
91        match highlighted.as_ref().and_then(|lines| lines.get(row)) {
92            Some(cells) if !cells.is_empty() => {
93                // Layer the code background under each highlighted span, keeping
94                // its foreground, so the whole line reads as one block.
95                for cell in cells {
96                    spans.push(Span::styled(
97                        cell.content.to_string(),
98                        cell.style.bg(code.background),
99                    ));
100                }
101            }
102            _ => spans.push(Span::styled((*source).to_string(), plain)),
103        }
104        out.push(Line::from(spans));
105    }
106
107    out
108}
109
110/// A themed, syntax-highlighted code block — a language label, a left rail, a
111/// code background, and verbatim (never-reflowed) body lines.
112///
113/// ![code_block demo](https://raw.githubusercontent.com/everruns/tuika/main/docs/demos/code_block.png)
114///
115/// Colors come entirely from [`Theme::code`](crate::style::CodeTheme); token classes are
116/// resolved by whatever [`Highlighter`](crate::highlight::Highlighter) you plug in (none →
117/// plain, theme-colored text).
118///
119/// # Options
120///
121/// | Builder | Default | Effect |
122/// | --- | --- | --- |
123/// | [`new(lang, source)`](Self::new) | — | language tag + source (split on `\n`) |
124/// | [`highlighter(&h)`](Self::highlighter) | plain | plug in syntax highlighting |
125/// | [`label(bool)`](Self::label) | `true` | show/hide the language-label row |
126/// | [`line_numbers(bool)`](Self::line_numbers) | `false` | show a line-number gutter |
127/// | [`start_line(n)`](Self::start_line) | `1` | first gutter line number |
128///
129/// ```no_run
130/// use tuika::prelude::*;
131/// let theme = Theme::default();
132/// // No highlighter → plain, theme-colored code; hide the label row.
133/// let block = CodeBlock::new("rust", "fn main() {}").label(false);
134/// // `block` is a `View`; render it through `tuika::paint` or embed it in a
135/// // `Flex`. Supply a highlighter with `.highlighter(&my_highlighter)` — see
136/// // the `tuika-codeformatters` crate for a tree-sitter one.
137/// # let _ = (theme, block);
138/// ```
139pub struct CodeBlock<'a> {
140    lang: String,
141    body: Vec<String>,
142    highlighter: CodeHighlighter<'a>,
143    show_label: bool,
144    start_line: Option<usize>,
145}
146
147impl<'a> CodeBlock<'a> {
148    /// A code block for `source` in language `lang` (e.g. `"rust"`, `"py"`, or
149    /// `""` for no language). `source` is split on newlines into body lines.
150    pub fn new(lang: impl Into<String>, source: impl AsRef<str>) -> Self {
151        Self {
152            lang: lang.into(),
153            body: source.as_ref().split('\n').map(str::to_owned).collect(),
154            highlighter: CodeHighlighter::Plain,
155            show_label: true,
156            start_line: None,
157        }
158    }
159
160    /// Plug in a syntax highlighter; without one the body renders plain.
161    pub fn highlighter(mut self, highlighter: &'a dyn crate::highlight::Highlighter) -> Self {
162        self.highlighter = CodeHighlighter::With(highlighter);
163        self
164    }
165
166    /// Whether to show the language-label row (default `true`).
167    pub fn label(mut self, show: bool) -> Self {
168        self.show_label = show;
169        self
170    }
171
172    /// Show (or hide) a right-aligned line-number gutter before the rail. The
173    /// gutter counts from [`start_line`](Self::start_line) (default `1`).
174    pub fn line_numbers(mut self, show: bool) -> Self {
175        self.start_line = show.then(|| self.start_line.unwrap_or(1));
176        self
177    }
178
179    /// Set the first gutter line number, implying [`line_numbers(true)`](Self::line_numbers).
180    /// Useful when a block is a slice of a larger file.
181    pub fn start_line(mut self, first: usize) -> Self {
182        self.start_line = Some(first);
183        self
184    }
185
186    fn lines(&self, theme: &Theme) -> Vec<Line<'static>> {
187        let body: Vec<&str> = self.body.iter().map(String::as_str).collect();
188        code_block_lines(
189            &self.lang,
190            &body,
191            theme,
192            self.highlighter,
193            self.show_label,
194            self.start_line,
195        )
196    }
197}
198
199impl View for CodeBlock<'_> {
200    fn measure(&self, available: Size) -> Size {
201        let theme = Theme::default();
202        let lines = self.lines(&theme);
203        let width = lines
204            .iter()
205            .map(|l| line_width(l))
206            .max()
207            .unwrap_or(0)
208            .min(available.width);
209        Size::new(width, lines.len() as u16)
210    }
211
212    fn render(&self, area: Rect, surface: &mut Surface, ctx: &RenderCtx) {
213        let lines = self.lines(ctx.theme);
214        for (row, line) in lines.iter().enumerate() {
215            let y = area.y.saturating_add(row as u16);
216            if y >= area.bottom() {
217                break;
218            }
219            let mut x = area.x;
220            for span in &line.spans {
221                if x >= area.right() {
222                    break;
223                }
224                x = surface.set_string(x, y, span.content.as_ref(), span.style);
225            }
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::style::Theme;
234    use crate::tests::support::row;
235
236    #[test]
237    fn line_numbers_gutter_counts_and_aligns() {
238        let theme = Theme::default();
239        // Nine lines so the gutter is one digit wide; label row hidden.
240        let src = (1..=9)
241            .map(|n| format!("line{n}"))
242            .collect::<Vec<_>>()
243            .join("\n");
244        let block = CodeBlock::new("", &src).label(false).line_numbers(true);
245        let buf = crate::testing::render(&block, 30, 9, &theme);
246        assert!(
247            row(&buf, 0).starts_with(" 1 "),
248            "first row: {:?}",
249            row(&buf, 0)
250        );
251        assert!(
252            row(&buf, 8).starts_with(" 9 "),
253            "last row: {:?}",
254            row(&buf, 8)
255        );
256        // The rail follows the gutter.
257        assert!(row(&buf, 0).contains('▏'));
258    }
259
260    #[test]
261    fn start_line_offsets_and_widens_gutter() {
262        let theme = Theme::default();
263        // Starting at 99 with two lines crosses into three digits (99, 100), so
264        // the gutter widens to keep the rail aligned across all rows.
265        let block = CodeBlock::new("", "a\nb").label(false).start_line(99);
266        let buf = crate::testing::render(&block, 30, 2, &theme);
267        assert!(
268            row(&buf, 0).starts_with("  99 "),
269            "row0: {:?}",
270            row(&buf, 0)
271        );
272        assert!(
273            row(&buf, 1).starts_with(" 100 "),
274            "row1: {:?}",
275            row(&buf, 1)
276        );
277    }
278
279    #[test]
280    fn no_gutter_by_default() {
281        let theme = Theme::default();
282        let block = CodeBlock::new("", "x").label(false);
283        let buf = crate::testing::render(&block, 20, 1, &theme);
284        // Rail is first, with no leading numeric column.
285        assert!(row(&buf, 0).starts_with('▏'), "row0: {:?}", row(&buf, 0));
286    }
287}