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..1658628 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; @@ -110,11 +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); - block.rows = if block.kind.preserves_columns() { - lines.map(|(l, o)| clip_line(l, o, width)).collect() + 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() } 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.rs b/crates/plannotator-tui/src/wrap.rs index 6df76b3..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 { @@ -39,11 +44,37 @@ 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 +} + +/// 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); let mut spans: Vec> = Vec::new(); let mut columns: Vec> = Vec::new(); for cell in &cells { @@ -56,26 +87,27 @@ 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(); 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() && !rows.is_empty() { + if is_space && current.is_empty() && !pieces.is_empty() { continue; } if current_width + token_width <= width { @@ -84,7 +116,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; @@ -97,7 +129,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); @@ -105,26 +137,24 @@ 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). 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) + finish_row(clip_cells(cells_of(line, offsets), width), line.style) } #[cfg(test)] diff --git a/crates/plannotator-tui/src/wrap/table.rs b/crates/plannotator-tui/src/wrap/table.rs new file mode 100644 index 0000000..2ff1856 --- /dev/null +++ b/crates/plannotator-tui/src/wrap/table.rs @@ -0,0 +1,520 @@ +//! 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, 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. +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); + + // 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 { + 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)); + // 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(_, 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(..))); + 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 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() + .map(|piece| clip_cells(trim_trailing_space(piece), width)) + .collect() + }) + .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 }]; + 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 +} + +#[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 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. + // 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); + } +}