fix(tui): preserve markdown styles in tables

This commit is contained in:
Felipe Coury
2026-04-30 13:24:00 -03:00
parent a0b16c1e1e
commit 939a9ffd37
5 changed files with 412 additions and 173 deletions

View File

@@ -30,9 +30,11 @@ use std::sync::LazyLock;
use url::Url;
mod table;
use table::TableState;
mod table_cell;
mod table_state;
use table::normalize_table_boundaries;
use table::render_table_lines;
use table_state::TableState;
struct MarkdownStyles {
h1: Style,
@@ -285,13 +287,7 @@ where
Tag::Emphasis => self.push_inline_style(self.styles.emphasis),
Tag::Strong => self.push_inline_style(self.styles.strong),
Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough),
Tag::Link { dest_url, .. } => {
if let Some(table) = self.table.as_mut() {
table.start_link(dest_url.to_string());
} else {
self.push_link(dest_url.to_string());
}
}
Tag::Link { dest_url, .. } => self.push_link(dest_url.to_string()),
Tag::Table(_) => self.start_table(),
Tag::TableHead | Tag::TableRow => self.start_table_row(),
Tag::TableCell => self.start_table_cell(),
@@ -320,8 +316,8 @@ where
}
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => self.pop_inline_style(),
TagEnd::Link => {
if let Some(table) = self.table.as_mut() {
table.end_link();
if self.table.is_some() {
self.pop_table_link();
} else {
self.pop_link();
}
@@ -435,8 +431,14 @@ where
}
fn text(&mut self, text: CowStr<'a>) {
if let Some(table) = self.table.as_mut() {
table.push_text(&text);
if self.table.is_some() {
if self.suppressing_local_link_label() {
return;
}
let style = self.inline_styles.last().copied().unwrap_or_default();
if let Some(table) = self.table.as_mut() {
table.push_text(&text, style);
}
return;
}
if self.suppressing_local_link_label() {
@@ -492,8 +494,13 @@ where
}
fn code(&mut self, code: CowStr<'a>) {
if let Some(table) = self.table.as_mut() {
table.push_text(&code);
if self.table.is_some() {
if self.suppressing_local_link_label() {
return;
}
if let Some(table) = self.table.as_mut() {
table.push_text(&code, self.styles.code);
}
return;
}
if self.suppressing_local_link_label() {
@@ -509,8 +516,14 @@ where
}
fn html(&mut self, html: CowStr<'a>, inline: bool) {
if let Some(table) = self.table.as_mut() {
table.push_html(&html);
if self.table.is_some() {
if self.suppressing_local_link_label() {
return;
}
let style = self.inline_styles.last().copied().unwrap_or_default();
if let Some(table) = self.table.as_mut() {
table.push_html(&html, style);
}
return;
}
if self.suppressing_local_link_label() {
@@ -533,8 +546,14 @@ where
}
fn hard_break(&mut self) {
if let Some(table) = self.table.as_mut() {
table.push_text(" ");
if self.table.is_some() {
if self.suppressing_local_link_label() {
return;
}
let style = self.inline_styles.last().copied().unwrap_or_default();
if let Some(table) = self.table.as_mut() {
table.push_text(" ", style);
}
return;
}
if self.suppressing_local_link_label() {
@@ -545,8 +564,14 @@ where
}
fn soft_break(&mut self) {
if let Some(table) = self.table.as_mut() {
table.push_text(" ");
if self.table.is_some() {
if self.suppressing_local_link_label() {
return;
}
let style = self.inline_styles.last().copied().unwrap_or_default();
if let Some(table) = self.table.as_mut() {
table.push_text(" ", style);
}
return;
}
if self.suppressing_local_link_label() {
@@ -721,6 +746,31 @@ where
}
}
fn pop_table_link(&mut self) {
let Some(link) = self.link.take() else {
return;
};
let Some(table) = self.table.as_mut() else {
return;
};
if link.show_destination {
table.push_span(" (".into());
table.push_span(Span::styled(link.destination, self.styles.link));
table.push_span(")".into());
} else if let Some(local_target_display) = link.local_target_display {
// Local file links are rendered as code-like path text so the transcript shows the
// resolved target instead of arbitrary caller-provided label text.
let style = self
.inline_styles
.last()
.copied()
.unwrap_or_default()
.patch(self.styles.code);
table.push_span(Span::styled(local_target_display, style));
}
}
fn suppressing_local_link_label(&self) -> bool {
self.link
.as_ref()

View File

@@ -1,74 +1,13 @@
use super::table_cell::TableCell;
use std::borrow::Cow;
use crate::render::line_utils::line_to_static;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use ratatui::text::Line;
use ratatui::text::Span;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Default)]
pub(super) struct TableState {
pub(super) rows: Vec<Vec<String>>,
current_row: Vec<String>,
current_cell: String,
in_cell: bool,
current_link_destination: Option<String>,
}
impl TableState {
pub(super) fn start_row(&mut self) {
self.current_row.clear();
}
pub(super) fn start_cell(&mut self) {
self.current_cell.clear();
self.in_cell = true;
}
pub(super) fn push_text(&mut self, text: &str) {
if self.in_cell {
self.current_cell.push_str(text);
}
}
pub(super) fn push_html(&mut self, html: &str) {
let trimmed = html.trim();
if matches!(
trimmed.to_ascii_lowercase().as_str(),
"<br>" | "<br/>" | "<br />"
) {
self.push_text("\n");
} else {
self.push_text(html);
}
}
pub(super) fn start_link(&mut self, destination: String) {
self.current_link_destination = Some(destination);
}
pub(super) fn end_link(&mut self) {
let Some(destination) = self.current_link_destination.take() else {
return;
};
if self.in_cell && !destination.is_empty() {
self.current_cell.push_str(" (");
self.current_cell.push_str(&destination);
self.current_cell.push(')');
}
}
pub(super) fn end_cell(&mut self) {
self.current_link_destination = None;
self.current_row
.push(std::mem::take(&mut self.current_cell));
self.in_cell = false;
}
pub(super) fn end_row(&mut self) {
if !self.current_row.is_empty() {
self.rows.push(std::mem::take(&mut self.current_row));
}
}
}
#[derive(Debug)]
struct TableLayoutCandidate {
column_widths: Vec<usize>,
@@ -84,7 +23,10 @@ struct TableMetrics {
hard_wrap_count: usize,
}
pub(super) fn render_table_lines(rows: &[Vec<String>], width: Option<usize>) -> Vec<Line<'static>> {
pub(super) fn render_table_lines(
rows: &[Vec<TableCell>],
width: Option<usize>,
) -> Vec<Line<'static>> {
if rows.is_empty() {
return Vec::new();
}
@@ -104,10 +46,7 @@ pub(super) fn render_table_lines(rows: &[Vec<String>], width: Option<usize>) ->
&candidate.column_widths,
candidate.padding,
candidate.hard_wrap,
)
.into_iter()
.map(Line::from)
.collect(),
),
None => render_vertical_table(&normalized_rows, available_width),
}
}
@@ -224,17 +163,17 @@ fn is_table_delimiter_source(line: &str) -> bool {
})
}
fn normalize_table_rows(rows: &[Vec<String>], column_count: usize) -> Vec<Vec<String>> {
fn normalize_table_rows(rows: &[Vec<TableCell>], column_count: usize) -> Vec<Vec<TableCell>> {
rows.iter()
.map(|row| {
let mut normalized = row.clone();
normalized.resize(column_count, String::new());
normalized.resize(column_count, TableCell::default());
normalized
})
.collect()
}
fn desired_column_widths(rows: &[Vec<String>], column_count: usize) -> Vec<usize> {
fn desired_column_widths(rows: &[Vec<TableCell>], column_count: usize) -> Vec<usize> {
let mut widths = vec![3; column_count];
for row in rows {
for (index, cell) in row.iter().enumerate() {
@@ -245,7 +184,7 @@ fn desired_column_widths(rows: &[Vec<String>], column_count: usize) -> Vec<usize
}
fn choose_table_layout(
rows: &[Vec<String>],
rows: &[Vec<TableCell>],
desired_widths: &[usize],
available_width: usize,
column_count: usize,
@@ -295,7 +234,7 @@ fn width_shape_requires_vertical(column_count: usize, available_width: usize) ->
}
fn build_table_candidate(
rows: &[Vec<String>],
rows: &[Vec<TableCell>],
desired_widths: &[usize],
column_widths: Vec<usize>,
available_width: usize,
@@ -368,31 +307,49 @@ fn allocate_table_widths(
}
fn render_box_table(
rows: &[Vec<String>],
rows: &[Vec<TableCell>],
column_widths: &[usize],
padding: usize,
hard_wrap: bool,
) -> Vec<String> {
) -> Vec<Line<'static>> {
let mut out = Vec::new();
out.push(border_line("", "", "", column_widths, padding));
out.push(Line::from(border_line(
"",
"",
"",
column_widths,
padding,
)));
for (index, row) in rows.iter().enumerate() {
out.extend(render_table_row(row, column_widths, padding, hard_wrap));
if index == 0 {
out.push(border_line("", "", "", column_widths, padding));
out.push(Line::from(border_line(
"",
"",
"",
column_widths,
padding,
)));
}
}
out.push(border_line("", "", "", column_widths, padding));
out.push(Line::from(border_line(
"",
"",
"",
column_widths,
padding,
)));
out
}
fn render_table_row(
row: &[String],
row: &[TableCell],
column_widths: &[usize],
padding: usize,
hard_wrap: bool,
) -> Vec<String> {
) -> Vec<Line<'static>> {
let wrapped_cells = row
.iter()
.zip(column_widths)
@@ -402,21 +359,31 @@ fn render_table_row(
let mut out = Vec::with_capacity(row_height);
for line_index in 0..row_height {
let mut line = String::from("");
let mut spans = vec![Span::from("")];
for (cell_lines, width) in wrapped_cells.iter().zip(column_widths) {
let content = cell_lines.get(line_index).map(String::as_str).unwrap_or("");
line.push_str(&" ".repeat(padding));
line.push_str(content);
line.push_str(&" ".repeat(width.saturating_sub(content.width())));
line.push_str(&" ".repeat(padding));
line.push('│');
let content = cell_lines.get(line_index);
push_padding(&mut spans, padding);
if let Some(content) = content {
spans.extend(content.spans.iter().cloned());
push_padding(&mut spans, width.saturating_sub(content.width()));
} else {
push_padding(&mut spans, *width);
}
push_padding(&mut spans, padding);
spans.push(Span::from(""));
}
out.push(line);
out.push(Line::from(spans));
}
out
}
fn push_padding(spans: &mut Vec<Span<'static>>, width: usize) {
if width > 0 {
spans.push(Span::from(" ".repeat(width)));
}
}
fn border_line(
left: &str,
separator: &str,
@@ -431,36 +398,44 @@ fn border_line(
format!("{left}{}{right}", cell_segments.join(separator))
}
fn wrap_table_cell(cell: &str, width: usize, hard_wrap: bool) -> Vec<String> {
if cell.is_empty() {
return vec![String::new()];
fn wrap_table_cell(cell: &TableCell, width: usize, hard_wrap: bool) -> Vec<Line<'static>> {
if cell.lines().is_empty() {
return vec![Line::default()];
}
let mut lines = Vec::new();
let options = textwrap::Options::new(width)
let options = RtOptions::new(width)
.break_words(hard_wrap)
.word_separator(textwrap::WordSeparator::AsciiSpace)
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit);
for segment in cell.split('\n') {
let wrapped = textwrap::wrap(segment, options.clone())
for segment in cell.lines() {
if segment.width() == 0 {
lines.push(Line::default());
continue;
}
let wrapped = word_wrap_line(segment, options.clone())
.into_iter()
.map(std::borrow::Cow::into_owned)
.map(|line| line_to_static(&line))
.collect::<Vec<_>>();
if wrapped.is_empty() {
lines.push(String::new());
lines.push(Line::default());
} else {
lines.extend(wrapped);
}
}
if lines.is_empty() {
vec![String::new()]
vec![Line::default()]
} else {
lines
}
}
fn table_metrics(rows: &[Vec<String>], column_widths: &[usize], hard_wrap: bool) -> TableMetrics {
fn table_metrics(
rows: &[Vec<TableCell>],
column_widths: &[usize],
hard_wrap: bool,
) -> TableMetrics {
let mut row_heights = Vec::with_capacity(rows.len());
let mut hard_wraps_url_or_code = false;
let mut hard_wrap_count = 0usize;
@@ -498,14 +473,15 @@ fn table_metrics(rows: &[Vec<String>], column_widths: &[usize], hard_wrap: bool)
}
}
fn cell_needs_hard_wrap(cell: &str, width: usize) -> bool {
cell.split('\n')
fn cell_needs_hard_wrap(cell: &TableCell, width: usize) -> bool {
cell.plain_text()
.split('\n')
.flat_map(str::split_whitespace)
.any(|token| token.width() > width)
}
fn should_render_vertical(
rows: &[Vec<String>],
rows: &[Vec<TableCell>],
desired_widths: &[usize],
column_widths: &[usize],
available_width: usize,
@@ -557,11 +533,11 @@ fn table_total_width(column_widths: &[usize], padding: usize) -> usize {
+ padding * 2 * column_widths.len()
}
fn is_index_column(rows: &[Vec<String>], index: usize) -> bool {
fn is_index_column(rows: &[Vec<TableCell>], index: usize) -> bool {
let header = rows
.first()
.and_then(|row| row.get(index))
.map(|header| normalized_header(header))
.map(normalized_header)
.unwrap_or_default();
if matches!(header.as_str(), "#" | "id" | "idx" | "index" | "row") {
return true;
@@ -570,15 +546,16 @@ fn is_index_column(rows: &[Vec<String>], index: usize) -> bool {
rows.iter()
.skip(1)
.filter_map(|row| row.get(index))
.filter(|cell| !cell.trim().is_empty())
.all(|cell| cell.trim().chars().all(|ch| ch.is_ascii_digit()))
.map(TableCell::trimmed_plain_text)
.filter(|cell| !cell.is_empty())
.all(|cell| cell.chars().all(|ch| ch.is_ascii_digit()))
}
fn is_content_heavy_column(rows: &[Vec<String>], index: usize) -> bool {
fn is_content_heavy_column(rows: &[Vec<TableCell>], index: usize) -> bool {
let header = rows
.first()
.and_then(|row| row.get(index))
.map(|header| normalized_header(header))
.map(normalized_header)
.unwrap_or_default();
if [
"link",
@@ -603,31 +580,32 @@ fn is_content_heavy_column(rows: &[Vec<String>], index: usize) -> bool {
.any(|cell| is_url_or_code_like(cell) || cell.width() > 24)
}
fn is_url_or_code_like(cell: &str) -> bool {
cell.contains("://")
|| cell.contains("::")
|| cell.contains("=>")
|| cell.contains("->")
|| cell.contains('`')
|| cell.contains('{')
|| cell.contains('}')
|| cell.contains('(')
|| cell.contains(')')
fn is_url_or_code_like(cell: &TableCell) -> bool {
let text = cell.plain_text();
text.contains("://")
|| text.contains("::")
|| text.contains("=>")
|| text.contains("->")
|| text.contains('`')
|| text.contains('{')
|| text.contains('}')
|| text.contains('(')
|| text.contains(')')
}
fn has_width_risk_chars(rows: &[Vec<String>]) -> bool {
fn has_width_risk_chars(rows: &[Vec<TableCell>]) -> bool {
rows.iter().flatten().any(|cell| {
cell.chars().any(|ch| {
cell.plain_text().chars().any(|ch| {
matches!(ch, '\u{fe0f}' | '\u{200d}') || ('\u{1f300}'..='\u{1faff}').contains(&ch)
})
})
}
fn normalized_header(header: &str) -> String {
header.trim().to_ascii_lowercase()
fn normalized_header(header: &TableCell) -> String {
header.trimmed_plain_text().to_ascii_lowercase()
}
fn render_vertical_table(rows: &[Vec<String>], available_width: usize) -> Vec<Line<'static>> {
fn render_vertical_table(rows: &[Vec<TableCell>], available_width: usize) -> Vec<Line<'static>> {
let Some((headers, body_rows)) = rows.split_first() else {
return Vec::new();
};
@@ -644,8 +622,13 @@ fn render_vertical_table(rows: &[Vec<String>], available_width: usize) -> Vec<Li
let max_value_width = body_rows
.iter()
.flat_map(|row| included_columns.iter().filter_map(|index| row.get(*index)))
.flat_map(|cell| cell.split('\n'))
.map(UnicodeWidthStr::width)
.flat_map(|cell| {
cell.plain_text()
.lines()
.map(str::to_string)
.collect::<Vec<_>>()
})
.map(|line| line.width())
.max()
.unwrap_or(1);
let available_content_width = available_width.saturating_sub(7);
@@ -688,20 +671,15 @@ fn render_vertical_table(rows: &[Vec<String>], available_width: usize) -> Vec<Li
}
for index in &included_columns {
let label = truncate_to_width(&vertical_label(headers, *index), label_width);
let cell = row.get(*index).map(String::as_str).unwrap_or("").trim();
let value = if cell.is_empty() { "" } else { cell };
let wrapped = textwrap::wrap(
value,
textwrap::Options::new(value_width)
.break_words(true)
.word_separator(textwrap::WordSeparator::AsciiSpace)
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
)
.into_iter()
.map(std::borrow::Cow::into_owned)
.collect::<Vec<_>>();
let empty_cell = TableCell::default();
let cell = row.get(*index).unwrap_or(&empty_cell);
let wrapped = if cell.is_blank() {
vec![Line::from("")]
} else {
wrap_table_cell(cell, value_width, /*hard_wrap*/ true)
};
let wrapped = if wrapped.is_empty() {
vec![String::new()]
vec![Line::default()]
} else {
wrapped
};
@@ -710,11 +688,14 @@ fn render_vertical_table(rows: &[Vec<String>], available_width: usize) -> Vec<Li
let label = if line_index == 0 { label.as_str() } else { "" };
let label_padding = label_width.saturating_sub(label.width());
let value_padding = value_width.saturating_sub(value_line.width());
out.push(Line::from(format!(
"{}{label}{value_line}{}",
" ".repeat(label_padding),
" ".repeat(value_padding),
)));
let mut spans = vec![Span::from("")];
push_padding(&mut spans, label_padding);
spans.push(Span::from(label.to_string()));
spans.push(Span::from(""));
spans.extend(value_line.spans.iter().cloned());
push_padding(&mut spans, value_padding);
spans.push(Span::from(""));
out.push(Line::from(spans));
}
}
}
@@ -728,31 +709,30 @@ fn render_vertical_table(rows: &[Vec<String>], available_width: usize) -> Vec<Li
out
}
fn included_vertical_columns(headers: &[String], body_rows: &[Vec<String>]) -> Vec<usize> {
fn included_vertical_columns(headers: &[TableCell], body_rows: &[Vec<TableCell>]) -> Vec<usize> {
let first_column_titles_rows = headers
.first()
.map(|header| normalized_header(header))
.map(normalized_header)
.is_some_and(|header| matches!(header.as_str(), "#" | "id" | "idx" | "index" | "row"))
&& headers.len() > 1;
(0..headers.len())
.filter(|index| !(first_column_titles_rows && *index == 0))
.filter(|index| {
!headers[*index].trim().is_empty()
!headers[*index].is_blank()
|| body_rows
.iter()
.any(|row| row.get(*index).is_some_and(|cell| !cell.trim().is_empty()))
.any(|row| row.get(*index).is_some_and(|cell| !cell.is_blank()))
})
.collect()
}
fn vertical_label(headers: &[String], index: usize) -> String {
fn vertical_label(headers: &[TableCell], index: usize) -> String {
headers
.get(index)
.map(|header| header.trim())
.map(TableCell::trimmed_plain_text)
.filter(|header| !header.is_empty())
.unwrap_or("Column")
.to_string()
.unwrap_or_else(|| "Column".to_string())
}
fn truncate_to_width(input: &str, max_width: usize) -> String {

View File

@@ -0,0 +1,77 @@
use std::borrow::Cow;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(super) struct TableCell {
lines: Vec<Line<'static>>,
}
impl TableCell {
pub(super) fn push_text(&mut self, text: &str, style: Style) {
self.push_span(Span::styled(text.to_string(), style));
}
pub(super) fn push_span(&mut self, span: Span<'static>) {
let content = span.content.to_string();
for (index, segment) in content.split('\n').enumerate() {
if index > 0 {
self.push_line_break();
}
if segment.is_empty() {
self.ensure_line();
continue;
}
let mut segment_span = span.clone();
segment_span.content = Cow::Owned(segment.to_string());
self.ensure_line().push_span(segment_span);
}
}
pub(super) fn push_line_break(&mut self) {
self.lines.push(Line::default());
}
pub(super) fn plain_text(&self) -> String {
self.lines
.iter()
.map(line_plain_text)
.collect::<Vec<_>>()
.join("\n")
}
pub(super) fn trimmed_plain_text(&self) -> String {
self.plain_text().trim().to_string()
}
pub(super) fn width(&self) -> usize {
self.lines.iter().map(Line::width).max().unwrap_or(0)
}
pub(super) fn is_blank(&self) -> bool {
self.lines
.iter()
.all(|line| line.spans.iter().all(|span| span.content.trim().is_empty()))
}
pub(super) fn lines(&self) -> &[Line<'static>] {
&self.lines
}
fn ensure_line(&mut self) -> &mut Line<'static> {
if self.lines.is_empty() {
self.lines.push(Line::default());
}
let last_index = self.lines.len() - 1;
&mut self.lines[last_index]
}
}
fn line_plain_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}

View File

@@ -0,0 +1,61 @@
use ratatui::style::Style;
use ratatui::text::Span;
use super::table_cell::TableCell;
#[derive(Debug, Default)]
pub(super) struct TableState {
pub(super) rows: Vec<Vec<TableCell>>,
current_row: Vec<TableCell>,
current_cell: TableCell,
in_cell: bool,
}
impl TableState {
pub(super) fn start_row(&mut self) {
self.current_row.clear();
}
pub(super) fn start_cell(&mut self) {
self.current_cell = TableCell::default();
self.in_cell = true;
}
pub(super) fn push_text(&mut self, text: &str, style: Style) {
if self.in_cell {
self.current_cell.push_text(text, style);
}
}
pub(super) fn push_span(&mut self, span: Span<'static>) {
if self.in_cell {
self.current_cell.push_span(span);
}
}
pub(super) fn push_html(&mut self, html: &str, style: Style) {
let trimmed = html.trim();
if matches!(
trimmed.to_ascii_lowercase().as_str(),
"<br>" | "<br/>" | "<br />"
) {
if self.in_cell {
self.current_cell.push_line_break();
}
} else {
self.push_text(html, style);
}
}
pub(super) fn end_cell(&mut self) {
self.current_row
.push(std::mem::take(&mut self.current_cell));
self.in_cell = false;
}
pub(super) fn end_row(&mut self) {
if !self.current_row.is_empty() {
self.rows.push(std::mem::take(&mut self.current_row));
}
}
}

View File

@@ -28,6 +28,14 @@ fn plain_lines(text: &Text<'_>) -> Vec<String> {
.collect()
}
fn find_span<'a>(text: &'a Text<'_>, content: &str) -> &'a Span<'a> {
text.lines
.iter()
.flat_map(|line| line.spans.iter())
.find(|span| span.content == content)
.unwrap_or_else(|| panic!("expected span containing {content:?} in {text:?}"))
}
fn assert_table_lines_leave_wrap_safety_column(lines: &[String], width: usize) {
assert!(
lines
@@ -295,6 +303,69 @@ fn table_inline_links_and_html_breaks_stay_inside_table() {
);
}
#[test]
fn table_preserves_inline_styles_in_boxed_layout() {
let markdown = "| Feature | Sample |\n| --- | --- |\n| Code | `run just fmt` |\n| Bold | **strong** |\n| Italic | *soft* |\n| Strike | ~~gone~~ |\n| Link | [docs](https://example.com/docs) |\n";
let rendered =
render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(96), /*cwd*/ None);
assert_eq!(find_span(&rendered, "run just fmt").style, "x".cyan().style);
assert_eq!(find_span(&rendered, "strong").style, "x".bold().style);
assert_eq!(find_span(&rendered, "soft").style, "x".italic().style);
assert_eq!(
find_span(&rendered, "gone").style,
"x".crossed_out().style
);
assert_eq!(
find_span(&rendered, "https://example.com/docs").style,
"x".cyan().underlined().style
);
}
#[test]
fn table_preserves_inline_styles_in_vertical_layout() {
let markdown =
"| Feature | Sample | Notes |\n| --- | --- | --- |\n| Code | `cargo test` | **done** |\n";
let rendered =
render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(30), /*cwd*/ None);
let lines = plain_lines(&rendered);
assert!(
lines
.iter()
.any(|line| line.contains("Sample │ cargo")),
"narrow table should render as vertical key/value rows: {lines:?}"
);
assert_eq!(find_span(&rendered, "cargo test").style, "x".cyan().style);
assert_eq!(find_span(&rendered, "done").style, "x".bold().style);
}
#[test]
fn table_local_file_links_match_regular_response_display() {
let markdown = "| Path |\n| --- |\n| [label](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3) |\n";
let rendered = render_markdown_text_with_width_and_cwd(
markdown,
/*width*/ None,
Some(Path::new("/Users/example/code/codex")),
);
let lines = plain_lines(&rendered);
assert!(
lines
.iter()
.any(|line| line.contains("codex-rs/tui/src/markdown_render.rs:74:3")),
"local table link should show the resolved target: {lines:?}"
);
assert!(
lines.iter().all(|line| !line.contains("label")),
"local table link should suppress the markdown label: {lines:?}"
);
assert_eq!(
find_span(&rendered, "codex-rs/tui/src/markdown_render.rs:74:3").style,
"x".cyan().style
);
}
#[test]
fn table_boundary_normalization_does_not_mutate_code_blocks() {
let markdown = "```\n| A | B |\n| --- | --- |\n```\nAfter.\n";