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