From a376443042589b4ac6eb779f0f24de3da1211271 Mon Sep 17 00:00:00 2001 From: Yannis Date: Mon, 14 Sep 2026 15:54:48 +0800 Subject: [PATCH 1/7] feat(tui): reflow wide table cells (cherry picked from commit c18b324ccff5f89c6639463d7b1e2c83ed623dbf) --- crates/plannotator-tui/README.md | 2 +- crates/plannotator-tui/src/layout.rs | 6 +- crates/plannotator-tui/src/wrap.rs | 231 +++++++++++++++++++++++++++ 3 files changed, 236 insertions(+), 3 deletions(-) diff --git a/crates/plannotator-tui/README.md b/crates/plannotator-tui/README.md index 93b5391..52f243f 100644 --- a/crates/plannotator-tui/README.md +++ b/crates/plannotator-tui/README.md @@ -13,7 +13,7 @@ cargo build --release | Where | Keys | |---|---| | anywhere | `Tab` cycle focus (tree · document · rail) · `E` send feedback (clipboard) · `t` show/hide tree · `r` reload · `q` quit | -| document | drag with the mouse, or `v` then `hjkl` / `w` `b` / `0` `$` to select; `Enter` confirms · `i` moves the cursor with those keys first, so `v` can start mid-block · `j`/`k` or click selects a block · `c` comments on the block · `x` clears the block's annotations | +| document | drag with the mouse, or `v` then `hjkl` / `w` `b` / `0` `$` to select; `Enter` confirms · `i` moves the cursor with those keys first, so `v` can start mid-block · `j`/`k` or click selects a block · `c` comments on the block · `x` clears the block's annotations · wide Markdown tables wrap by cell | | selection toolbar | `a` 👍 looks good · `c` 💬 comment (opens a box at the selection) · `d` ✗ delete · `Esc` clears | | rail | `j`/`k` move · `e` / `Enter` edit body · `x` remove · click a bubble to focus it | | file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) | diff --git a/crates/plannotator-tui/src/layout.rs b/crates/plannotator-tui/src/layout.rs index 0251e27..bb5ee8a 100644 --- a/crates/plannotator-tui/src/layout.rs +++ b/crates/plannotator-tui/src/layout.rs @@ -12,7 +12,7 @@ use tui_markdown::{Options, StyleSheet}; use crate::doc::{BlockKind, Document}; use crate::srcmap::{LineOffsets, align}; -use crate::wrap::{Row, clip_line, wrap_line}; +use crate::wrap::{Row, clip_line, wrap_line, wrap_table}; /// Rows of vertical space between blocks. const BLOCK_GAP: usize = 1; @@ -111,7 +111,9 @@ impl DocLayout { for block in &mut self.blocks { block.first_row = row; let lines = block.text.lines.iter().zip(&block.offsets); - block.rows = if block.kind.preserves_columns() { + block.rows = if block.kind == BlockKind::Table { + wrap_table(&block.text.lines, &block.offsets, width) + } else if block.kind.preserves_columns() { lines.map(|(l, o)| clip_line(l, o, width)).collect() } else { lines.flat_map(|(l, o)| wrap_line(l, o, width)).collect() diff --git a/crates/plannotator-tui/src/wrap.rs b/crates/plannotator-tui/src/wrap.rs index 6df76b3..f827837 100644 --- a/crates/plannotator-tui/src/wrap.rs +++ b/crates/plannotator-tui/src/wrap.rs @@ -24,6 +24,13 @@ struct Cell { style: Style, } +#[derive(Clone, Copy)] +enum TableBorder { + Top, + Middle, + Bottom, +} + /// Flatten a line into cells, pairing each char with its source offset. fn cells_of(line: &Line<'_>, offsets: &[Option]) -> Vec { let mut offsets = offsets.iter(); @@ -127,6 +134,206 @@ pub(crate) fn clip_line(line: &Line<'_>, offsets: &[Option], width: usize finish_row(kept, line.style) } +/// Reflow a rendered Unicode table to fit `width`, preserving cell boundaries and source offsets. +/// The Markdown renderer emits a complete table before this layer sees it, so the table is +/// identified from its box-drawing borders and each rendered cell is wrapped independently. +pub(crate) fn wrap_table(lines: &[Line<'_>], offsets: &[Vec>], width: usize) -> Vec { + let Some((original_widths, border_kind, border_style)) = + lines.first().zip(offsets.first()).and_then(|(line, map)| table_border(line, map)) + else { + return lines.iter().zip(offsets).flat_map(|(line, map)| wrap_line(line, map, width)).collect(); + }; + + let column_widths = fit_table_columns(&original_widths, width.max(1)); + let mut out = Vec::new(); + for (line_index, (line, map)) in lines.iter().zip(offsets).enumerate() { + if let Some((_, kind, style)) = table_border(line, map) { + // Keep the detected border kind for each row; the first line's kind only supplies + // the style fallback when a renderer emits an unusual border sequence. + let kind = if matches!(kind, TableBorder::Top | TableBorder::Middle | TableBorder::Bottom) { + kind + } else { + border_kind + }; + out.push(render_table_border(&column_widths, kind, style, border_style)); + } else if let Some((cells, style)) = table_content_cells(line, map) { + let (content_rows, wrapped) = render_table_content(&cells, &column_widths, style, border_style); + out.extend(content_rows); + let next_is_content = lines + .get(line_index + 1) + .zip(offsets.get(line_index + 1)) + .is_some_and(|(next_line, next_map)| table_content_cells(next_line, next_map).is_some()); + if wrapped && next_is_content { + out.push(render_table_border( + &column_widths, + TableBorder::Middle, + border_style, + border_style, + )); + } + } else { + out.extend(wrap_line(line, map, width)); + } + } + out +} + +fn table_border(line: &Line<'_>, offsets: &[Option]) -> Option<(Vec, TableBorder, Style)> { + let cells = cells_of(line, offsets); + let first = cells.first()?.ch; + let (kind, left, intersection, right) = match first { + '┌' => (TableBorder::Top, '┌', '┬', '┐'), + '├' => (TableBorder::Middle, '├', '┼', '┤'), + '└' => (TableBorder::Bottom, '└', '┴', '┘'), + _ => return None, + }; + if cells.last()?.ch != right { + return None; + } + let mut widths = Vec::new(); + let mut current = 0usize; + let mut seen_left = false; + for cell in &cells { + if cell.ch == left && !seen_left { + seen_left = true; + } else if seen_left && cell.ch == '─' { + current += cell.width; + } else if seen_left && matches!(cell.ch, c if c == intersection || c == right) { + if current == 0 { + return None; + } + widths.push(current.saturating_sub(2).max(1)); + current = 0; + } else if seen_left { + return None; + } + } + (!widths.is_empty()).then(|| (widths, kind, cells.first().map_or(Style::default(), |c| c.style))) +} + +fn table_content_cells(line: &Line<'_>, offsets: &[Option]) -> Option<(Vec>, Style)> { + let cells = cells_of(line, offsets); + if cells.first()?.ch != '│' || cells.last()?.ch != '│' { + return None; + } + let separator_indices: Vec = + cells.iter().enumerate().filter_map(|(index, cell)| (cell.ch == '│').then_some(index)).collect(); + if separator_indices.len() < 2 { + return None; + } + let mut columns = Vec::with_capacity(separator_indices.len() - 1); + for pair in separator_indices.windows(2) { + let [left, right] = pair else { continue }; + let mut column = cells.get(left + 1..*right)?.to_vec(); + while column.first().is_some_and(|cell| cell.ch.is_whitespace()) { + column.remove(0); + } + while column.last().is_some_and(|cell| cell.ch.is_whitespace()) { + column.pop(); + } + columns.push(column); + } + Some((columns, cells.first().map_or(Style::default(), |c| c.style))) +} + +fn fit_table_columns(original: &[usize], width: usize) -> Vec { + let columns = original.len(); + let overhead = columns.saturating_mul(3).saturating_add(1); + let content_budget = width.saturating_sub(overhead).max(columns); + let original_total: usize = original.iter().sum(); + if original_total <= content_budget { + return original.to_vec(); + } + let mut fitted = vec![1; columns]; + let mut remaining = content_budget.saturating_sub(columns); + let mut order: Vec = (0..columns).collect(); + order.sort_by_key(|&index| std::cmp::Reverse(original.get(index).copied().unwrap_or(0))); + let mut cursor = 0usize; + while remaining > 0 && !order.is_empty() { + let Some(&index) = order.get(cursor % order.len()) else { break }; + if let Some(slot) = fitted.get_mut(index) { + *slot += 1; + } + cursor += 1; + remaining -= 1; + } + fitted +} + +fn render_table_border(widths: &[usize], kind: TableBorder, style: Style, fallback_style: Style) -> Row { + let (left, intersection, right) = match kind { + TableBorder::Top => ('┌', '┬', '┐'), + TableBorder::Middle => ('├', '┼', '┤'), + TableBorder::Bottom => ('└', '┴', '┘'), + }; + let style = if style == Style::default() { fallback_style } else { style }; + let mut cells = vec![Cell { ch: left, width: 1, offset: None, style }]; + for (index, &width) in widths.iter().enumerate() { + cells.extend((0..width + 2).map(|_| Cell { ch: '─', width: 1, offset: None, style })); + if index + 1 < widths.len() { + cells.push(Cell { ch: intersection, width: 1, offset: None, style }); + } + } + cells.push(Cell { ch: right, width: 1, offset: None, style }); + finish_row(cells, Style::default()) +} + +fn render_table_content( + columns: &[Vec], + widths: &[usize], + cell_style: Style, + border_style: Style, +) -> (Vec, bool) { + let wrapped: Vec> = widths + .iter() + .enumerate() + .map(|(index, &width)| { + let line = finish_row(columns.get(index).cloned().unwrap_or_default(), cell_style); + wrap_line(&line.line, &line.cells, width.max(1)) + }) + .collect(); + let height = wrapped.iter().map(Vec::len).max().unwrap_or(1); + let mut rows = Vec::with_capacity(height); + for row_index in 0..height { + let mut cells = vec![Cell { ch: '│', width: 1, offset: None, style: border_style }]; + for (column_index, &width) in widths.iter().enumerate() { + let row = wrapped.get(column_index).and_then(|rows| rows.get(row_index)); + let row_style = + row.and_then(|value| value.line.spans.first().map(|span| span.style)).unwrap_or(cell_style); + cells.push(Cell { ch: ' ', width: 1, offset: None, style: row_style }); + if let Some(row) = row { + cells.extend(cells_from_row(row)); + } + let used = row.map_or(0, |value| value.cells.len()); + cells.extend((0..=width.saturating_sub(used)).map(|_| Cell { + ch: ' ', + width: 1, + offset: None, + style: row_style, + })); + cells.push(Cell { ch: '│', width: 1, offset: None, style: border_style }); + } + rows.push(finish_row(cells, Style::default())); + } + (rows, height > 1) +} + +fn cells_from_row(row: &Row) -> Vec { + let mut offsets = row.cells.iter(); + row.line + .spans + .iter() + .flat_map(|span| span.content.chars().map(move |ch| (ch, span.style))) + .map(|(ch, style)| { + let offset = offsets.next().copied().flatten(); + for _ in 1..ch.width().unwrap_or(0) { + let _ = offsets.next(); + } + Cell { ch, width: ch.width().unwrap_or(0), offset, style } + }) + .collect() +} + #[cfg(test)] #[allow(clippy::expect_used, clippy::indexing_slicing, reason = "tests assert by panicking")] mod tests { @@ -175,4 +382,28 @@ mod tests { fn empty_line_is_one_row() { assert_eq!(wrap_line(&Line::from(""), &[], 20).len(), 1); } + + #[test] + fn table_cells_wrap_without_breaking_the_box() { + let lines = [ + Line::from("┌────────────┬──────────────┐"), + Line::from("│ Header │ Description │"), + Line::from("├────────────┼──────────────┤"), + Line::from("│ item │ TABLE_TAIL is a long value │"), + Line::from("│ second │ another long value │"), + Line::from("└────────────┴──────────────┘"), + ]; + let offsets: Vec>> = + lines.iter().map(|line| (0..line.to_string().chars().count()).map(Some).collect()).collect(); + let rows = wrap_table(&lines, &offsets, 24); + let rendered = rows.iter().map(|row| row.line.to_string()).collect::>().join("\n"); + + assert!(rows.iter().all(|row| row.cells.len() <= 24)); + assert!( + rendered.contains("TABLE_TAI") && rendered.contains("L is a"), + "rendered table was {rendered:?}" + ); + assert!(rendered.contains("┌") && rendered.contains("└")); + assert_eq!(rendered.lines().filter(|line| line.starts_with("├")).count(), 2); + } } From 677d02e52f396009ef7493ba6e34c5d37bdfd9aa Mon Sep 17 00:00:00 2001 From: Yannis Date: Mon, 14 Sep 2026 17:26:17 +0800 Subject: [PATCH 2/7] fix(tui): grid all rows in wrapped tables (cherry picked from commit 1bc7351581dbff276b81832977e4496b55e17c65) --- crates/plannotator-tui/src/wrap.rs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/plannotator-tui/src/wrap.rs b/crates/plannotator-tui/src/wrap.rs index f827837..5e8169c 100644 --- a/crates/plannotator-tui/src/wrap.rs +++ b/crates/plannotator-tui/src/wrap.rs @@ -145,6 +145,11 @@ pub(crate) fn wrap_table(lines: &[Line<'_>], offsets: &[Vec>], wid }; let column_widths = fit_table_columns(&original_widths, width.max(1)); + let table_wraps = lines + .iter() + .zip(offsets) + .filter_map(|(line, map)| table_content_cells(line, map).map(|(cells, _)| cells)) + .any(|cells| table_content_wraps(&cells, &column_widths)); let mut out = Vec::new(); for (line_index, (line, map)) in lines.iter().zip(offsets).enumerate() { if let Some((_, kind, style)) = table_border(line, map) { @@ -157,13 +162,13 @@ pub(crate) fn wrap_table(lines: &[Line<'_>], offsets: &[Vec>], wid }; out.push(render_table_border(&column_widths, kind, style, border_style)); } else if let Some((cells, style)) = table_content_cells(line, map) { - let (content_rows, wrapped) = render_table_content(&cells, &column_widths, style, border_style); + let content_rows = render_table_content(&cells, &column_widths, style, border_style); out.extend(content_rows); let next_is_content = lines .get(line_index + 1) .zip(offsets.get(line_index + 1)) .is_some_and(|(next_line, next_map)| table_content_cells(next_line, next_map).is_some()); - if wrapped && next_is_content { + if table_wraps && next_is_content { out.push(render_table_border( &column_widths, TableBorder::Middle, @@ -283,7 +288,7 @@ fn render_table_content( widths: &[usize], cell_style: Style, border_style: Style, -) -> (Vec, bool) { +) -> Vec { let wrapped: Vec> = widths .iter() .enumerate() @@ -315,7 +320,14 @@ fn render_table_content( } rows.push(finish_row(cells, Style::default())); } - (rows, height > 1) + rows +} + +fn table_content_wraps(columns: &[Vec], widths: &[usize]) -> bool { + widths.iter().enumerate().any(|(index, &width)| { + let line = finish_row(columns.get(index).cloned().unwrap_or_default(), Style::default()); + wrap_line(&line.line, &line.cells, width.max(1)).len() > 1 + }) } fn cells_from_row(row: &Row) -> Vec { @@ -391,6 +403,7 @@ mod tests { Line::from("├────────────┼──────────────┤"), Line::from("│ item │ TABLE_TAIL is a long value │"), Line::from("│ second │ another long value │"), + Line::from("│ third │ short │"), Line::from("└────────────┴──────────────┘"), ]; let offsets: Vec>> = @@ -404,6 +417,6 @@ mod tests { "rendered table was {rendered:?}" ); assert!(rendered.contains("┌") && rendered.contains("└")); - assert_eq!(rendered.lines().filter(|line| line.starts_with("├")).count(), 2); + assert_eq!(rendered.lines().filter(|line| line.starts_with("├")).count(), 3); } } From 926b3b5210bf2cddf0a0ae1fa0c0e841b7ce6285 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 21 Sep 2026 10:38:33 -0700 Subject: [PATCH 3/7] feat(layout): size wrapped table columns by their content Every column first gets room for its longest word, then the spare width is shared in proportion to what each column asked for beyond that. Equal shares gave "Risk" as much room as "Notes" and split words like "platform". refs #79 --- crates/plannotator-tui/src/wrap.rs | 144 +++++++++++++++++++++++------ 1 file changed, 118 insertions(+), 26 deletions(-) diff --git a/crates/plannotator-tui/src/wrap.rs b/crates/plannotator-tui/src/wrap.rs index 5e8169c..cd630a5 100644 --- a/crates/plannotator-tui/src/wrap.rs +++ b/crates/plannotator-tui/src/wrap.rs @@ -138,13 +138,21 @@ pub(crate) fn clip_line(line: &Line<'_>, offsets: &[Option], width: usize /// The Markdown renderer emits a complete table before this layer sees it, so the table is /// identified from its box-drawing borders and each rendered cell is wrapped independently. pub(crate) fn wrap_table(lines: &[Line<'_>], offsets: &[Vec>], width: usize) -> Vec { - let Some((original_widths, border_kind, border_style)) = + let Some((original_widths, _, border_style)) = lines.first().zip(offsets.first()).and_then(|(line, map)| table_border(line, map)) else { return lines.iter().zip(offsets).flat_map(|(line, map)| wrap_line(line, map, width)).collect(); }; - let column_widths = fit_table_columns(&original_widths, width.max(1)); + let mut longest_word = vec![0usize; original_widths.len()]; + for (line, map) in lines.iter().zip(offsets) { + if let Some((cells, _)) = table_content_cells(line, map) { + for (column, longest) in cells.iter().zip(longest_word.iter_mut()) { + *longest = (*longest).max(longest_word_width(column)); + } + } + } + let column_widths = fit_table_columns(&original_widths, &longest_word, width.max(1)); let table_wraps = lines .iter() .zip(offsets) @@ -153,13 +161,6 @@ pub(crate) fn wrap_table(lines: &[Line<'_>], offsets: &[Vec>], wid let mut out = Vec::new(); for (line_index, (line, map)) in lines.iter().zip(offsets).enumerate() { if let Some((_, kind, style)) = table_border(line, map) { - // Keep the detected border kind for each row; the first line's kind only supplies - // the style fallback when a renderer emits an unusual border sequence. - let kind = if matches!(kind, TableBorder::Top | TableBorder::Middle | TableBorder::Bottom) { - kind - } else { - border_kind - }; out.push(render_table_border(&column_widths, kind, style, border_style)); } else if let Some((cells, style)) = table_content_cells(line, map) { let content_rows = render_table_content(&cells, &column_widths, style, border_style); @@ -241,30 +242,79 @@ fn table_content_cells(line: &Line<'_>, offsets: &[Option]) -> Option<(Ve Some((columns, cells.first().map_or(Style::default(), |c| c.style))) } -fn fit_table_columns(original: &[usize], width: usize) -> Vec { +/// Column widths for a table that does not fit. Every column first gets room for its +/// longest word (capped, so one huge token cannot starve the rest); the space left over is +/// shared in proportion to how much each column asked for beyond that, so a column of short +/// words stays narrow and the long one takes the room. A column never grows past its own +/// content. Only when the words alone exceed the budget do columns shrink below them. +fn fit_table_columns(original: &[usize], longest_word: &[usize], width: usize) -> Vec { + const WORD_CAP: usize = 20; let columns = original.len(); let overhead = columns.saturating_mul(3).saturating_add(1); - let content_budget = width.saturating_sub(overhead).max(columns); - let original_total: usize = original.iter().sum(); - if original_total <= content_budget { + let budget = width.saturating_sub(overhead).max(columns); + let total: usize = original.iter().sum(); + if total == 0 || total <= budget { return original.to_vec(); } - let mut fitted = vec![1; columns]; - let mut remaining = content_budget.saturating_sub(columns); - let mut order: Vec = (0..columns).collect(); - order.sort_by_key(|&index| std::cmp::Reverse(original.get(index).copied().unwrap_or(0))); - let mut cursor = 0usize; - while remaining > 0 && !order.is_empty() { - let Some(&index) = order.get(cursor % order.len()) else { break }; + let minimum: Vec = original + .iter() + .enumerate() + .map(|(i, &w)| longest_word.get(i).copied().unwrap_or(1).clamp(1, WORD_CAP).min(w).max(1)) + .collect(); + let floor: usize = minimum.iter().sum(); + if floor >= budget { + // Even the words do not fit: scale them down, keeping every column at least one cell. + let mut fitted: Vec = + minimum.iter().map(|&m| (m.saturating_mul(budget) / floor).max(1)).collect(); + while fitted.iter().sum::() > budget { + let Some((index, _)) = + fitted.iter().enumerate().filter(|(_, w)| **w > 1).max_by_key(|(_, w)| **w) + else { + break; + }; + if let Some(slot) = fitted.get_mut(index) { + *slot -= 1; + } + } + return fitted; + } + let spare = budget - floor; + let wants: Vec = original.iter().zip(&minimum).map(|(&o, &m)| o.saturating_sub(m)).collect(); + let wanted: usize = wants.iter().sum::().max(1); + let mut fitted: Vec = + minimum.iter().zip(&wants).map(|(&m, &w)| m + w.saturating_mul(spare) / wanted).collect(); + // Rounding leaves a few cells over: give them to whichever column is squeezed the most. + while fitted.iter().sum::() < budget { + let deficit = |i: usize| { + original.get(i).copied().unwrap_or(0).saturating_sub(fitted.get(i).copied().unwrap_or(0)) + }; + let Some((index, _)) = + (0..columns).map(|i| (i, deficit(i))).filter(|(_, d)| *d > 0).max_by_key(|(_, d)| *d) + else { + break; + }; if let Some(slot) = fitted.get_mut(index) { *slot += 1; } - cursor += 1; - remaining -= 1; } fitted } +/// The widest run of non-whitespace cells in a rendered cell's content. +fn longest_word_width(cells: &[Cell]) -> usize { + let mut longest = 0; + let mut run = 0; + for cell in cells { + if cell.ch.is_whitespace() { + longest = longest.max(run); + run = 0; + } else { + run += cell.width; + } + } + longest.max(run) +} + fn render_table_border(widths: &[usize], kind: TableBorder, style: Style, fallback_style: Style) -> Row { let (left, intersection, right) = match kind { TableBorder::Top => ('┌', '┬', '┐'), @@ -395,6 +445,49 @@ mod tests { assert_eq!(wrap_line(&Line::from(""), &[], 20).len(), 1); } + #[test] + fn columns_keep_their_longest_word_and_share_the_rest_in_proportion() { + // Step 37, Owner 8, Risk 6, Notes 60 at a 68-cell document: budget is 68 - 13 = 55. + // Longest words: "accounts" 8, "platform" 8, "medium" 6, "config/flags.toml," 18. + let widths = fit_table_columns(&[37, 8, 6, 60], &[8, 8, 6, 18], 68); + assert_eq!(widths.iter().sum::(), 55, "{widths:?}"); + assert_eq!( + (widths[1], widths[2]), + (8, 6), + "short columns get exactly their longest word: {widths:?}" + ); + assert!(widths[3] > widths[0], "the long column takes most of the spare room: {widths:?}"); + assert!(widths.iter().zip([37, 8, 6, 60]).all(|(w, o)| *w <= o), "never wider than the content"); + assert_eq!( + fit_table_columns(&[10, 20], &[4, 7], 80), + vec![10, 20], + "a table that fits keeps its widths" + ); + let tiny = fit_table_columns(&[30, 30, 30], &[12, 12, 12], 12); + assert_eq!(tiny.iter().sum::(), 3, "a tiny budget still gives every column a cell: {tiny:?}"); + } + + #[test] + fn words_stay_whole_when_a_wrapped_table_has_room_for_them() { + let lines = [ + Line::from("┌──────────────────────────────────┬──────────┬────────┐"), + Line::from("│ Step │ Owner │ Risk │"), + Line::from("├──────────────────────────────────┼──────────┼────────┤"), + Line::from("│ Enable the flag for everyone now │ platform │ medium │"), + Line::from("└──────────────────────────────────┴──────────┴────────┘"), + ]; + let offsets: Vec>> = + lines.iter().map(|line| (0..line.to_string().chars().count()).map(Some).collect()).collect(); + let rendered = wrap_table(&lines, &offsets, 40) + .iter() + .map(|row| row.line.to_string()) + .collect::>() + .join("\n"); + for word in ["Owner", "platform", "medium", "Risk"] { + assert!(rendered.contains(word), "{word} was split: {rendered}"); + } + } + #[test] fn table_cells_wrap_without_breaking_the_box() { let lines = [ @@ -412,10 +505,9 @@ mod tests { let rendered = rows.iter().map(|row| row.line.to_string()).collect::>().join("\n"); assert!(rows.iter().all(|row| row.cells.len() <= 24)); - assert!( - rendered.contains("TABLE_TAI") && rendered.contains("L is a"), - "rendered table was {rendered:?}" - ); + // The long cell wraps, but its text survives intact and in order. + let squashed: String = rendered.chars().filter(|c| c.is_alphanumeric() || *c == '_').collect(); + assert!(squashed.contains("TABLE_TAILisalongvalue"), "rendered table was {rendered:?}"); assert!(rendered.contains("┌") && rendered.contains("└")); assert_eq!(rendered.lines().filter(|line| line.starts_with("├")).count(), 3); } From 93a3ea23ff465212e7205689f1aae257c3e62879 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 21 Sep 2026 10:50:39 -0700 Subject: [PATCH 4/7] fix(wrap): wrap table cells without losing wide-char offsets Table cells were wrapped by building a Row and feeding its per-column cell map back through wrap_line, which reads one offset per char. After a wide char every later offset in the cell pointed one char too early, and a zero-width char shifted them the other way. Wrapping now works on the Cell slice the table code already holds. refs #79 --- crates/plannotator-tui/src/wrap.rs | 136 ++++++++++++++++++++--------- 1 file changed, 95 insertions(+), 41 deletions(-) diff --git a/crates/plannotator-tui/src/wrap.rs b/crates/plannotator-tui/src/wrap.rs index cd630a5..1d8f27a 100644 --- a/crates/plannotator-tui/src/wrap.rs +++ b/crates/plannotator-tui/src/wrap.rs @@ -46,11 +46,17 @@ fn cells_of(line: &Line<'_>, offsets: &[Option]) -> Vec { .collect() } -/// Turn accumulated cells into a row, merging same-style runs into spans. -fn finish_row(mut cells: Vec, line_style: Style) -> Row { +/// Drop trailing whitespace: a wrapped piece can end on the space it broke at. +fn trim_trailing_space(mut cells: Vec) -> Vec { while cells.last().is_some_and(|c| c.ch.is_whitespace()) { cells.pop(); } + cells +} + +/// Turn accumulated cells into a row, merging same-style runs into spans. +fn finish_row(cells: Vec, line_style: Style) -> Row { + let cells = trim_trailing_space(cells); let mut spans: Vec> = Vec::new(); let mut columns: Vec> = Vec::new(); for cell in &cells { @@ -63,17 +69,18 @@ fn finish_row(mut cells: Vec, line_style: Style) -> Row { Row { line: Line::from(spans).style(line_style), cells: columns } } -/// Wrap one logical line into as many rows as needed for `width` columns. -/// `offsets` has one entry per char of the line. An empty line yields one empty row. -pub(crate) fn wrap_line(line: &Line<'_>, offsets: &[Option], width: usize) -> Vec { +/// Split cells into the runs that fit `width` columns each. One empty piece for no cells. +/// +/// Cells, not `Row`s: a `Row` records one offset per *column*, so reading offsets back out +/// of one would mis-pair them after any wide or zero-width char. +fn wrap_cells(cells: &[Cell], width: usize) -> Vec> { let width = width.max(1); - let cells = cells_of(line, offsets); - let mut rows: Vec = Vec::new(); + let mut pieces: Vec> = Vec::new(); let mut current: Vec = Vec::new(); let mut current_width = 0usize; // Tokens are unbreakable runs: a word, or a run of whitespace. - let mut rest = cells.as_slice(); + let mut rest = cells; while let Some(first) = rest.first() { let is_space = first.ch.is_whitespace(); let len = rest.iter().take_while(|c| c.ch.is_whitespace() == is_space).count(); @@ -82,7 +89,7 @@ pub(crate) fn wrap_line(line: &Line<'_>, offsets: &[Option], width: usize let token_width: usize = token.iter().map(|c| c.width).sum(); // Whitespace at a row start is dropped, except leading indentation on the first row. - if is_space && current.is_empty() && !rows.is_empty() { + if is_space && current.is_empty() && !pieces.is_empty() { continue; } if current_width + token_width <= width { @@ -91,7 +98,7 @@ pub(crate) fn wrap_line(line: &Line<'_>, offsets: &[Option], width: usize continue; } if !current.is_empty() { - rows.push(finish_row(std::mem::take(&mut current), line.style)); + pieces.push(std::mem::take(&mut current)); current_width = 0; if is_space { continue; @@ -104,7 +111,7 @@ pub(crate) fn wrap_line(line: &Line<'_>, offsets: &[Option], width: usize // Token wider than a row: hard-split by cells. for cell in token { if current_width + cell.width > width && !current.is_empty() { - rows.push(finish_row(std::mem::take(&mut current), line.style)); + pieces.push(std::mem::take(&mut current)); current_width = 0; } current.push(*cell); @@ -112,10 +119,19 @@ pub(crate) fn wrap_line(line: &Line<'_>, offsets: &[Option], width: usize } } } - if !current.is_empty() || rows.is_empty() { - rows.push(finish_row(current, line.style)); + if !current.is_empty() || pieces.is_empty() { + pieces.push(current); } - rows + pieces +} + +/// Wrap one logical line into as many rows as needed for `width` columns. +/// `offsets` has one entry per char of the line. An empty line yields one empty row. +pub(crate) fn wrap_line(line: &Line<'_>, offsets: &[Option], width: usize) -> Vec { + wrap_cells(&cells_of(line, offsets), width) + .into_iter() + .map(|piece| finish_row(piece, line.style)) + .collect() } /// Keep one row; clip anything past `width` (code and tables keep their columns). @@ -339,12 +355,14 @@ fn render_table_content( cell_style: Style, border_style: Style, ) -> Vec { - let wrapped: Vec> = widths + let wrapped: Vec>> = widths .iter() .enumerate() .map(|(index, &width)| { - let line = finish_row(columns.get(index).cloned().unwrap_or_default(), cell_style); - wrap_line(&line.line, &line.cells, width.max(1)) + wrap_cells(columns.get(index).map_or(&[][..], Vec::as_slice), width.max(1)) + .into_iter() + .map(trim_trailing_space) + .collect() }) .collect(); let height = wrapped.iter().map(Vec::len).max().unwrap_or(1); @@ -352,14 +370,13 @@ fn render_table_content( for row_index in 0..height { let mut cells = vec![Cell { ch: '│', width: 1, offset: None, style: border_style }]; for (column_index, &width) in widths.iter().enumerate() { - let row = wrapped.get(column_index).and_then(|rows| rows.get(row_index)); - let row_style = - row.and_then(|value| value.line.spans.first().map(|span| span.style)).unwrap_or(cell_style); + let piece = wrapped.get(column_index).and_then(|pieces| pieces.get(row_index)); + let row_style = piece.and_then(|p| p.first().map(|c| c.style)).unwrap_or(cell_style); cells.push(Cell { ch: ' ', width: 1, offset: None, style: row_style }); - if let Some(row) = row { - cells.extend(cells_from_row(row)); + let used = piece.map_or(0, |p| p.iter().map(|c| c.width).sum()); + if let Some(piece) = piece { + cells.extend_from_slice(piece); } - let used = row.map_or(0, |value| value.cells.len()); cells.extend((0..=width.saturating_sub(used)).map(|_| Cell { ch: ' ', width: 1, @@ -375,27 +392,10 @@ fn render_table_content( fn table_content_wraps(columns: &[Vec], widths: &[usize]) -> bool { widths.iter().enumerate().any(|(index, &width)| { - let line = finish_row(columns.get(index).cloned().unwrap_or_default(), Style::default()); - wrap_line(&line.line, &line.cells, width.max(1)).len() > 1 + wrap_cells(columns.get(index).map_or(&[][..], Vec::as_slice), width.max(1)).len() > 1 }) } -fn cells_from_row(row: &Row) -> Vec { - let mut offsets = row.cells.iter(); - row.line - .spans - .iter() - .flat_map(|span| span.content.chars().map(move |ch| (ch, span.style))) - .map(|(ch, style)| { - let offset = offsets.next().copied().flatten(); - for _ in 1..ch.width().unwrap_or(0) { - let _ = offsets.next(); - } - Cell { ch, width: ch.width().unwrap_or(0), offset, style } - }) - .collect() -} - #[cfg(test)] #[allow(clippy::expect_used, clippy::indexing_slicing, reason = "tests assert by panicking")] mod tests { @@ -410,6 +410,42 @@ mod tests { (0..n).map(Some).collect() } + /// Offsets that are unique across the whole table, so a cell offset names exactly one + /// source char. Returns the flat source and one offset per char of each line. + fn unique_offsets(lines: &[Line<'static>]) -> (Vec, Vec>>) { + let mut source = Vec::new(); + let mut offsets = Vec::new(); + for line in lines { + let chars: Vec = line.to_string().chars().collect(); + offsets.push((source.len()..source.len() + chars.len()).map(Some).collect()); + source.extend(chars); + } + (source, offsets) + } + + /// Every mapped column of every row points at the char actually drawn there. + fn assert_cells_name_their_char(rows: &[Row], source: &[char]) { + for row in rows { + let mut column = 0usize; + for ch in row.line.spans.iter().flat_map(|span| span.content.chars()) { + let width = ch.width().unwrap_or(0); + for slot in column..column + width { + if let Some(offset) = row.cells[slot] { + assert_eq!( + source.get(offset).copied(), + Some(ch), + "column {slot} of {:?} claims offset {offset} ({:?})", + row.line.to_string(), + source.get(offset) + ); + } + } + column += width; + } + assert_eq!(column, row.cells.len(), "row width disagrees with its cell map"); + } + } + #[test] fn wraps_on_word_boundaries_and_keeps_offsets() { let line = Line::from("the quick brown fox jumps"); @@ -511,4 +547,22 @@ mod tests { assert!(rendered.contains("┌") && rendered.contains("└")); assert_eq!(rendered.lines().filter(|line| line.starts_with("├")).count(), 3); } + + #[test] + fn wrapped_table_cells_still_name_the_source_char_under_them() { + // Wide chars take two columns and variation selectors and ZWJ take none, so a cell + // map built by counting columns per char drifts after the first of either. + let lines = [ + Line::from("┌──────────────────────┬──────────────────────┐"), + Line::from("│ Head │ Detail │"), + Line::from("├──────────────────────┼──────────────────────┤"), + Line::from("│ alpha beta gamma del │ 中文 漢字 表格 内容 説明 │"), + Line::from("│ ⚠️ caution ⚠️ severe │ 👨‍👩‍👧 family 👍 fine │"), + Line::from("└──────────────────────┴──────────────────────┘"), + ]; + let (source, offsets) = unique_offsets(&lines); + let rows = wrap_table(&lines, &offsets, 30); + assert!(rows.len() > lines.len(), "the fixture must actually wrap: {}", rows.len()); + assert_cells_name_their_char(&rows, &source); + } } From 7a8d3fd63e82e91ccabf962719067aae888294a3 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 21 Sep 2026 10:51:39 -0700 Subject: [PATCH 5/7] fix(wrap): clip tables the reflow cannot parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `│` in cell text splits a row into more columns than the border has and the surplus was silently dropped; such a block now keeps its columns and clips, as it did before the reflow. Lines that are neither border nor row, and blocks with no border at all, clip too instead of word-wrapping a box-drawing row into the middle of the table. refs #79 --- crates/plannotator-tui/src/wrap.rs | 55 ++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/plannotator-tui/src/wrap.rs b/crates/plannotator-tui/src/wrap.rs index 1d8f27a..28e3c26 100644 --- a/crates/plannotator-tui/src/wrap.rs +++ b/crates/plannotator-tui/src/wrap.rs @@ -157,8 +157,18 @@ pub(crate) fn wrap_table(lines: &[Line<'_>], offsets: &[Vec>], wid let Some((original_widths, _, border_style)) = lines.first().zip(offsets.first()).and_then(|(line, map)| table_border(line, map)) else { - return lines.iter().zip(offsets).flat_map(|(line, map)| wrap_line(line, map, width)).collect(); + return clip_table(lines, offsets, width); }; + // A `│` in cell text splits that line into more columns than the border has, and the + // surplus would be dropped. One unreadable line disqualifies the whole block. + let ragged = lines + .iter() + .zip(offsets) + .filter_map(|(line, map)| table_content_cells(line, map)) + .any(|(cells, _)| cells.len() != original_widths.len()); + if ragged { + return clip_table(lines, offsets, width); + } let mut longest_word = vec![0usize; original_widths.len()]; for (line, map) in lines.iter().zip(offsets) { @@ -194,12 +204,17 @@ pub(crate) fn wrap_table(lines: &[Line<'_>], offsets: &[Vec>], wid )); } } else { - out.extend(wrap_line(line, map, width)); + out.push(clip_line(line, map, width)); } } out } +/// The pre-reflow behaviour: a table keeps its columns and loses what does not fit. +fn clip_table(lines: &[Line<'_>], offsets: &[Vec>], width: usize) -> Vec { + lines.iter().zip(offsets).map(|(line, map)| clip_line(line, map, width)).collect() +} + fn table_border(line: &Line<'_>, offsets: &[Option]) -> Option<(Vec, TableBorder, Style)> { let cells = cells_of(line, offsets); let first = cells.first()?.ch; @@ -548,6 +563,42 @@ mod tests { assert_eq!(rendered.lines().filter(|line| line.starts_with("├")).count(), 3); } + #[test] + fn a_pipe_inside_a_cell_falls_back_to_clipping_instead_of_dropping_it() { + // Splitting on every `│` gives this row three columns for a two-column border; the + // surplus used to be zipped away, taking its text with it. + let lines = [ + Line::from("┌────────────┬──────────────┐"), + Line::from("│ Header │ Description │"), + Line::from("├────────────┼──────────────┤"), + Line::from("│ a │ b │ pipe inside │"), + Line::from("└────────────┴──────────────┘"), + ]; + let (_, offsets) = unique_offsets(&lines); + let rendered = plain(&wrap_table(&lines, &offsets, 60)).join("\n"); + assert!(rendered.contains("│ a │ b │ pipe inside │"), "cell text was lost: {rendered}"); + } + + #[test] + fn a_row_the_reflow_cannot_read_is_clipped_not_word_wrapped() { + // The trailing space stops this line looking like a table row; wrapping it would + // word-wrap a box-drawing row into the middle of the table. + let lines = [ + Line::from("┌────────────┬──────────────┐"), + Line::from("│ Header │ Description │"), + Line::from("├────────────┼──────────────┤"), + Line::from("│ CLIPME │ a fairly long value here │ "), + Line::from("└────────────┴──────────────┘"), + ]; + let (_, offsets) = unique_offsets(&lines); + let rows = wrap_table(&lines, &offsets, 24); + let clipped: Vec = plain(&rows).into_iter().filter(|row| row.contains("CLIPME")).collect(); + assert_eq!(clipped.len(), 1, "the unreadable row was wrapped: {clipped:?}"); + let row = clipped.first().expect("one row"); + assert!(lines[3].to_string().starts_with(row.as_str()), "not a prefix of the source: {row:?}"); + assert!(row.chars().count() <= 24); + } + #[test] fn wrapped_table_cells_still_name_the_source_char_under_them() { // Wide chars take two columns and variation selectors and ZWJ take none, so a cell From ca2aeaa3eeac93a4e36fed5245a8a6054502978c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 21 Sep 2026 10:56:16 -0700 Subject: [PATCH 6/7] refactor(wrap): parse table lines once and move table reflow to its own module Every line was re-parsed four times per reflow, once per pass, and trimmed its cells with Vec::remove(0); each line is now classified once into a border, a row, or neither, and every pass reads that. wrap.rs goes back to generic wrapping with the table code beside it in wrap/table.rs. Also: the scaled-down column widths now spend the whole budget like the normal branch does, a cell too narrow for its char is clipped rather than pushing the box out, and the longest-word cap is a named constant. refs #79 --- crates/plannotator-tui/src/wrap.rs | 465 ++-------------------- crates/plannotator-tui/src/wrap/table.rs | 482 +++++++++++++++++++++++ 2 files changed, 509 insertions(+), 438 deletions(-) create mode 100644 crates/plannotator-tui/src/wrap/table.rs diff --git a/crates/plannotator-tui/src/wrap.rs b/crates/plannotator-tui/src/wrap.rs index 28e3c26..640e31f 100644 --- a/crates/plannotator-tui/src/wrap.rs +++ b/crates/plannotator-tui/src/wrap.rs @@ -3,11 +3,16 @@ //! `tui-markdown` emits one `Line` per logical line and never wraps; the layout needs //! exact row counts and the selection needs to know which source byte sits under each //! screen column, so both live here. Generic text wrapping — no markdown knowledge. +//! Reflowing a rendered table is the one shape-aware job, and it lives in `wrap::table`. + +mod table; use ratatui::style::Style; use ratatui::text::{Line, Span}; use unicode_width::UnicodeWidthChar; +pub(crate) use table::wrap_table; + /// One screen row: styled text plus, per column, the source byte it came from. #[derive(Debug)] pub(crate) struct Row { @@ -24,13 +29,6 @@ struct Cell { style: Style, } -#[derive(Clone, Copy)] -enum TableBorder { - Top, - Middle, - Bottom, -} - /// Flatten a line into cells, pairing each char with its source offset. fn cells_of(line: &Line<'_>, offsets: &[Option]) -> Vec { let mut offsets = offsets.iter(); @@ -54,6 +52,26 @@ fn trim_trailing_space(mut cells: Vec) -> Vec { cells } +/// Keep the leading cells that fit `width` columns, dropping the rest. +fn clip_cells(cells: Vec, width: usize) -> Vec { + let mut used = 0usize; + cells + .into_iter() + .take_while(|cell| { + let fits = used + cell.width <= width; + if fits { + used += cell.width; + } + fits + }) + .collect() +} + +/// Columns a run of cells occupies on screen. +fn cells_width(cells: &[Cell]) -> usize { + cells.iter().map(|cell| cell.width).sum() +} + /// Turn accumulated cells into a row, merging same-style runs into spans. fn finish_row(cells: Vec, line_style: Style) -> Row { let cells = trim_trailing_space(cells); @@ -86,7 +104,7 @@ fn wrap_cells(cells: &[Cell], width: usize) -> Vec> { let len = rest.iter().take_while(|c| c.ch.is_whitespace() == is_space).count(); let (token, tail) = rest.split_at(len); rest = tail; - let token_width: usize = token.iter().map(|c| c.width).sum(); + let token_width = cells_width(token); // Whitespace at a row start is dropped, except leading indentation on the first row. if is_space && current.is_empty() && !pieces.is_empty() { @@ -136,279 +154,7 @@ pub(crate) fn wrap_line(line: &Line<'_>, offsets: &[Option], width: usize /// Keep one row; clip anything past `width` (code and tables keep their columns). pub(crate) fn clip_line(line: &Line<'_>, offsets: &[Option], width: usize) -> Row { - let mut used = 0usize; - let kept = cells_of(line, offsets) - .into_iter() - .take_while(|cell| { - let fits = used + cell.width <= width; - if fits { - used += cell.width; - } - fits - }) - .collect(); - finish_row(kept, line.style) -} - -/// Reflow a rendered Unicode table to fit `width`, preserving cell boundaries and source offsets. -/// The Markdown renderer emits a complete table before this layer sees it, so the table is -/// identified from its box-drawing borders and each rendered cell is wrapped independently. -pub(crate) fn wrap_table(lines: &[Line<'_>], offsets: &[Vec>], width: usize) -> Vec { - let Some((original_widths, _, border_style)) = - lines.first().zip(offsets.first()).and_then(|(line, map)| table_border(line, map)) - else { - return clip_table(lines, offsets, width); - }; - // A `│` in cell text splits that line into more columns than the border has, and the - // surplus would be dropped. One unreadable line disqualifies the whole block. - let ragged = lines - .iter() - .zip(offsets) - .filter_map(|(line, map)| table_content_cells(line, map)) - .any(|(cells, _)| cells.len() != original_widths.len()); - if ragged { - return clip_table(lines, offsets, width); - } - - let mut longest_word = vec![0usize; original_widths.len()]; - for (line, map) in lines.iter().zip(offsets) { - if let Some((cells, _)) = table_content_cells(line, map) { - for (column, longest) in cells.iter().zip(longest_word.iter_mut()) { - *longest = (*longest).max(longest_word_width(column)); - } - } - } - let column_widths = fit_table_columns(&original_widths, &longest_word, width.max(1)); - let table_wraps = lines - .iter() - .zip(offsets) - .filter_map(|(line, map)| table_content_cells(line, map).map(|(cells, _)| cells)) - .any(|cells| table_content_wraps(&cells, &column_widths)); - let mut out = Vec::new(); - for (line_index, (line, map)) in lines.iter().zip(offsets).enumerate() { - if let Some((_, kind, style)) = table_border(line, map) { - out.push(render_table_border(&column_widths, kind, style, border_style)); - } else if let Some((cells, style)) = table_content_cells(line, map) { - let content_rows = render_table_content(&cells, &column_widths, style, border_style); - out.extend(content_rows); - let next_is_content = lines - .get(line_index + 1) - .zip(offsets.get(line_index + 1)) - .is_some_and(|(next_line, next_map)| table_content_cells(next_line, next_map).is_some()); - if table_wraps && next_is_content { - out.push(render_table_border( - &column_widths, - TableBorder::Middle, - border_style, - border_style, - )); - } - } else { - out.push(clip_line(line, map, width)); - } - } - out -} - -/// The pre-reflow behaviour: a table keeps its columns and loses what does not fit. -fn clip_table(lines: &[Line<'_>], offsets: &[Vec>], width: usize) -> Vec { - lines.iter().zip(offsets).map(|(line, map)| clip_line(line, map, width)).collect() -} - -fn table_border(line: &Line<'_>, offsets: &[Option]) -> Option<(Vec, TableBorder, Style)> { - let cells = cells_of(line, offsets); - let first = cells.first()?.ch; - let (kind, left, intersection, right) = match first { - '┌' => (TableBorder::Top, '┌', '┬', '┐'), - '├' => (TableBorder::Middle, '├', '┼', '┤'), - '└' => (TableBorder::Bottom, '└', '┴', '┘'), - _ => return None, - }; - if cells.last()?.ch != right { - return None; - } - let mut widths = Vec::new(); - let mut current = 0usize; - let mut seen_left = false; - for cell in &cells { - if cell.ch == left && !seen_left { - seen_left = true; - } else if seen_left && cell.ch == '─' { - current += cell.width; - } else if seen_left && matches!(cell.ch, c if c == intersection || c == right) { - if current == 0 { - return None; - } - widths.push(current.saturating_sub(2).max(1)); - current = 0; - } else if seen_left { - return None; - } - } - (!widths.is_empty()).then(|| (widths, kind, cells.first().map_or(Style::default(), |c| c.style))) -} - -fn table_content_cells(line: &Line<'_>, offsets: &[Option]) -> Option<(Vec>, Style)> { - let cells = cells_of(line, offsets); - if cells.first()?.ch != '│' || cells.last()?.ch != '│' { - return None; - } - let separator_indices: Vec = - cells.iter().enumerate().filter_map(|(index, cell)| (cell.ch == '│').then_some(index)).collect(); - if separator_indices.len() < 2 { - return None; - } - let mut columns = Vec::with_capacity(separator_indices.len() - 1); - for pair in separator_indices.windows(2) { - let [left, right] = pair else { continue }; - let mut column = cells.get(left + 1..*right)?.to_vec(); - while column.first().is_some_and(|cell| cell.ch.is_whitespace()) { - column.remove(0); - } - while column.last().is_some_and(|cell| cell.ch.is_whitespace()) { - column.pop(); - } - columns.push(column); - } - Some((columns, cells.first().map_or(Style::default(), |c| c.style))) -} - -/// Column widths for a table that does not fit. Every column first gets room for its -/// longest word (capped, so one huge token cannot starve the rest); the space left over is -/// shared in proportion to how much each column asked for beyond that, so a column of short -/// words stays narrow and the long one takes the room. A column never grows past its own -/// content. Only when the words alone exceed the budget do columns shrink below them. -fn fit_table_columns(original: &[usize], longest_word: &[usize], width: usize) -> Vec { - const WORD_CAP: usize = 20; - let columns = original.len(); - let overhead = columns.saturating_mul(3).saturating_add(1); - let budget = width.saturating_sub(overhead).max(columns); - let total: usize = original.iter().sum(); - if total == 0 || total <= budget { - return original.to_vec(); - } - let minimum: Vec = original - .iter() - .enumerate() - .map(|(i, &w)| longest_word.get(i).copied().unwrap_or(1).clamp(1, WORD_CAP).min(w).max(1)) - .collect(); - let floor: usize = minimum.iter().sum(); - if floor >= budget { - // Even the words do not fit: scale them down, keeping every column at least one cell. - let mut fitted: Vec = - minimum.iter().map(|&m| (m.saturating_mul(budget) / floor).max(1)).collect(); - while fitted.iter().sum::() > budget { - let Some((index, _)) = - fitted.iter().enumerate().filter(|(_, w)| **w > 1).max_by_key(|(_, w)| **w) - else { - break; - }; - if let Some(slot) = fitted.get_mut(index) { - *slot -= 1; - } - } - return fitted; - } - let spare = budget - floor; - let wants: Vec = original.iter().zip(&minimum).map(|(&o, &m)| o.saturating_sub(m)).collect(); - let wanted: usize = wants.iter().sum::().max(1); - let mut fitted: Vec = - minimum.iter().zip(&wants).map(|(&m, &w)| m + w.saturating_mul(spare) / wanted).collect(); - // Rounding leaves a few cells over: give them to whichever column is squeezed the most. - while fitted.iter().sum::() < budget { - let deficit = |i: usize| { - original.get(i).copied().unwrap_or(0).saturating_sub(fitted.get(i).copied().unwrap_or(0)) - }; - let Some((index, _)) = - (0..columns).map(|i| (i, deficit(i))).filter(|(_, d)| *d > 0).max_by_key(|(_, d)| *d) - else { - break; - }; - if let Some(slot) = fitted.get_mut(index) { - *slot += 1; - } - } - fitted -} - -/// The widest run of non-whitespace cells in a rendered cell's content. -fn longest_word_width(cells: &[Cell]) -> usize { - let mut longest = 0; - let mut run = 0; - for cell in cells { - if cell.ch.is_whitespace() { - longest = longest.max(run); - run = 0; - } else { - run += cell.width; - } - } - longest.max(run) -} - -fn render_table_border(widths: &[usize], kind: TableBorder, style: Style, fallback_style: Style) -> Row { - let (left, intersection, right) = match kind { - TableBorder::Top => ('┌', '┬', '┐'), - TableBorder::Middle => ('├', '┼', '┤'), - TableBorder::Bottom => ('└', '┴', '┘'), - }; - let style = if style == Style::default() { fallback_style } else { style }; - let mut cells = vec![Cell { ch: left, width: 1, offset: None, style }]; - for (index, &width) in widths.iter().enumerate() { - cells.extend((0..width + 2).map(|_| Cell { ch: '─', width: 1, offset: None, style })); - if index + 1 < widths.len() { - cells.push(Cell { ch: intersection, width: 1, offset: None, style }); - } - } - cells.push(Cell { ch: right, width: 1, offset: None, style }); - finish_row(cells, Style::default()) -} - -fn render_table_content( - columns: &[Vec], - widths: &[usize], - cell_style: Style, - border_style: Style, -) -> Vec { - let wrapped: Vec>> = widths - .iter() - .enumerate() - .map(|(index, &width)| { - wrap_cells(columns.get(index).map_or(&[][..], Vec::as_slice), width.max(1)) - .into_iter() - .map(trim_trailing_space) - .collect() - }) - .collect(); - let height = wrapped.iter().map(Vec::len).max().unwrap_or(1); - let mut rows = Vec::with_capacity(height); - for row_index in 0..height { - let mut cells = vec![Cell { ch: '│', width: 1, offset: None, style: border_style }]; - for (column_index, &width) in widths.iter().enumerate() { - let piece = wrapped.get(column_index).and_then(|pieces| pieces.get(row_index)); - let row_style = piece.and_then(|p| p.first().map(|c| c.style)).unwrap_or(cell_style); - cells.push(Cell { ch: ' ', width: 1, offset: None, style: row_style }); - let used = piece.map_or(0, |p| p.iter().map(|c| c.width).sum()); - if let Some(piece) = piece { - cells.extend_from_slice(piece); - } - cells.extend((0..=width.saturating_sub(used)).map(|_| Cell { - ch: ' ', - width: 1, - offset: None, - style: row_style, - })); - cells.push(Cell { ch: '│', width: 1, offset: None, style: border_style }); - } - rows.push(finish_row(cells, Style::default())); - } - rows -} - -fn table_content_wraps(columns: &[Vec], widths: &[usize]) -> bool { - widths.iter().enumerate().any(|(index, &width)| { - wrap_cells(columns.get(index).map_or(&[][..], Vec::as_slice), width.max(1)).len() > 1 - }) + finish_row(clip_cells(cells_of(line, offsets), width), line.style) } #[cfg(test)] @@ -425,42 +171,6 @@ mod tests { (0..n).map(Some).collect() } - /// Offsets that are unique across the whole table, so a cell offset names exactly one - /// source char. Returns the flat source and one offset per char of each line. - fn unique_offsets(lines: &[Line<'static>]) -> (Vec, Vec>>) { - let mut source = Vec::new(); - let mut offsets = Vec::new(); - for line in lines { - let chars: Vec = line.to_string().chars().collect(); - offsets.push((source.len()..source.len() + chars.len()).map(Some).collect()); - source.extend(chars); - } - (source, offsets) - } - - /// Every mapped column of every row points at the char actually drawn there. - fn assert_cells_name_their_char(rows: &[Row], source: &[char]) { - for row in rows { - let mut column = 0usize; - for ch in row.line.spans.iter().flat_map(|span| span.content.chars()) { - let width = ch.width().unwrap_or(0); - for slot in column..column + width { - if let Some(offset) = row.cells[slot] { - assert_eq!( - source.get(offset).copied(), - Some(ch), - "column {slot} of {:?} claims offset {offset} ({:?})", - row.line.to_string(), - source.get(offset) - ); - } - } - column += width; - } - assert_eq!(column, row.cells.len(), "row width disagrees with its cell map"); - } - } - #[test] fn wraps_on_word_boundaries_and_keeps_offsets() { let line = Line::from("the quick brown fox jumps"); @@ -495,125 +205,4 @@ mod tests { fn empty_line_is_one_row() { assert_eq!(wrap_line(&Line::from(""), &[], 20).len(), 1); } - - #[test] - fn columns_keep_their_longest_word_and_share_the_rest_in_proportion() { - // Step 37, Owner 8, Risk 6, Notes 60 at a 68-cell document: budget is 68 - 13 = 55. - // Longest words: "accounts" 8, "platform" 8, "medium" 6, "config/flags.toml," 18. - let widths = fit_table_columns(&[37, 8, 6, 60], &[8, 8, 6, 18], 68); - assert_eq!(widths.iter().sum::(), 55, "{widths:?}"); - assert_eq!( - (widths[1], widths[2]), - (8, 6), - "short columns get exactly their longest word: {widths:?}" - ); - assert!(widths[3] > widths[0], "the long column takes most of the spare room: {widths:?}"); - assert!(widths.iter().zip([37, 8, 6, 60]).all(|(w, o)| *w <= o), "never wider than the content"); - assert_eq!( - fit_table_columns(&[10, 20], &[4, 7], 80), - vec![10, 20], - "a table that fits keeps its widths" - ); - let tiny = fit_table_columns(&[30, 30, 30], &[12, 12, 12], 12); - assert_eq!(tiny.iter().sum::(), 3, "a tiny budget still gives every column a cell: {tiny:?}"); - } - - #[test] - fn words_stay_whole_when_a_wrapped_table_has_room_for_them() { - let lines = [ - Line::from("┌──────────────────────────────────┬──────────┬────────┐"), - Line::from("│ Step │ Owner │ Risk │"), - Line::from("├──────────────────────────────────┼──────────┼────────┤"), - Line::from("│ Enable the flag for everyone now │ platform │ medium │"), - Line::from("└──────────────────────────────────┴──────────┴────────┘"), - ]; - let offsets: Vec>> = - lines.iter().map(|line| (0..line.to_string().chars().count()).map(Some).collect()).collect(); - let rendered = wrap_table(&lines, &offsets, 40) - .iter() - .map(|row| row.line.to_string()) - .collect::>() - .join("\n"); - for word in ["Owner", "platform", "medium", "Risk"] { - assert!(rendered.contains(word), "{word} was split: {rendered}"); - } - } - - #[test] - fn table_cells_wrap_without_breaking_the_box() { - let lines = [ - Line::from("┌────────────┬──────────────┐"), - Line::from("│ Header │ Description │"), - Line::from("├────────────┼──────────────┤"), - Line::from("│ item │ TABLE_TAIL is a long value │"), - Line::from("│ second │ another long value │"), - Line::from("│ third │ short │"), - Line::from("└────────────┴──────────────┘"), - ]; - let offsets: Vec>> = - lines.iter().map(|line| (0..line.to_string().chars().count()).map(Some).collect()).collect(); - let rows = wrap_table(&lines, &offsets, 24); - let rendered = rows.iter().map(|row| row.line.to_string()).collect::>().join("\n"); - - assert!(rows.iter().all(|row| row.cells.len() <= 24)); - // The long cell wraps, but its text survives intact and in order. - let squashed: String = rendered.chars().filter(|c| c.is_alphanumeric() || *c == '_').collect(); - assert!(squashed.contains("TABLE_TAILisalongvalue"), "rendered table was {rendered:?}"); - assert!(rendered.contains("┌") && rendered.contains("└")); - assert_eq!(rendered.lines().filter(|line| line.starts_with("├")).count(), 3); - } - - #[test] - fn a_pipe_inside_a_cell_falls_back_to_clipping_instead_of_dropping_it() { - // Splitting on every `│` gives this row three columns for a two-column border; the - // surplus used to be zipped away, taking its text with it. - let lines = [ - Line::from("┌────────────┬──────────────┐"), - Line::from("│ Header │ Description │"), - Line::from("├────────────┼──────────────┤"), - Line::from("│ a │ b │ pipe inside │"), - Line::from("└────────────┴──────────────┘"), - ]; - let (_, offsets) = unique_offsets(&lines); - let rendered = plain(&wrap_table(&lines, &offsets, 60)).join("\n"); - assert!(rendered.contains("│ a │ b │ pipe inside │"), "cell text was lost: {rendered}"); - } - - #[test] - fn a_row_the_reflow_cannot_read_is_clipped_not_word_wrapped() { - // The trailing space stops this line looking like a table row; wrapping it would - // word-wrap a box-drawing row into the middle of the table. - let lines = [ - Line::from("┌────────────┬──────────────┐"), - Line::from("│ Header │ Description │"), - Line::from("├────────────┼──────────────┤"), - Line::from("│ CLIPME │ a fairly long value here │ "), - Line::from("└────────────┴──────────────┘"), - ]; - let (_, offsets) = unique_offsets(&lines); - let rows = wrap_table(&lines, &offsets, 24); - let clipped: Vec = plain(&rows).into_iter().filter(|row| row.contains("CLIPME")).collect(); - assert_eq!(clipped.len(), 1, "the unreadable row was wrapped: {clipped:?}"); - let row = clipped.first().expect("one row"); - assert!(lines[3].to_string().starts_with(row.as_str()), "not a prefix of the source: {row:?}"); - assert!(row.chars().count() <= 24); - } - - #[test] - fn wrapped_table_cells_still_name_the_source_char_under_them() { - // Wide chars take two columns and variation selectors and ZWJ take none, so a cell - // map built by counting columns per char drifts after the first of either. - let lines = [ - Line::from("┌──────────────────────┬──────────────────────┐"), - Line::from("│ Head │ Detail │"), - Line::from("├──────────────────────┼──────────────────────┤"), - Line::from("│ alpha beta gamma del │ 中文 漢字 表格 内容 説明 │"), - Line::from("│ ⚠️ caution ⚠️ severe │ 👨‍👩‍👧 family 👍 fine │"), - Line::from("└──────────────────────┴──────────────────────┘"), - ]; - let (source, offsets) = unique_offsets(&lines); - let rows = wrap_table(&lines, &offsets, 30); - assert!(rows.len() > lines.len(), "the fixture must actually wrap: {}", rows.len()); - assert_cells_name_their_char(&rows, &source); - } } diff --git a/crates/plannotator-tui/src/wrap/table.rs b/crates/plannotator-tui/src/wrap/table.rs new file mode 100644 index 0000000..fe77354 --- /dev/null +++ b/crates/plannotator-tui/src/wrap/table.rs @@ -0,0 +1,482 @@ +//! Reflowing an already-rendered Unicode table so it fits the window. +//! +//! The Markdown renderer emits the whole box before layout sees it, so this module reads +//! the box-drawing characters back: each line is classified once, columns are re-sized to +//! the content, and every cell is wrapped on its own. Anything it cannot read confidently +//! is clipped instead, which is what the table did before it could reflow. + +use ratatui::style::Style; +use ratatui::text::Line; + +use super::{Cell, Row, cells_of, cells_width, clip_cells, clip_line, finish_row, wrap_cells}; + +/// Room a column gets for its longest word before it is asked to share. One very long +/// token (a path, a URL) would otherwise starve every other column of the budget. +const WORD_CAP: usize = 20; + +#[derive(Clone, Copy)] +enum TableBorder { + Top, + Middle, + Bottom, +} + +/// A table line, read once. Everything downstream is driven from this, never re-parsed. +enum TableLine { + /// The column widths the border draws, its shape, and the style of its box chars. + Border(Vec, TableBorder, Style), + /// One trimmed run of cells per column, and the style of the row's `│` chars. + Content(Vec>, Style), + /// Not part of the box as far as this module can tell. + Other, +} + +/// Reflow a rendered Unicode table to fit `width`, preserving cell boundaries and source offsets. +pub(crate) fn wrap_table(lines: &[Line<'_>], offsets: &[Vec>], width: usize) -> Vec { + let parsed: Vec = + lines.iter().zip(offsets).map(|(line, map)| parse_table_line(line, map)).collect(); + let Some(TableLine::Border(original_widths, _, border_style)) = parsed.first() else { + return clip_table(lines, offsets, width); + }; + let (original_widths, border_style) = (original_widths.clone(), *border_style); + + // A `│` in cell text splits that line into more columns than the border has, and the + // surplus would be dropped. One unreadable line disqualifies the whole block. + let ragged = parsed.iter().any(|line| match line { + TableLine::Content(columns, _) => columns.len() != original_widths.len(), + _ => false, + }); + if ragged { + return clip_table(lines, offsets, width); + } + + let mut longest_word = vec![0usize; original_widths.len()]; + for line in &parsed { + if let TableLine::Content(columns, _) = line { + for (column, longest) in columns.iter().zip(longest_word.iter_mut()) { + *longest = (*longest).max(longest_word_width(column)); + } + } + } + let column_widths = fit_table_columns(&original_widths, &longest_word, width.max(1)); + let table_wraps = parsed.iter().any(|line| match line { + TableLine::Content(columns, _) => table_content_wraps(columns, &column_widths), + _ => false, + }); + + let mut out = Vec::new(); + for (index, parsed_line) in parsed.iter().enumerate() { + match parsed_line { + TableLine::Border(_, kind, style) => { + let style = if *style == Style::default() { border_style } else { *style }; + out.push(render_table_border(&column_widths, *kind, style)); + } + TableLine::Content(columns, line_border_style) => { + out.extend(render_table_content(columns, &column_widths, *line_border_style, border_style)); + // Wrapped rows run together without a rule between them. + let next_is_content = matches!(parsed.get(index + 1), Some(TableLine::Content(..))); + if table_wraps && next_is_content { + out.push(render_table_border(&column_widths, TableBorder::Middle, border_style)); + } + } + TableLine::Other => { + if let Some((line, map)) = lines.get(index).zip(offsets.get(index)) { + out.push(clip_line(line, map, width)); + } + } + } + } + out +} + +/// The pre-reflow behaviour: a table keeps its columns and loses what does not fit. +fn clip_table(lines: &[Line<'_>], offsets: &[Vec>], width: usize) -> Vec { + lines.iter().zip(offsets).map(|(line, map)| clip_line(line, map, width)).collect() +} + +fn parse_table_line(line: &Line<'_>, offsets: &[Option]) -> TableLine { + let cells = cells_of(line, offsets); + parse_border(&cells).or_else(|| parse_content(&cells)).unwrap_or(TableLine::Other) +} + +fn parse_border(cells: &[Cell]) -> Option { + let first = cells.first()?; + let (kind, left, intersection, right) = match first.ch { + '┌' => (TableBorder::Top, '┌', '┬', '┐'), + '├' => (TableBorder::Middle, '├', '┼', '┤'), + '└' => (TableBorder::Bottom, '└', '┴', '┘'), + _ => return None, + }; + if cells.last()?.ch != right { + return None; + } + let mut widths = Vec::new(); + let mut current = 0usize; + let mut seen_left = false; + for cell in cells { + if cell.ch == left && !seen_left { + seen_left = true; + } else if seen_left && cell.ch == '─' { + current += cell.width; + } else if seen_left && matches!(cell.ch, c if c == intersection || c == right) { + if current == 0 { + return None; + } + // The border spans the cell plus one pad column on each side. + widths.push(current.saturating_sub(2).max(1)); + current = 0; + } else if seen_left { + return None; + } + } + (!widths.is_empty()).then_some(TableLine::Border(widths, kind, first.style)) +} + +fn parse_content(cells: &[Cell]) -> Option { + let first = cells.first()?; + if first.ch != '│' || cells.last()?.ch != '│' { + return None; + } + let separators: Vec = + cells.iter().enumerate().filter_map(|(index, cell)| (cell.ch == '│').then_some(index)).collect(); + if separators.len() < 2 { + return None; + } + let mut columns = Vec::with_capacity(separators.len() - 1); + for pair in separators.windows(2) { + let [left, right] = pair else { continue }; + let padded = cells.get(left + 1..*right)?; + let start = padded.iter().position(|cell| !cell.ch.is_whitespace()).unwrap_or(padded.len()); + let end = padded.iter().rposition(|cell| !cell.ch.is_whitespace()).map_or(start, |i| i + 1); + columns.push(padded.get(start..end)?.to_vec()); + } + Some(TableLine::Content(columns, first.style)) +} + +/// Column widths for a table that does not fit. Every column first gets room for its +/// longest word (capped, so one huge token cannot starve the rest); the space left over is +/// shared in proportion to how much each column asked for beyond that, so a column of short +/// words stays narrow and the long one takes the room. A column never grows past its own +/// content. Only when the words alone exceed the budget do columns shrink below them. +fn fit_table_columns(original: &[usize], longest_word: &[usize], width: usize) -> Vec { + let columns = original.len(); + let overhead = columns.saturating_mul(3).saturating_add(1); + let budget = width.saturating_sub(overhead).max(columns); + let total: usize = original.iter().sum(); + if total == 0 || total <= budget { + return original.to_vec(); + } + let minimum: Vec = original + .iter() + .enumerate() + .map(|(i, &w)| longest_word.get(i).copied().unwrap_or(1).clamp(1, WORD_CAP).min(w).max(1)) + .collect(); + let floor: usize = minimum.iter().sum(); + if floor >= budget { + // Even the words do not fit: scale them down, keeping every column at least one cell. + let mut fitted: Vec = + minimum.iter().map(|&m| (m.saturating_mul(budget) / floor).max(1)).collect(); + while fitted.iter().sum::() > budget { + let Some((index, _)) = + fitted.iter().enumerate().filter(|(_, w)| **w > 1).max_by_key(|(_, w)| **w) + else { + break; + }; + if let Some(slot) = fitted.get_mut(index) { + *slot -= 1; + } + } + spend_remainder(&mut fitted, &minimum, budget); + return fitted; + } + let spare = budget - floor; + let wants: Vec = original.iter().zip(&minimum).map(|(&o, &m)| o.saturating_sub(m)).collect(); + let wanted: usize = wants.iter().sum::().max(1); + let mut fitted: Vec = + minimum.iter().zip(&wants).map(|(&m, &w)| m + w.saturating_mul(spare) / wanted).collect(); + spend_remainder(&mut fitted, original, budget); + fitted +} + +/// Rounding leaves a few cells over: give them to whichever column is squeezed most +/// against what it wanted, until the budget is spent or nobody wants more. +fn spend_remainder(fitted: &mut [usize], want: &[usize], budget: usize) { + while fitted.iter().sum::() < budget { + let deficit = + |i: usize| want.get(i).copied().unwrap_or(0).saturating_sub(fitted.get(i).copied().unwrap_or(0)); + let Some((index, _)) = + (0..fitted.len()).map(|i| (i, deficit(i))).filter(|(_, d)| *d > 0).max_by_key(|(_, d)| *d) + else { + break; + }; + if let Some(slot) = fitted.get_mut(index) { + *slot += 1; + } + } +} + +/// The widest run of non-whitespace cells in a rendered cell's content. +fn longest_word_width(cells: &[Cell]) -> usize { + let mut longest = 0; + let mut run = 0; + for cell in cells { + if cell.ch.is_whitespace() { + longest = longest.max(run); + run = 0; + } else { + run += cell.width; + } + } + longest.max(run) +} + +fn render_table_border(widths: &[usize], kind: TableBorder, style: Style) -> Row { + let (left, intersection, right) = match kind { + TableBorder::Top => ('┌', '┬', '┐'), + TableBorder::Middle => ('├', '┼', '┤'), + TableBorder::Bottom => ('└', '┴', '┘'), + }; + let mut cells = vec![Cell { ch: left, width: 1, offset: None, style }]; + for (index, &width) in widths.iter().enumerate() { + cells.extend((0..width + 2).map(|_| Cell { ch: '─', width: 1, offset: None, style })); + if index + 1 < widths.len() { + cells.push(Cell { ch: intersection, width: 1, offset: None, style }); + } + } + cells.push(Cell { ch: right, width: 1, offset: None, style }); + finish_row(cells, Style::default()) +} + +/// Wrap every cell of one source row and stack the results into as many screen rows as the +/// tallest cell needs. `line_border_style` is the style of this row's own `│` characters; +/// it dresses the padding of a column that has run out of content. +fn render_table_content( + columns: &[Vec], + widths: &[usize], + line_border_style: Style, + border_style: Style, +) -> Vec { + let wrapped: Vec>> = widths + .iter() + .enumerate() + .map(|(index, &width)| { + let width = width.max(1); + wrap_cells(columns.get(index).map_or(&[][..], Vec::as_slice), width) + .into_iter() + // A cell narrower than one char still has to stay inside its column. + .map(|piece| clip_cells(super::trim_trailing_space(piece), width)) + .collect() + }) + .collect(); + let height = wrapped.iter().map(Vec::len).max().unwrap_or(1); + let mut rows = Vec::with_capacity(height); + for row_index in 0..height { + let mut cells = vec![Cell { ch: '│', width: 1, offset: None, style: border_style }]; + for (column_index, &width) in widths.iter().enumerate() { + let piece = wrapped.get(column_index).and_then(|pieces| pieces.get(row_index)); + let style = piece.and_then(|p| p.first().map(|c| c.style)).unwrap_or(line_border_style); + let used = piece.map_or(0, |p| cells_width(p)); + cells.push(Cell { ch: ' ', width: 1, offset: None, style }); + if let Some(piece) = piece { + cells.extend_from_slice(piece); + } + // The columns this cell left empty, plus the pad column before the next `│`. + let padding = width.max(1).saturating_sub(used) + 1; + cells.extend((0..padding).map(|_| Cell { ch: ' ', width: 1, offset: None, style })); + cells.push(Cell { ch: '│', width: 1, offset: None, style: border_style }); + } + rows.push(finish_row(cells, Style::default())); + } + rows +} + +fn table_content_wraps(columns: &[Vec], widths: &[usize]) -> bool { + widths.iter().enumerate().any(|(index, &width)| { + wrap_cells(columns.get(index).map_or(&[][..], Vec::as_slice), width.max(1)).len() > 1 + }) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::indexing_slicing, reason = "tests assert by panicking")] +mod tests { + use super::*; + use unicode_width::UnicodeWidthChar; + + fn plain(rows: &[Row]) -> Vec { + rows.iter().map(|r| r.line.to_string()).collect() + } + + /// Offsets that are unique across the whole table, so a cell offset names exactly one + /// source char. Returns the flat source and one offset per char of each line. + fn unique_offsets(lines: &[Line<'static>]) -> (Vec, Vec>>) { + let mut source = Vec::new(); + let mut offsets = Vec::new(); + for line in lines { + let chars: Vec = line.to_string().chars().collect(); + offsets.push((source.len()..source.len() + chars.len()).map(Some).collect()); + source.extend(chars); + } + (source, offsets) + } + + /// Every mapped column of every row points at the char actually drawn there. + fn assert_cells_name_their_char(rows: &[Row], source: &[char]) { + for row in rows { + let mut column = 0usize; + for ch in row.line.spans.iter().flat_map(|span| span.content.chars()) { + let width = ch.width().unwrap_or(0); + for slot in column..column + width { + if let Some(offset) = row.cells[slot] { + assert_eq!( + source.get(offset).copied(), + Some(ch), + "column {slot} of {:?} claims offset {offset} ({:?})", + row.line.to_string(), + source.get(offset) + ); + } + } + column += width; + } + assert_eq!(column, row.cells.len(), "row width disagrees with its cell map"); + } + } + + #[test] + fn columns_keep_their_longest_word_and_share_the_rest_in_proportion() { + // Step 37, Owner 8, Risk 6, Notes 60 at a 68-cell document: budget is 68 - 13 = 55. + // Longest words: "accounts" 8, "platform" 8, "medium" 6, "config/flags.toml," 18. + let widths = fit_table_columns(&[37, 8, 6, 60], &[8, 8, 6, 18], 68); + assert_eq!(widths.iter().sum::(), 55, "{widths:?}"); + assert_eq!( + (widths[1], widths[2]), + (8, 6), + "short columns get exactly their longest word: {widths:?}" + ); + assert!(widths[3] > widths[0], "the long column takes most of the spare room: {widths:?}"); + assert!(widths.iter().zip([37, 8, 6, 60]).all(|(w, o)| *w <= o), "never wider than the content"); + assert_eq!( + fit_table_columns(&[10, 20], &[4, 7], 80), + vec![10, 20], + "a table that fits keeps its widths" + ); + let tiny = fit_table_columns(&[30, 30, 30], &[12, 12, 12], 12); + assert_eq!(tiny.iter().sum::(), 3, "a tiny budget still gives every column a cell: {tiny:?}"); + } + + #[test] + fn a_budget_too_small_for_the_words_is_still_spent_in_full() { + // Words need 21 cells and the budget is 10: scaling gives 3 each and the odd cell + // used to be left on the table, shrinking the box below the window. + let widths = fit_table_columns(&[30, 30, 30], &[7, 7, 7], 20); + assert_eq!(widths.iter().sum::(), 10, "{widths:?}"); + } + + #[test] + fn words_stay_whole_when_a_wrapped_table_has_room_for_them() { + let lines = [ + Line::from("┌──────────────────────────────────┬──────────┬────────┐"), + Line::from("│ Step │ Owner │ Risk │"), + Line::from("├──────────────────────────────────┼──────────┼────────┤"), + Line::from("│ Enable the flag for everyone now │ platform │ medium │"), + Line::from("└──────────────────────────────────┴──────────┴────────┘"), + ]; + let (_, offsets) = unique_offsets(&lines); + let rendered = plain(&wrap_table(&lines, &offsets, 40)).join("\n"); + for word in ["Owner", "platform", "medium", "Risk"] { + assert!(rendered.contains(word), "{word} was split: {rendered}"); + } + } + + #[test] + fn table_cells_wrap_without_breaking_the_box() { + let lines = [ + Line::from("┌────────────┬──────────────┐"), + Line::from("│ Header │ Description │"), + Line::from("├────────────┼──────────────┤"), + Line::from("│ item │ TABLE_TAIL is a long value │"), + Line::from("│ second │ another long value │"), + Line::from("│ third │ short │"), + Line::from("└────────────┴──────────────┘"), + ]; + let (_, offsets) = unique_offsets(&lines); + let rows = wrap_table(&lines, &offsets, 24); + let rendered = plain(&rows).join("\n"); + + assert!(rows.iter().all(|row| row.cells.len() <= 24)); + // The long cell wraps, but its text survives intact and in order. + let squashed: String = rendered.chars().filter(|c| c.is_alphanumeric() || *c == '_').collect(); + assert!(squashed.contains("TABLE_TAILisalongvalue"), "rendered table was {rendered:?}"); + assert!(rendered.contains("┌") && rendered.contains("└")); + assert_eq!(rendered.lines().filter(|line| line.starts_with("├")).count(), 3); + } + + #[test] + fn every_row_of_a_squeezed_table_is_the_same_width() { + // One cell per column: a wide char does not fit, and must not push the box out. + let lines = [ + Line::from("┌──────────────┬──────────────┬──────────────┐"), + Line::from("│ 中文表格内容 │ 日本語の内容 │ 한국어의내용 │"), + Line::from("│ 表格内容中文 │ 内容の日本語 │ 내용의한국어 │"), + Line::from("└──────────────┴──────────────┴──────────────┘"), + ]; + let (_, offsets) = unique_offsets(&lines); + let rows = wrap_table(&lines, &offsets, 13); + let widths: Vec = rows.iter().map(|row| row.cells.len()).collect(); + assert!(widths.iter().all(|w| *w == widths[0]), "ragged table: {:?}", plain(&rows)); + } + + #[test] + fn a_pipe_inside_a_cell_falls_back_to_clipping_instead_of_dropping_it() { + // Splitting on every `│` gives this row three columns for a two-column border; the + // surplus used to be zipped away, taking its text with it. + let lines = [ + Line::from("┌────────────┬──────────────┐"), + Line::from("│ Header │ Description │"), + Line::from("├────────────┼──────────────┤"), + Line::from("│ a │ b │ pipe inside │"), + Line::from("└────────────┴──────────────┘"), + ]; + let (_, offsets) = unique_offsets(&lines); + let rendered = plain(&wrap_table(&lines, &offsets, 60)).join("\n"); + assert!(rendered.contains("│ a │ b │ pipe inside │"), "cell text was lost: {rendered}"); + } + + #[test] + fn a_row_the_reflow_cannot_read_is_clipped_not_word_wrapped() { + // The trailing space stops this line looking like a table row; wrapping it would + // word-wrap a box-drawing row into the middle of the table. + let lines = [ + Line::from("┌────────────┬──────────────┐"), + Line::from("│ Header │ Description │"), + Line::from("├────────────┼──────────────┤"), + Line::from("│ CLIPME │ a fairly long value here │ "), + Line::from("└────────────┴──────────────┘"), + ]; + let (_, offsets) = unique_offsets(&lines); + let rows = wrap_table(&lines, &offsets, 24); + let clipped: Vec = plain(&rows).into_iter().filter(|row| row.contains("CLIPME")).collect(); + assert_eq!(clipped.len(), 1, "the unreadable row was wrapped: {clipped:?}"); + let row = clipped.first().expect("one row"); + assert!(lines[3].to_string().starts_with(row.as_str()), "not a prefix of the source: {row:?}"); + assert!(row.chars().count() <= 24); + } + + #[test] + fn wrapped_table_cells_still_name_the_source_char_under_them() { + // Wide chars take two columns and variation selectors and ZWJ take none, so a cell + // map built by counting columns per char drifts after the first of either. + let lines = [ + Line::from("┌──────────────────────┬──────────────────────┐"), + Line::from("│ Head │ Detail │"), + Line::from("├──────────────────────┼──────────────────────┤"), + Line::from("│ alpha beta gamma del │ 中文 漢字 表格 内容 説明 │"), + Line::from("│ ⚠️ caution ⚠️ severe │ 👨‍👩‍👧 family 👍 fine │"), + Line::from("└──────────────────────┴──────────────────────┘"), + ]; + let (source, offsets) = unique_offsets(&lines); + let rows = wrap_table(&lines, &offsets, 30); + assert!(rows.len() > lines.len(), "the fixture must actually wrap: {}", rows.len()); + assert_cells_name_their_char(&rows, &source); + } +} From da3e2b31403aa1b5ea1eb324881f5e02e341efc6 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 21 Sep 2026 11:04:19 -0700 Subject: [PATCH 7/7] fix(wrap): clip tables narrower than their box and wrap each cell once A window under four cells per column cannot show the box at all, so the reflow handed back rows wider than the window; those tables are clipped again. Cells were wrapped twice per relayout, once to ask whether anything wraps and once to render; the pieces are now computed once and shared. refs #79 --- crates/plannotator-tui/src/layout.rs | 6 +- crates/plannotator-tui/src/wrap/table.rs | 90 +++++++++++++++++------- 2 files changed, 67 insertions(+), 29 deletions(-) diff --git a/crates/plannotator-tui/src/layout.rs b/crates/plannotator-tui/src/layout.rs index bb5ee8a..1658628 100644 --- a/crates/plannotator-tui/src/layout.rs +++ b/crates/plannotator-tui/src/layout.rs @@ -110,13 +110,13 @@ impl DocLayout { let mut row = 0usize; for block in &mut self.blocks { block.first_row = row; - let lines = block.text.lines.iter().zip(&block.offsets); + let lines = || block.text.lines.iter().zip(&block.offsets); block.rows = if block.kind == BlockKind::Table { wrap_table(&block.text.lines, &block.offsets, width) } else if block.kind.preserves_columns() { - lines.map(|(l, o)| clip_line(l, o, width)).collect() + lines().map(|(l, o)| clip_line(l, o, width)).collect() } else { - lines.flat_map(|(l, o)| wrap_line(l, o, width)).collect() + lines().flat_map(|(l, o)| wrap_line(l, o, width)).collect() }; row += block.rows.len() + BLOCK_GAP; } diff --git a/crates/plannotator-tui/src/wrap/table.rs b/crates/plannotator-tui/src/wrap/table.rs index fe77354..2ff1856 100644 --- a/crates/plannotator-tui/src/wrap/table.rs +++ b/crates/plannotator-tui/src/wrap/table.rs @@ -8,7 +8,9 @@ use ratatui::style::Style; use ratatui::text::Line; -use super::{Cell, Row, cells_of, cells_width, clip_cells, clip_line, finish_row, wrap_cells}; +use super::{ + Cell, Row, cells_of, cells_width, clip_cells, clip_line, finish_row, trim_trailing_space, wrap_cells, +}; /// Room a column gets for its longest word before it is asked to share. One very long /// token (a path, a URL) would otherwise starve every other column of the budget. @@ -40,6 +42,12 @@ pub(crate) fn wrap_table(lines: &[Line<'_>], offsets: &[Vec>], wid }; let (original_widths, border_style) = (original_widths.clone(), *border_style); + // Below four cells per column the box itself is wider than the window; reflowing would + // only draw a border the window cannot show. Clip, as the table was before it could reflow. + if width < original_widths.len().saturating_mul(4).saturating_add(1) { + return clip_table(lines, offsets, width); + } + // A `│` in cell text splits that line into more columns than the border has, and the // surplus would be dropped. One unreadable line disqualifies the whole block. let ragged = parsed.iter().any(|line| match line { @@ -59,19 +67,28 @@ pub(crate) fn wrap_table(lines: &[Line<'_>], offsets: &[Vec>], wid } } let column_widths = fit_table_columns(&original_widths, &longest_word, width.max(1)); - let table_wraps = parsed.iter().any(|line| match line { - TableLine::Content(columns, _) => table_content_wraps(columns, &column_widths), - _ => false, - }); + // Wrap every cell exactly once; rendering and the "does anything wrap" question both + // read these pieces. + let wrapped: Vec>>>> = parsed + .iter() + .map(|line| match line { + TableLine::Content(columns, _) => Some(wrap_columns(columns, &column_widths)), + _ => None, + }) + .collect(); + let table_wraps = wrapped.iter().flatten().any(|columns| columns.iter().any(|pieces| pieces.len() > 1)); let mut out = Vec::new(); for (index, parsed_line) in parsed.iter().enumerate() { match parsed_line { TableLine::Border(_, kind, style) => { + // A border the renderer left unstyled borrows the top border's style, so the + // box is drawn in one colour. let style = if *style == Style::default() { border_style } else { *style }; out.push(render_table_border(&column_widths, *kind, style)); } - TableLine::Content(columns, line_border_style) => { + TableLine::Content(_, line_border_style) => { + let columns = wrapped.get(index).and_then(Option::as_deref).unwrap_or(&[]); out.extend(render_table_content(columns, &column_widths, *line_border_style, border_style)); // Wrapped rows run together without a rule between them. let next_is_content = matches!(parsed.get(index + 1), Some(TableLine::Content(..))); @@ -247,28 +264,32 @@ fn render_table_border(widths: &[usize], kind: TableBorder, style: Style) -> Row finish_row(cells, Style::default()) } -/// Wrap every cell of one source row and stack the results into as many screen rows as the -/// tallest cell needs. `line_border_style` is the style of this row's own `│` characters; -/// it dresses the padding of a column that has run out of content. -fn render_table_content( - columns: &[Vec], - widths: &[usize], - line_border_style: Style, - border_style: Style, -) -> Vec { - let wrapped: Vec>> = widths +/// Wrap every cell of one source row to its column, each piece trimmed and clipped so it +/// stays inside the column even when a single wide char is wider than it. +fn wrap_columns(columns: &[Vec], widths: &[usize]) -> Vec>> { + widths .iter() .enumerate() .map(|(index, &width)| { let width = width.max(1); wrap_cells(columns.get(index).map_or(&[][..], Vec::as_slice), width) .into_iter() - // A cell narrower than one char still has to stay inside its column. - .map(|piece| clip_cells(super::trim_trailing_space(piece), width)) + .map(|piece| clip_cells(trim_trailing_space(piece), width)) .collect() }) - .collect(); - let height = wrapped.iter().map(Vec::len).max().unwrap_or(1); + .collect() +} + +/// Stack one source row's wrapped cells into as many screen rows as the tallest cell needs. +/// `line_border_style` is the style of this row's own `│` characters; it dresses the padding +/// of a column that has run out of content. +fn render_table_content( + wrapped: &[Vec>], + widths: &[usize], + line_border_style: Style, + border_style: Style, +) -> Vec { + let height = wrapped.iter().map(Vec::len).max().unwrap_or(1).max(1); let mut rows = Vec::with_capacity(height); for row_index in 0..height { let mut cells = vec![Cell { ch: '│', width: 1, offset: None, style: border_style }]; @@ -290,12 +311,6 @@ fn render_table_content( rows } -fn table_content_wraps(columns: &[Vec], widths: &[usize]) -> bool { - widths.iter().enumerate().any(|(index, &width)| { - wrap_cells(columns.get(index).map_or(&[][..], Vec::as_slice), width.max(1)).len() > 1 - }) -} - #[cfg(test)] #[allow(clippy::expect_used, clippy::indexing_slicing, reason = "tests assert by panicking")] mod tests { @@ -342,6 +357,29 @@ mod tests { } } + #[test] + fn a_window_narrower_than_the_box_gets_the_clipped_table() { + let lines = [ + Line::from("┌─────┬─────┬───────┬──────┐"), + Line::from("│ a │ b │ c │ d │"), + Line::from("├─────┼─────┼───────┼──────┤"), + Line::from("│ one │ two │ three │ four │"), + Line::from("└─────┴─────┴───────┴──────┘"), + ]; + let offsets: Vec>> = + lines.iter().map(|line| (0..line.to_string().chars().count()).map(Some).collect()).collect(); + for width in 5..=16 { + let rows = wrap_table(&lines, &offsets, width); + assert_eq!(rows.len(), lines.len(), "width {width}: clipped, one row per line"); + assert!( + rows.iter().all(|row| row.cells.len() <= width), + "width {width}: no row wider than the window" + ); + } + let rows = wrap_table(&lines, &offsets, 17); + assert!(rows.iter().all(|row| row.cells.len() <= 17), "at four cells per column the box fits"); + } + #[test] fn columns_keep_their_longest_word_and_share_the_rest_in_proportion() { // Step 37, Owner 8, Risk 6, Notes 60 at a 68-cell document: budget is 68 - 13 = 55.