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/// 
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}