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:
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user