use ratatui::style::Style;
use ratatui::text::{Line, Span};
use crate::markdown::ast::{to_u32, Inline, SrcLines};
use crate::markdown::renderer::util::{
extend_with_styled_chars, link_fallback, spaces, styled_cells, truncate_to_width,
wrap_styled_chars, StyledChar,
};
use crate::markdown::renderer::Renderer;
use crate::markdown::row_origin::{ContentKind, RowOrigin, RowSink};
use crate::markdown::table_layout::{self, char_cells, str_cells, MIN_COL_WIDTH};
const BREAKABLE_MIN_WIDTH: usize = 8;
fn cell_min_width(inlines: &[Inline]) -> usize {
let mut chars: Vec<(char, bool)> = Vec::new();
flatten_breakable_chars(inlines, false, &mut chars);
let plain: Vec<char> = chars.iter().map(|&(ch, _)| ch).collect();
let run_cells = |run: &[(char, bool)]| run.iter().map(|&(ch, _)| char_cells(ch)).sum();
let mut best = 0usize;
for word in table_layout::word_ranges(&plain) {
let token = &chars[word];
let cells = run_cells(token);
let contribution = if token.iter().any(|&(_, breakable)| breakable) {
let longest_prose_run = token
.split(|&(_, breakable)| breakable)
.map(run_cells)
.max()
.unwrap_or(0);
longest_prose_run.max(cells.min(BREAKABLE_MIN_WIDTH))
} else {
cells
};
best = best.max(contribution);
}
best
}
fn flatten_breakable_chars(inlines: &[Inline], breakable: bool, out: &mut Vec<(char, bool)>) {
for inline in inlines {
match inline {
Inline::Text(t) | Inline::Html(t) => out.extend(t.chars().map(|c| (c, breakable))),
Inline::Bold(inner)
| Inline::Italic(inner)
| Inline::Strikethrough(inner)
| Inline::Highlight(inner) => flatten_breakable_chars(inner, breakable, out),
Inline::Code(c) => out.extend(c.chars().map(|c| (c, true))),
Inline::Link { text, url, .. } => {
let before = out.len();
flatten_breakable_chars(text, true, out);
if out.len() == before {
out.extend(link_fallback(url).chars().map(|c| (c, true)));
}
}
Inline::Image { alt, .. } => out.extend(alt.chars().map(|c| (c, false))),
Inline::HtmlComment(_) | Inline::FootnoteReference { .. } => {}
Inline::Math { source, display } => {
let delim = if *display { "$$" } else { "$" };
out.extend(delim.chars().map(|c| (c, false)));
out.extend(source.chars().map(|c| (c, false)));
out.extend(delim.chars().map(|c| (c, false)));
}
Inline::SoftBreak => out.push((' ', false)),
Inline::HardBreak => out.push(('\n', false)),
}
}
}
impl<'t> Renderer<'t> {
pub(super) fn render_table(
&self,
col_count: usize,
headers: &[Vec<Inline>],
rows: &[Vec<Vec<Inline>>],
user_widths: Option<&[Option<usize>]>,
src: &SrcLines,
out: &mut RowSink,
) {
if col_count == 0 {
return;
}
let line = |k: usize| src.first.saturating_add(to_u32(k));
let row_origin = |row: usize, k: usize| {
move |sub: usize| match src.col(k) {
Some(raw_col) => RowOrigin::content(
line(k)..line(k).saturating_add(1),
raw_col,
0,
ContentKind::TableRow {
row: to_u32(row),
sub: to_u32(sub),
},
),
None => RowOrigin::chrome(Some(line(k))),
}
};
let mut cell_max_widths: Vec<Vec<usize>> = Vec::with_capacity(rows.len() + 1);
let mut cell_min_widths: Vec<Vec<usize>> = Vec::with_capacity(rows.len() + 1);
let header_max: Vec<usize> = headers
.iter()
.take(col_count)
.map(|c| self.rendered_inlines_width(c))
.collect();
let header_min: Vec<usize> = headers
.iter()
.take(col_count)
.map(|c| cell_min_width(c))
.collect();
cell_max_widths.push(header_max);
cell_min_widths.push(header_min);
for row in rows {
cell_max_widths.push(
row.iter()
.take(col_count)
.map(|c| self.rendered_inlines_width(c))
.collect(),
);
cell_min_widths.push(
row.iter()
.take(col_count)
.map(|c| cell_min_width(c))
.collect(),
);
}
let widths = table_layout::compute_widths(
&cell_max_widths,
&cell_min_widths,
col_count,
self.width(),
user_widths,
);
let border_style = self.theme.table_border;
let header_style = self.theme.table_header;
let header_border_style = self.theme.table_header_border;
let top: String = std::iter::once("┌".to_string())
.chain(widths.iter().enumerate().map(|(i, &w)| {
let sep = if i + 1 < col_count { "┬" } else { "┐" };
format!("{}{}", "─".repeat(w + 2), sep)
}))
.collect();
out.push(Line::styled(top, border_style), RowOrigin::chrome(None));
self.render_table_row(
headers,
&widths,
col_count,
header_style,
row_origin(0, 0),
out,
);
let header_border: String = std::iter::once("┝".to_string())
.chain(widths.iter().enumerate().map(|(i, &w)| {
let corner = if i + 1 < col_count { "┿" } else { "┥" };
format!("{}{}", "━".repeat(w + 2), corner)
}))
.collect();
out.push(
Line::styled(header_border, header_border_style),
RowOrigin::chrome(Some(line(1))),
);
let thin: String = std::iter::once("├".to_string())
.chain(widths.iter().enumerate().map(|(i, &w)| {
let corner = if i + 1 < col_count { "┼" } else { "┤" };
format!("{}{}", "─".repeat(w + 2), corner)
}))
.collect();
for (i, row) in rows.iter().enumerate() {
let cell_style = if self.row_striping {
if i % 2 == 0 {
self.theme.table_cell.patch(self.theme.table_row_even)
} else {
self.theme.table_cell.patch(self.theme.table_row_odd)
}
} else {
self.theme.table_cell
};
self.render_table_row(
row,
&widths,
col_count,
cell_style,
row_origin(1 + i, 2 + i),
out,
);
if i + 1 < rows.len() {
let separator = RowOrigin::chrome(Some(line(2 + i)));
if self.row_striping {
out.push(
self.blank_table_separator(&widths, col_count, cell_style),
separator,
);
} else {
out.push(Line::styled(thin.clone(), border_style), separator);
}
}
}
let bottom: String = std::iter::once("└".to_string())
.chain(widths.iter().enumerate().map(|(i, &w)| {
let corner = if i + 1 < col_count { "┴" } else { "┘" };
format!("{}{}", "─".repeat(w + 2), corner)
}))
.collect();
out.push(
Line::styled(bottom, border_style),
RowOrigin::chrome(Some(line(1 + rows.len()))),
);
}
fn blank_table_separator(
&self,
widths: &[usize],
col_count: usize,
cell_style: Style,
) -> Line<'static> {
let outer_border = self.theme.table_border;
let inner_border = match cell_style.bg {
Some(bg) => self.theme.table_border.bg(bg),
None => self.theme.table_border,
};
let mut spans: Vec<Span<'static>> = Vec::with_capacity(col_count * 2 + 1);
spans.push(Span::styled("│", outer_border));
for i in 0..col_count {
let width = widths.get(i).copied().unwrap_or(MIN_COL_WIDTH);
spans.push(Span::styled(spaces(width + 2), cell_style));
let is_last = i + 1 == col_count;
spans.push(Span::styled(
"│",
if is_last { outer_border } else { inner_border },
));
}
Line::from(spans)
}
fn render_table_row(
&self,
cells: &[Vec<Inline>],
widths: &[usize],
col_count: usize,
default_style: Style,
origin: impl Fn(usize) -> RowOrigin,
out: &mut RowSink,
) {
let outer_border = self.theme.table_border;
let inner_border = match default_style.bg {
Some(bg) => self.theme.table_border.bg(bg),
None => self.theme.table_border,
};
let mut cell_rows: Vec<Vec<Vec<StyledChar>>> = Vec::with_capacity(col_count);
for i in 0..col_count {
let cell_inlines: &[Inline] = cells.get(i).map(|v| v.as_slice()).unwrap_or(&[]);
let width = widths.get(i).copied().unwrap_or(MIN_COL_WIDTH);
let chars = self.cell_styled_chars(cell_inlines, default_style);
let wrapped = wrap_styled_chars(&chars, width);
cell_rows.push(wrapped);
}
let row_height = cell_rows.iter().map(|r| r.len()).max().unwrap_or(1).max(1);
for sub in 0..row_height {
let mut spans: Vec<Span<'static>> = Vec::with_capacity(col_count * 4 + 1);
spans.push(Span::styled("│", outer_border));
#[allow(clippy::needless_range_loop)]
for i in 0..col_count {
let width = widths.get(i).copied().unwrap_or(MIN_COL_WIDTH);
let row: &[StyledChar] = cell_rows[i].get(sub).map(|v| v.as_slice()).unwrap_or(&[]);
let row_w = styled_cells(row);
spans.push(Span::styled(" ", default_style));
if row_w <= width {
extend_with_styled_chars(&mut spans, row);
let pad = width.saturating_sub(row_w);
spans.push(Span::styled(spaces(pad + 1), default_style));
} else {
let plain: String = row.iter().map(|c| c.ch).collect();
let truncated = truncate_to_width(&plain, width.saturating_sub(1));
let pad = width.saturating_sub(str_cells(&truncated) + 1);
spans.push(Span::styled(
format!("{truncated}…{} ", " ".repeat(pad)),
default_style,
));
}
let is_last = i + 1 == col_count;
spans.push(Span::styled(
"│",
if is_last { outer_border } else { inner_border },
));
}
out.push(Line::from(spans), origin(sub));
}
}
fn cell_styled_chars(&self, cell_inlines: &[Inline], default_style: Style) -> Vec<StyledChar> {
let mut out: Vec<StyledChar> = Vec::new();
for span in self.render_inlines(cell_inlines, default_style) {
let style = span.style;
for ch in span.content.chars() {
out.push(StyledChar { ch, style });
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn text(s: &str) -> Inline {
Inline::Text(s.to_owned())
}
#[test]
fn cell_min_width_prose_uses_longest_word() {
let cell = vec![text("a couple of ordinary words")];
assert_eq!(cell_min_width(&cell), "ordinary".chars().count());
}
#[test]
fn cell_min_width_long_code_span_is_breakable() {
let cell = vec![Inline::Code("really_long_function_name()".to_owned())];
assert_eq!(cell_min_width(&cell), BREAKABLE_MIN_WIDTH);
}
#[test]
fn cell_min_width_short_code_span_counts_content_only() {
let cell = vec![Inline::Code("ok".to_owned())];
assert_eq!(cell_min_width(&cell), 2);
}
#[test]
fn cell_min_width_prose_word_beside_code_token_wins() {
let cell = vec![
text("unbreakableprose "),
Inline::Code("very_long_identifier_here".to_owned()),
];
assert_eq!(cell_min_width(&cell), "unbreakableprose".chars().count());
}
#[test]
fn cell_min_width_mixed_token_keeps_prose_run_intact() {
let cell = vec![text("unbreakableprose"), Inline::Code("x".to_owned())];
assert_eq!(cell_min_width(&cell), "unbreakableprose".chars().count());
}
#[test]
fn cell_min_width_link_text_is_breakable() {
let cell = vec![Inline::Link {
text: vec![text("a-very-long-link-label-indeed")],
url: "https://example.com".to_owned(),
title: None,
}];
assert_eq!(cell_min_width(&cell), BREAKABLE_MIN_WIDTH);
}
#[test]
fn cell_min_width_empty_link_text_measures_url_fallback() {
let cell = vec![Inline::Link {
text: vec![],
url: "https://example.com/some/long/path".to_owned(),
title: None,
}];
assert_eq!(cell_min_width(&cell), BREAKABLE_MIN_WIDTH);
}
#[test]
fn cell_min_width_cjk_run_floors_at_one_glyph() {
assert_eq!(cell_min_width(&[text("日本語日本語")]), 2);
}
#[test]
fn cell_min_width_prose_glued_to_cjk_keeps_its_word() {
assert_eq!(cell_min_width(&[text("日本語abc")]), 3);
}
#[test]
fn cell_min_width_cjk_code_span_floors_at_one_glyph() {
assert_eq!(
cell_min_width(&[Inline::Code("日本語日本語".to_owned())]),
2
);
}
#[test]
fn cell_min_width_zwj_family_is_one_unbreakable_glyph() {
let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}";
assert_eq!(cell_min_width(&[text(&format!("ab {family}"))]), 6);
}
#[test]
fn cell_min_width_empty_cell_is_zero() {
assert_eq!(cell_min_width(&[]), 0);
}
}