1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
//! `Block::List` rendering.
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use crate::markdown::ast::{to_u32, Block, ListItem};
use crate::markdown::renderer::util::spaces;
use crate::markdown::renderer::{
paragraph_reflows, paragraph_row_inlines, paragraph_rows, push_gap_rows, render_children,
Renderer,
};
use crate::markdown::row_origin::{RowOrigin, RowSink};
use crate::markdown::table_layout::str_cells;
impl<'t> Renderer<'t> {
pub(super) fn render_list(
&self,
ordered: bool,
start: Option<u64>,
items: &[ListItem],
out: &mut RowSink,
indent_prefix: &str,
) {
// `marker_width` is the prefix printed before each item's first line; `nested_indent_width`
// is the leading whitespace before each nested list (other blocks align under the item's
// text, see `indent_for`). The latter is `max(4, marker_width)` because source nesting
// uses 4 spaces, and rendering at the same width keeps the raw-mode view from showing the
// nested marker shifted; a marker wider than 4 grows the indent with it so those markers
// still align under their parent's content column.
let first_num = start.unwrap_or(1);
let last_num = first_num + items.len().saturating_sub(1) as u64;
let digit_width = last_num.to_string().len().max(1);
let marker_width = if ordered { digit_width + 2 } else { 2 };
let nested_indent_width = marker_width.max(4);
let child_indent_prefix = format!("{indent_prefix}{}", " ".repeat(nested_indent_width));
// Measured once per list rather than per item: every item's marker starts with it.
let indent_prefix_cells = str_cells(indent_prefix);
let mut counter = first_num;
let mut prev_end: Option<u32> = None;
for item in items {
// Loose-list spacing, at any depth: one blank row per source line between this
// item's span and the last one's. A blank inside an item (in a fence, or between its
// blocks) lies within that item's span, so it never counts here; the item renders it
// itself.
if let Some(end) = prev_end {
push_gap_rows(out, end..item.span.start, &[]);
}
prev_end = Some(item.span.end);
// A task is a decorated bullet — the same marker plus the checkbox span below — so
// task items and plain bullets can coexist in one list.
let (marker, marker_style) = if ordered {
// Right-aligned in a `digit_width` slot so 10+ doesn't push its text out of
// alignment with the single-digit items above.
let s = format!(
"{indent_prefix}{counter:>digit_width$}. ",
digit_width = digit_width
);
counter += 1;
(s, self.theme.list_number)
} else {
let bullet_style = match item.task {
Some(true) => self.theme.task_checked,
Some(false) => self.theme.task_unchecked,
None => self.theme.list_bullet,
};
(format!("{indent_prefix}• "), bullet_style)
};
let task_prefix: Option<Span<'static>> = item.task.map(|checked| {
if checked {
Span::styled("[✓] ", self.theme.task_checked)
} else {
Span::styled("[ ] ", self.theme.task_unchecked)
}
});
// `task_strikethrough` keeps CROSSED_OUT opt-in, so a theme can mute completed text
// without striking it through.
let checked_text_style = if item.task == Some(true) {
if self.theme.task_strikethrough {
self.theme
.task_complete_text
.add_modifier(ratatui::style::Modifier::CROSSED_OUT)
} else {
self.theme.task_complete_text
}
} else {
Style::default()
};
// Blocks after the first indent to the item's text, behind the marker and any task box.
// A nested list instead takes `child_indent_prefix`, the 4-space nesting.
// The cells before the item's text — marker and task box — counted rather than
// measured: the marker is the indent prefix and `marker_width` cells (`• `, or the
// number right-aligned in its slot and `. `), the task box four (`[✓] `).
let prefix_cells =
indent_prefix_cells + marker_width + if item.task.is_some() { 4 } else { 0 };
debug_assert_eq!(
prefix_cells,
str_cells(&marker) + task_prefix.as_ref().map_or(0, |tp| str_cells(&tp.content)),
);
let text_indent = spaces(prefix_cells);
let render_nested = |block: &Block, out: &mut RowSink| match block {
Block::List { .. } => self.render_block(block, out, &child_indent_prefix, false),
_ if renders_flush(block) => self.render_under_indent(block, prefix_cells, out),
_ => self.render_block(block, out, &text_indent, false),
};
// The marker row shows the item's first line; a marker-only row is chrome.
let marker_row = RowOrigin::chrome(Some(item.span.start));
// An empty item still emits its marker so the block produces at least one line.
if item.blocks.is_empty() {
let mut spans = vec![Span::styled(marker.clone(), marker_style)];
if let Some(tp) = task_prefix.clone() {
spans.push(tp);
}
out.push(Line::from(spans), marker_row);
continue;
}
// A blank line between two of the item's blocks renders a blank row (one per line),
// so the item stays one row per source line, as a quote does.
render_children(out, &item.blocks, None, &item.hidden, |i, block, out| {
if i == 0 {
match block {
// A `$$` paragraph renders flush, as a ` math ` code block, so it takes
// the arm below like a fence does (#72).
Block::Paragraph { inlines, src } if !renders_flush(block) => {
let mut spans = vec![Span::styled(marker.clone(), marker_style)];
if let Some(tp) = task_prefix.clone() {
spans.push(tp);
}
// Marker and task box are prefix; the text's later rows sit under
// its first, behind the same width of indent.
// Reflowed as one flow, or one row per source line, like the
// item's later paragraphs (see `render_paragraph`).
let join = paragraph_reflows(self.reflow_paragraphs, inlines);
let inlines = paragraph_row_inlines(inlines, join);
let mut rows =
paragraph_rows(&inlines, src, join, to_u32(prefix_cells))
.peekable();
let mut first = true;
while let Some((segment, origin)) = rows.next() {
let rendered = self.render_inlines(segment, checked_text_style);
if std::mem::take(&mut first) {
spans.extend(rendered);
out.push(Line::from(std::mem::take(&mut spans)), origin);
} else if rows.peek().is_some()
|| rendered.iter().any(|s| !s.content.trim().is_empty())
{
// Only a trailing segment with nothing to show is
// suppressed, as in `render_paragraph`.
let mut row = vec![Span::raw(text_indent.clone())];
row.extend(rendered);
out.push(Line::from(row), origin);
}
}
}
other => {
// A non-paragraph first block gets the marker on a line of its own.
let mut spans = vec![Span::styled(marker.clone(), marker_style)];
if let Some(tp) = task_prefix.clone() {
spans.push(tp);
}
out.push(Line::from(spans), marker_row.clone());
render_nested(other, out);
}
}
} else {
render_nested(block, out);
}
});
}
}
/// Render `block`, which draws no indent of its own, under an item's text: inset by
/// `cells`, each row behind that many spaces, as a quote's children sit behind its bar
/// (`render_blockquote`). A row keeps its line style, so a surface (a quote's wash, a code
/// block's) still fills its trailing cells; the indent gets the background around the item
/// instead ([`Renderer::surface_bg`]), so the surface starts where the block does.
fn render_under_indent(&self, block: &Block, cells: usize, out: &mut RowSink) {
let mut inner = RowSink::default();
self.inset_by(cells, || self.render_block(block, &mut inner, "", false));
for (line, origin) in inner.into_rows() {
let indent_style = match line.style.bg {
Some(_) => Style::default().bg(self.surface_bg()),
None => Style::default(),
};
// Into the row's own span vector: no copy of its spans, and no allocation for the
// indent, which borrows from a static run of spaces.
let mut spans = line.spans;
spans.insert(0, Span::styled(spaces(cells), indent_style));
out.push(Line::from(spans).style(line.style), origin.shifted(cells));
}
}
}
/// Whether `block` renders flush left, ignoring an `indent_prefix`, so an item indents it with
/// [`Renderer::render_under_indent`]: everything but a paragraph, raw HTML and a list. An image
/// is left flush too, since its overlay paints from the view's left edge whatever the row's text.
fn renders_flush(block: &Block) -> bool {
match block {
Block::Paragraph { .. } => {
crate::markdown::parser::post_pass::display_math_block_body(block).is_some()
}
Block::Html(..)
| Block::HtmlComment(..)
| Block::ImageBlock { .. }
| Block::List { .. } => false,
_ => true,
}
}