markdown: honour table alignment in the TUI too, and bound the table measure

Follows the desktop2 fix through the other consumer of the shared
document model: the TUI adapter now pads table cells to the delimiter
row's declared alignment, so the terminal and the desktop agree about
what a table means rather than only about what is in its cells.

Also memoizes the monospace cell advance per message (a streaming delta
re-flattens every block of the tail message, and measuring it per block
put a Parley layout on that path), names the table measure bounds, and
covers narrow measures and character-by-character streaming of a table,
which is where a width-dependent layout is most likely to go wrong.
This commit is contained in:
jeremy
2026-07-31 14:05:48 -07:00
parent 7201c57d8c
commit 70ce4b236a
4 changed files with 135 additions and 9 deletions
+82 -7
View File
@@ -799,19 +799,24 @@ pub fn lay_out_message_reusing(
// Kind of the block laid immediately before this one, so the gap between
// them can depend on the pair rather than on one of them alone.
let mut previous_kind: Option<BlockKind> = None;
// Width of one monospace cell, measured lazily (see below).
let mut advance: Option<f64> = None;
for block in &document.blocks {
let inset = block_inset(block) + role_inset;
// Measure available in *characters*: the app is a monospace stack, so
// a table's columns are laid out in cells, and the cell width has to
// come from the font rather than from a guess.
let measure = (width - inset * 2.0).max(1.0);
// Measure available in *characters*: the app is a monospace stack, so a
// table's columns are laid out in cells, and the cell width has to come
// from the font rather than from a guess. Measured at most once per
// message, and only when a table asks: it costs a Parley layout, and a
// streaming delta re-flattens every block of the tail message.
let advance = &mut advance;
let lines = block_lines(block, || {
let advance = text.measure_width("0", base, scale);
if advance <= 0.0 {
80
let cell = *advance.get_or_insert_with(|| text.measure_width("0", base, scale));
if cell <= 0.0 {
DEFAULT_TABLE_COLUMNS
} else {
((measure / advance).floor() as usize).max(8)
((measure / cell).floor() as usize).max(MIN_TABLE_COLUMNS)
}
});
if lines.is_empty() {
@@ -1281,6 +1286,13 @@ fn column_widths(rows: &[Vec<String>], count: usize, columns: usize) -> Vec<usiz
widths
}
/// Measure to fall back to when the font reports no advance at all, and the
/// narrowest measure a table is laid out against. A table squeezed below this
/// is unreadable either way, so it is allowed to be the one thing that
/// overflows rather than being shredded into single letters.
const DEFAULT_TABLE_COLUMNS: usize = 80;
const MIN_TABLE_COLUMNS: usize = 16;
/// Narrowest a squeezed table column may become, in monospace cells.
const MIN_COLUMN_WIDTH: usize = 6;
@@ -2356,6 +2368,69 @@ mod tests {
);
}
/// A table streams in one character at a time without panicking or losing
/// its columns. Half a delimiter row is not a table yet, and the block a
/// prefix parses into changes shape as the rows arrive, which is exactly
/// the case a width-dependent layout can get wrong.
#[test]
fn tables_survive_being_streamed() {
let source = "| a | bb |\n|:--|--:|\n| 1 | 2000 |\n| 3 | 4 |\n";
let mut text = TextSystem::default();
for end in source
.char_indices()
.map(|(index, _)| index)
.chain([source.len()])
{
let laid = lay_out_message(
&mut text,
&Message::assistant(&source[..end]),
600.0,
&theme(),
base(),
1.75,
);
assert!(laid.height >= 0.0);
}
}
/// A table laid out to a narrow measure still fits it, and still has every
/// column. The squeeze is the only thing standing between a narrow window
/// and a table drawn off the page, so it is asserted through the real
/// layout rather than only through `table_lines`.
#[test]
fn narrow_windows_still_fit_their_tables() {
let mut text = TextSystem::default();
let source = "| field | meaning | bytes |\n|:--|:-:|--:|\n\
| `kind` | which frame this is and how to read it | 1 |\n\
| `payload` | length-prefixed line-delimited JSON | 4096 |\n";
for width in [220.0, 320.0, 600.0] {
let laid = lay_out_message(
&mut text,
&Message::assistant(source),
width,
&theme(),
base(),
1.75,
);
let table = laid
.blocks
.iter()
.find(|block| block.kind == BlockKind::Table)
.expect("no table block");
// Wrapping inside a cell is fine; wrapping the *row* is what the
// budget exists to prevent, because a wrapped row breaks the
// columns the reader is scanning down.
let rows = table.source.lines().count();
assert!(rows >= 4, "table lost rows at width {width}: {rows}");
for header in ["field", "meaning", "bytes"] {
assert!(
table.source.contains(header),
"table lost the {header:?} column at width {width}"
);
}
}
}
/// A nested block's furniture starts at the block's edge, which is inside
/// the list indent it inherited. Drawing the wash from the message margin
/// left it visibly detached from the text it is supposed to wrap.
@@ -147,6 +147,19 @@ fn strip_blockquote_gutter(text: &str) -> &str {
/// Render a table as ASCII-style lines
/// max_width: Optional maximum width for the entire table
pub(super) fn render_table(rows: &[Vec<String>], max_width: Option<usize>) -> Vec<Line<'static>> {
render_table_aligned(rows, max_width, &[])
}
/// As [`render_table`], honouring the delimiter row's per-column alignment.
///
/// A right-aligned numeric column that renders left-aligned misreads what the
/// author wrote, and the alignment is the one piece of table structure a
/// front-end cannot recover from the cells alone.
pub(super) fn render_table_aligned(
rows: &[Vec<String>],
max_width: Option<usize>,
alignments: &[jcode_render_core::Alignment],
) -> Vec<Line<'static>> {
if rows.is_empty() {
return vec![];
}
@@ -230,7 +243,23 @@ pub(super) fn render_table(rows: &[Vec<String>], max_width: Option<usize>) -> Ve
.unwrap_or_else(|| UnicodeWidthStr::width(display_text));
let text_width = UnicodeWidthStr::width(display_text);
let pad = col_width.saturating_sub(text_width);
let padded = format!("{}{}", display_text, " ".repeat(pad));
let padded = match alignments.get(i).copied().unwrap_or_default() {
jcode_render_core::Alignment::Left => {
format!("{}{}", display_text, " ".repeat(pad))
}
jcode_render_core::Alignment::Right => {
format!("{}{}", " ".repeat(pad), display_text)
}
jcode_render_core::Alignment::Center => {
let left = pad / 2;
format!(
"{}{}{}",
" ".repeat(left),
display_text,
" ".repeat(pad - left)
)
}
};
// Header row gets bold styling
let style = if row_idx == 0 {
@@ -157,7 +157,11 @@ pub fn document_to_lines_with_width(doc: &Document, width: Option<usize>) -> Vec
push_math_display(&mut lines, block);
}
BlockKind::Table => {
lines.extend(crate::render_support::render_table(&block.table, width));
lines.extend(crate::render_support::render_table_aligned(
&block.table,
width,
&block.alignments,
));
}
BlockKind::ThematicBreak => {
lines.push(Line::from(Span::styled(
@@ -562,3 +562,21 @@ fn fuzz_random_documents_wrapped_parity() {
.join("\n\n")
);
}
/// A table's delimiter row says how to read its columns, and the TUI has to
/// honour it too: a right-aligned numeric column that renders left-aligned
/// misreads what the author wrote, and the desktop and the terminal are meant
/// to agree about what a document *is*.
#[test]
fn table_columns_follow_the_declared_alignment() {
let lines = super::render_markdown_via_core_wrapped("| n |\n|--:|\n| 1 |\n| 1000 |\n", 40);
let short = lines
.iter()
.map(crate::line_plain_text)
.find(|text| text.trim() == "1")
.expect("no short row");
assert!(
short.starts_with(' '),
"right-aligned cell was not padded on the left: {short:?}"
);
}