use ratatui::style::Style;
use ratatui::text::{Line, Span};
use crate::markdown::ast::Inline;
use crate::markdown::renderer::util::{
extend_with_styled_chars, is_soft_break_space, link_fallback, truncate_to_width,
wrap_styled_chars, StyledChar,
};
use crate::markdown::renderer::Renderer;
use crate::markdown::table_layout::{self, 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 mut best = 0usize;
for token in chars.split(|&(ch, _)| is_soft_break_space(ch)) {
if token.is_empty() {
continue;
}
let len = token.len();
let contribution = if token.iter().any(|&(_, breakable)| breakable) {
let longest_prose_run = token
.split(|&(_, breakable)| breakable)
.map(<[(char, bool)]>::len)
.max()
.unwrap_or(0);
longest_prose_run.max(len.min(BREAKABLE_MIN_WIDTH))
} else {
len
};
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) => 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::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>]>,
out: &mut Vec<Line<'static>>,
) {
if col_count == 0 {
return;
}
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_char_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_char_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.viewport_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));
self.render_table_row(headers, &widths, col_count, header_style, 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));
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, out);
if i + 1 < rows.len() {
if self.row_striping {
out.push(self.blank_table_separator(&widths, col_count, cell_style));
} else {
out.push(Line::styled(thin.clone(), border_style));
}
}
}
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));
}
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("\u{00A0}".repeat(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,
out: &mut Vec<Line<'static>>,
) {
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: usize = row.iter().map(|c| c.ch.to_string().chars().count()).sum();
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(format!("{} ", " ".repeat(pad)), default_style));
} else {
let plain: String = row.iter().map(|c| c.ch).collect();
let truncated = truncate_to_width(&plain, width.saturating_sub(1));
spans.push(Span::styled(format!("{truncated}…"), default_style));
spans.push(Span::styled(" ", 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));
}
}
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_empty_cell_is_zero() {
assert_eq!(cell_min_width(&[]), 0);
}
}