edamame 0.1.6

A Terminal Markdown editor that stays rendered while you edit
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Table widths and wrapping

> Part of the edamame contributor deep-dives. Index and project-wide conventions: [`AGENTS.md`](../../AGENTS.md). Sibling docs live in [`docs/dev/`](.).

- **Every table width is in terminal cells, measured by `table_layout::str_cells` / `char_cells` (`cells_of` for a char slice).** That is the per-char sum `line_render::paint_row` draws — not `chars().count()` (a CJK glyph is two cells) and not `UnicodeWidthStr::width`, which is sequence-aware and counts `❤️` and a ZWJ family as two cells each while the painter draws one and six. Column max (`Renderer::rendered_inlines_width`), column min (`renderer::table::cell_min_width`), row padding and truncation (`render_table_row`) and the drag anchors (`table_drag::natural_widths`) all go through it. Mixing units sizes a column for one width and draws it at another: a char-counted max pushed every CJK row's right border one cell per glyph (#45). A new width decision in this area uses `str_cells`, nothing else.
- **There is one wrap, `table_layout::wrap_ranges`.** The renderer's styled cells (`util::wrap_styled_chars`), the click mapper (`coord::table_click_to_raw_col`) and the wrapped raw-reveal overlay (via `wrap_cell_with_indices`) all call it, so their rows agree by construction. Don't add a parallel tokenizer — the two copies that existed before had already drifted on leading whitespace after a hard split. The raw-reveal chunk overlay (`cell_overlay::compute_cell_chunk_overlay`) deliberately hard-wraps instead — its chunks must depend only on glyph widths — but through `table_layout::hard_wrap_ranges`, which shares the cluster-boundary splitter (`split_at_clusters`) with the word wrap's hard split. A punctuation-preferring cut is taken only when the remainder still fits beside the next cluster, so no row exceeds its width except one holding a single cluster wider than the column.
- **A cell's columns map through `table_layout::CellContent`.** It trims the raw cell, builds an `InlineColMap` (`build_inline`, with the document's `RefLabels`) from that to the marker-collapsed text the renderer shows, and wraps *that* text at the cell's width, so its chunks are the ones `render_table_row` drew. The click (`coord::table_click_to_raw_col`), the selection / search overlay (`table_raw_col_range_to_rendered_segments`) and the cursor on a formatted row (`table_raw_col_to_rendered`) all read cells through it. Wrapping the raw cell instead, or offsetting into it one for one, puts any text after a hidden marker (`*b* x`) in the wrong chunk or column, which is how the overlay showed no highlight there. The cell reveal overlays count raw columns, since they show the raw text, and so does a click on the revealed cell: `coord::table_click_to_offset` asks `markdown::cell_overlay` for the overlay the view paints on the clicked line (`revealed_cell_overlay`, gated as `RenderedView` gates it) and maps the click through its raw text (`overlay_click_to_raw_col`), falling back to `CellContent` for the row's other cells. Mapped through `CellContent` there, a click on the char showing the cursor in `**a**` landed past the hidden markers, at the cell's end (#78). So the cursor, drawn on its glyph while the row is formatted (before the reveal fires, or while search, a `:s` preview or a drag holds it off) and on its raw char once revealed, moves by the hidden markers before it when the cell reveals, as on any row with inline markup; in a wrapped cell it can change sub-line too, since the raw text wraps differently. Placing it where the reveal would (raw columns over formatted text) kept it still but off its glyph, in the padding for a whole search, where a click didn't put it back.
- **Words split at soft spaces and beside wide glyphs.** `word_ranges` breaks at whitespace (NBSP excepted: code-span pads must travel with their token) and at every `Joint::Glyph` — a boundary of a cluster wider than one cell — because CJK carries no inter-word spaces. No `Glyph` joint forms before closing punctuation, after opening punctuation (`no_break_before` / `no_break_after`), or beside a no-break char or an ambiguous quote (`is_no_break_char`, `is_ambiguous_quote`), so `せ。` and `「さ` are each one word. The prose wrap (`document::wrap`) reads the same joints. A wrap never cuts at `Joint::Inside`, so a grapheme cluster is never split, even by a hard split. `cell_min_width` and `table_drag::longest_word_cells` floor on the same `word_ranges`, so a CJK run floors at its widest glyph and the floor always matches what the wrap can actually achieve.
- **Rendered pipe positions come in two units, and each consumer needs a specific one.** `rendered_pipe_positions` returns char indices into the rendered line, which is what a `cursor_col_override` takes (`table_raw_col_to_rendered`). `rendered_pipe_cells` returns screen cells, which is what anything addressing the screen needs: `overlay_raw_cell` (`CellOverlay::rendered_start` / `rendered_end`), the click mapper `coord::table_click_to_raw_col` (a click column is a cell), the resize / cell hit map (`TableLayoutSnapshot::col_ranges`), and the wrap width of `table_raw_col_range_to_rendered_segments`. They diverge once a wide glyph sits left of the cell. `CellOverlay::cursor_in_cell` and the selection columns stay char indices into `raw_text`; the painter walks chars while advancing by cells, and blanks a glyph that would straddle the border rather than overwrite it.
- **A revealed cell paints where the row's wrap put it.** A table wider than the viewport wraps its rows on screen like any line, so the overlay's unwrapped cell columns go through `PaintedRows::unwrapped_cell_positions` (`overlay_raw_cell`) to reach their screen row and cell, the same wrap a click resolves through (`coord::table_click_to_offset`). Painted at the unwrapped column on the row's first screen row, a cell past the wrap showed on the row above or off the edge while a click on it resolved below, and a cell the wrap absorbed showed nowhere (#69). A wide raw glyph whose two cells the wrap splits is blanked, as at the border. Separately, a cell that wraps inside its column takes the wrapped overlay (`compute_wrapped_cell_overlay`) in the header as in a data row: the one-line overlays replace only the first of the cell's lines, leaving the rest rendered.
- **A Preview cell band is in cells; convert it per line.** `CellBand::cols` is a cell range because every sub-line of a table row shares cell geometry, while its char columns shift sub-line by sub-line with the wide glyphs in other columns. Copy, the selection size, drag clamping and the Preview painter each call `CellBand::char_cols(line)` for the line at hand; never compare `cols` against a char column directly.
- **Highlight painters take char columns and place them by cells.** `line_render::patch_char_cols`, the one painter behind Rendered search / selection (`paint_cols_on_line`) and Preview selection, walks the row's chars from its start, adding `char_cells`, and styles both cells of a wide glyph. Offsetting `x` by the char index is what put CJK highlights in the wrong place, in paragraphs as well as tables.
- **The renderer records each table row's origin.** A row's wrap chunks are `TableRow { row, sub }` (row 0 the header, `1 + i` data row `i`) showing that row's source line; the heavy rule shows the delimiter line, each separator and the bottom border the line above them, and the top border none — so a line's first row (`row_map::row_for_line`) is its content, never a border. Every mapping path reads these through `row_map::table_row` — the cursor's row, the gutter, clicks, Preview's cell band, the drag clamp (`table_cell_char_range_at`, which then parses the line's cells past any prefix) and the click's reveal suppression, the selection / search overlay, and the cell-scoped reveal — so a table nested in a list item or a quote maps exactly as a top-level one does. A border or separator snaps onto a table row: a separator and the bottom border onto the row above, the top border and the heavy rule onto the row below. The handles read the same origins: `row_map::table_parts` names each row's part of its table (border, header chunk, heavy rule, data chunk, separator) from the origins' sequence, and `table_view::build_snapshots` builds one snapshot per table from it, at any depth. The column-resize preview (`live_table_widths`) finds a nested table by its header's source line, since no block range starts there.
- **Table columns map cell for cell, never by counting pipes.** `table_layout::raw_cells` splits the raw row as GFM does, from its origin's `raw_col` (carried on `TableRowHit::raw_col`), so a container prefix never contributes a pipe, even a footnote label holding one (`[^a|b]: | x | y |`), and edge pipes are optional (`a | b`, `a | b |`), where the renderer always draws both borders. `paired_raw_cells` keeps the first as many cells as the row draws, and `drawn_cell_at` finds the drawn cell a raw column shows in. The click, the cursor indicator, the selection overlay and the three cell-reveal overlays all pair cells this way, and the editing path splits its rows with the same `raw_cells`.
- **A row's cell count can differ from the header's, and each direction has one rule.** GFM drops cells past the header's count, and the renderer draws none: a cursor in one (reachable by keyboard) has no cell to reveal into, so `drawn_cell_at` answers `None`, the row reveals whole, and the indicator before the reveal falls back to the raw column. A short row is padded with empty cells that have no raw cell; a click in one lands at the row's end. Past a closing pipe, typing there adds that cell, so `drawn_cell_at` puts the cursor in the first missing one; without a closing pipe, typing extends the last cell, so the cursor stays there. A revealed cell with no space after its opening pipe (`a | b`'s first, or a missing one) paints one cell in (`unpadded_cell_shift`), on the text it replaces rather than the pad cell, which is where a click maps it.
- **The editing path finds a table through the parse, not by scanning lines.** Tab, the hints, the structure edits, the vim scoping, the drag clamp and the handles all take a `TableInfo` from `table_edit_ops::locate_table`: `row_map::table_lines` names the table's lines and each row's content column (from the `Block::Table`'s `SrcLines`, at any nesting depth), and `table_edit::table_from_lines` splits them from the live buffer. The delimiter row is chrome to the parse, so its content column is read off the line, whose prefix can only be `>` and indent. A line scanner can't know a container's prefix, so it accepted only rows starting and ending with `|`: pipe-less tables, quoted ones and footnote-leader ones had no Tab or hints, and a short row with no closing pipe split the table in two. Location is by line, which an in-line edit never moves, so the deferred re-parse (`parsed_dirty`) is safe to read; a table the edit just made or broke waits for the re-parse. A column-swap chain re-splits the same lines from its composed text (`TableInfo::reparse`), since a swap moves no line or prefix.
- **A rewritten row keeps its prefix and its edge pipes.** `TableRow` records the text before its cells (a container prefix, a footnote label, the indent up to the first pipe or cell) and whether it has each edge pipe; `rebuild_row` re-emits both. It adds an edge pipe only where GFM would misread the row without one: a blank edge cell (`   | b` reads as `| b`), a single cell, or a first cell moved in that could open a block at the start of a line (`- x | 1` is a list item, which ends the table; `may_open_block` is deliberately broad, since a false positive only costs a pipe). Edge cells without a pipe are padded before a move (`padded_cells`) and trimmed where they land on a pipe-less edge, so `a | b` swaps to `b | a`. A short row lacking the column a swap moves its cell into gets that column, blank, so the cell follows its header. A new row is all blank cells, so it always gets both pipes, behind the prefix of the row above (the delimiter's, at the earliest, never the header's marker or label). The widths comment takes the last row's prefix for the same reason. Tab and Shift+Tab stop at each row's last cell (`TableInfo::last_col`): a short row's missing cells hold no text, and landing there made Tab stick, since every missing cell resolves to the row's end.
- **Insert Table places a table where text typed at the cursor would belong.** On a blank line, `table_edit::blank_line_indent` asks the parse for the content columns of the items holding the nearest line above (`row_map::item_content_cols`): directly below it, the innermost item takes the table; past a blank line, the deepest item the line's own indentation reaches, so the unindented line `Enter` leaves on exiting a list stays top-level. An empty item (`- `) is found from the text (`list_edit`), as it has no leaf to read a column from, and the table opens on its marker line. The text can't say whether the item is real (a `- ` in a fence is code; `2. ` below a paragraph line continues it), and the parse before the insertion can't either (an empty item can't interrupt a paragraph, so `text⏎- ` is a setext heading, yet `- | a |` is an item), so `table_fills_item` parses the text with the table inserted and accepts only when the table opens that item. Items in a quote or footnote get none: a blank line has no `>`, and a footnote's first line puts its items' columns past the label.