fix(tui): keep markdown table links inline

This commit is contained in:
Felipe Coury
2026-04-29 16:00:19 -03:00
parent d63fd991a8
commit 0df9a141a5
3 changed files with 96 additions and 8 deletions

View File

@@ -285,7 +285,13 @@ 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, .. } => self.push_link(dest_url.to_string()),
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::Table(_) => self.start_table(),
Tag::TableHead | Tag::TableRow => self.start_table_row(),
Tag::TableCell => self.start_table_cell(),
@@ -313,7 +319,13 @@ where
self.pending_marker_line = false;
}
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => self.pop_inline_style(),
TagEnd::Link => self.pop_link(),
TagEnd::Link => {
if let Some(table) = self.table.as_mut() {
table.end_link();
} else {
self.pop_link();
}
}
TagEnd::Table => self.end_table(),
TagEnd::TableHead | TagEnd::TableRow => self.end_table_row(),
TagEnd::TableCell => self.end_table_cell(),
@@ -497,6 +509,10 @@ where
}
fn html(&mut self, html: CowStr<'a>, inline: bool) {
if let Some(table) = self.table.as_mut() {
table.push_html(&html);
return;
}
if self.suppressing_local_link_label() {
return;
}

View File

@@ -8,6 +8,7 @@ pub(super) struct TableState {
current_row: Vec<String>,
current_cell: String,
in_cell: bool,
current_link_destination: Option<String>,
}
impl TableState {
@@ -26,7 +27,35 @@ impl TableState {
}
}
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;
@@ -299,18 +328,28 @@ fn wrap_table_cell(cell: &str, width: usize, hard_wrap: bool) -> Vec<String> {
if cell.is_empty() {
return vec![String::new()];
}
let mut lines = Vec::new();
let options = textwrap::Options::new(width)
.break_words(hard_wrap)
.word_separator(textwrap::WordSeparator::AsciiSpace)
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit);
let wrapped = textwrap::wrap(cell, options)
.into_iter()
.map(std::borrow::Cow::into_owned)
.collect::<Vec<_>>();
if wrapped.is_empty() {
for segment in cell.split('\n') {
let wrapped = textwrap::wrap(segment, options.clone())
.into_iter()
.map(std::borrow::Cow::into_owned)
.collect::<Vec<_>>();
if wrapped.is_empty() {
lines.push(String::new());
} else {
lines.extend(wrapped);
}
}
if lines.is_empty() {
vec![String::new()]
} else {
wrapped
lines
}
}

View File

@@ -94,6 +94,39 @@ fn table_resize_lifecycle_renderer_uses_vertical_fallback_only_at_tiny_width() {
);
}
#[test]
fn table_inline_links_and_html_breaks_stay_inside_table() {
let markdown = "| A | B |\n|---|---|\n| [link](https://example.com) | [CLI docs](https://example.com/cli) |\n| one<br>two | three<br>four |\n";
let rendered = render_markdown_text_with_width_and_cwd(markdown, Some(72), /*cwd*/ None);
let lines = plain_lines(&rendered);
assert!(
lines
.iter()
.filter(|line| !line.trim().is_empty())
.all(|line| line.contains('│') || line.contains('─')),
"table inline content should not leak outside table: {lines:?}"
);
assert!(
lines
.iter()
.any(|line| line.contains("link (https://example.com)")),
"link destination should render in the table cell: {lines:?}"
);
assert!(
lines
.iter()
.any(|line| line.contains("CLI docs (https://example.com/cli)")),
"second link destination should render in the table cell: {lines:?}"
);
assert!(
lines.iter().any(|line| line.contains("one"))
&& lines.iter().any(|line| line.contains("two"))
&& lines.iter().all(|line| !line.contains("<br>")),
"HTML breaks should render as table cell line breaks: {lines:?}"
);
}
#[test]
fn empty() {
assert_eq!(render_markdown_text(""), Text::default());