edamame 0.1.2

A fast TUI Markdown editor and viewer
Documentation
# Hybrid editing model

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


- **Virtual blocks for blank lines.** `ParsedDoc::build` synthesises a one-byte block for every blank line, so the cursor lands on each as its own block and the blank survives in `RenderedView` when the surrounding block goes raw. Don't use `parse_offsets::covering_ranges` for cursor mapping — it absorbs blank-line bytes into adjacent blocks.
- **`per_block_own` vs. extended ranges.** `ParsedDoc` tracks per-block *own* rendered line counts (for `RenderedView`'s raw-replacement region) *and* extended covering ranges (for cursor lookup). Mixing them collapses gap blank lines when the cursor enters the previous block.
- **Loose lists stay one block; blanks are annotated, not split.** One `Block::List`, with blank source lines per item recorded in `ListItem::blank_lines_before` (`parser::post_pass::annotate_list_blanks`); the renderer emits that many blank `Line`s before the item, so spacing is kept while numbering comes from pulldown-cmark. Don't reintroduce `split_lists_on_blank_lines` — fragmenting forced per-group `start` re-derivation and block/range surgery for no rendering benefit.
- **The reveal depends on rendered rows staying 1:1 with source lines.** `RenderedView` maps rendered rows to source lines via `block_text.split('\n')`, so every separator blank must emit exactly one row. `editor::state::sub_lines_in_block` — the single raw→rendered line mapping — counts *separator* blanks (a blank run ending at a top-level marker) as rows while skipping interior-item blanks and soft-break continuations; "count only non-blank lines" reveals the cursor line one row too high. It answers a whole block per call (`cursor_sub_line_in_block` indexes the result) because per-line queries were quadratic.
  - **Four callers must agree and must never re-derive it:** `RenderedView`, `cursor_rendered_line_idx`, `mouse_ops::coord`'s `revealed_cursor_line` (through the latter), and `editor::state_source_lines` (the gutter). Drift maps a click on a revealed line against *rendered* spans, so a line whose markers were dropped places the cursor short by the marker width.
  - `state_source_lines` *inverts* the function rather than hand-writing an inverse. Three rules: **last writer wins** (a line rendering no row shares its sub-row with the next line, whose text is on screen); skip blocks with `block_own_line_count == 0`; and read the document from `ParsedDoc::source`, not the live `Buffer` — it resolves parse-time byte ranges, and the table is memoised per parse.
  - **Its inputs are shared too:** `rendered_view::raw_text::raw_block_cursor` is the single derivation of the `(block source, raw line index, column)` triple both callers feed it. `RenderedView` keeps one branch of its own, for a stale parse, rebuilding the triple from `cursor_block_line_range` so just-typed characters show before the reparse.
- **Jitter-suppression reveal.** `cursor_block_revealed()` returns false during a 120 ms `RAW_REVEAL_DELAY` after the cursor enters a new *buffer line* (not block); `RenderedView` keeps the block rendered and draws an inverted-cell cursor indicator meanwhile. The app loop's `rx.recv_timeout(60 ms)` fires the redraw without a keypress.
- **Single-pass parse.** `ParsedDoc::build` gets blocks and top-level byte ranges from one `parse_raw_with_ranges` call (a `RangeTracker` observes the same offset-iterator events), so blocks↔ranges stay 1:1 by construction. A second `top_level_block_ranges` pass costs a full extra parse per reparse.
- **Block-level render memoization.** `EditorState` owns a `markdown::RenderCache` threaded into every `refresh_parsed`. The key is the `Block` *value* plus a render-settings fingerprint (theme address, viewport width, `table.row_striping`, big-H1, …); keying by AST rather than source bytes keeps live table-width drags and post-pass mutations correct. `Block::ImageBlock` is never cached (its row count tracks the decode cache). Eviction is by document membership per build. **A new `Renderer` knob that changes output must go into `RenderSettings`**, or stale hits paint with the old setting. The measurements behind it — the frame budget, the per-stage costs, the ceilings that remain — are in [`docs/dev/performance.md`](performance.md).
- **One shared `line_render` module.** Preview and Rendered both call it; the trailing-cell fill and word-aware wrap live there. Change it, don't fork it.
- **Where a row may break is one predicate.** `line_render::is_break_after`: "anything non-alphanumeric", narrowed by three refinements (issue #36) — a no-break char (U+00A0 and friends) is never a break; an *opening* delimiter (`(` `[` `{` `“` `‘` `«` `¿` `¡`, plus `"`/`'` when a word follows and none precedes) is never one, so it can't hang alone at the row edge; and `.` `,` `:` `'` `’` `_` are not breaks between two alphanumerics, keeping `they’re`, `3.14`, `12:30`, `file.md` whole. **`/` is deliberately excluded** — including it strips every break point out of a URL path, and links are far more common in Markdown than `and/or`. Both neighbours matter, so the predicate takes the slice and an index, not a lone `char`. A break right after a one-letter word backs up to the preceding break (`ends_with_lone_word`) unless that word starts the row. Smart punctuation is on, so a rendered contraction carries `’` (U+2019) — a rule naming one owes the other.
- **A row may never end mid-grapheme-cluster; the cluster gate outranks every break refinement.** The wrap reasons in `char`s, where a ZWJ looked like a fine break (a family emoji split across rows, a combining mark stranded from its base). `cluster_starts` builds a boundary mask once per line, consulted by both `is_break_after` and `snap_to_cluster_boundary`. Two things keep it affordable and terminating: an **all-ASCII fast path** returns `None`, and a snap that would empty the row **keeps the unsnapped break**. This fixes *breaking* inside a cluster, not *measuring* one — `char_cells` still sums a ZWJ sequence's members.
- **A break absorbs the single space that follows it, so `next_start` may exceed `row_end`.** A space at a row *start* reads as accidental indentation. `absorbed_next_start` applies to every arm, not just the hard break. The absorbed char belongs to no row, so both cursor paths know it: `sub_line_of_col` reports a column in `end..next_start` as the **next row's column 0**, and `render_line_reporting_cursor` rewrites a `cursor_col_override` landing there onto that row's first char (otherwise the cursor sits on a cell that doesn't exist). A *run* of spaces is never absorbed (interior whitespace is content — `visual_rows_preserves_interior_whitespace_across_wrap`), nor is a trailing one. Overlay painters intersect with `[row_start, row_end)`, so an absorbed char is never washed.
  - **`line_render::last_col_in_row` is the single clamp keeping a cursor on its own row**, shared by `state_cursor_visual::raw_col_for_visual_cells` and `mouse_ops::coord`'s `char_in_row_at_cell`, `non_table_click_to_raw_col`, `click_to_rendered_char_idx` and `rendered_click_to_line_col`. It measures against `end`, never `next_start`: `next_start - 1` clamps the cursor onto the absorbed space — a char with no cell, so Up stalls permanently and a click on a row's last cell lands a row low. A new screen-cell→char-column site owes it the call.
- **The cursor is a uniform block; color, not shape, signals context.** The cell at the insertion point is recolored while the character stays visible. Resolved in one place — `app::cursor_style::editor_cursor_style` — with views receiving the finished `Style`. The default handler colors by *view* mode (`status_mode_preview` / `_rendered` / `_raw`); under vim the cursor **mirrors the sub-mode chip**, reading the same `status_mode_vim_normal` / `_insert` / `_visual` fields (minus the chip's `BOLD`). RAW is surfaced only in INSERT; NORMAL/VISUAL keep their sub-mode color in every view. Modal inputs use `theme.cursor`. No bar/caret shape, no `CursorShape` enum.
  - Painted via `cursor_col_override`, never baked into the wrapped `Line`, so the wrap keys on glyph-free source text and stays in lockstep with `move_up_visual` / `move_down_visual` and the scroll math. `line_render::paint_row` recolors the resolved cell; the raw-reveal builders in `rendered_view/paint.rs` and `raw_view::raw_display_line` build lines without the cursor; `overlay_raw_cell` recolors a table cell in place. A block cursor on a selected cell wins over that wash.
- **Modal input cursors are blink-stable.** `ui::cursor::text_field_spans` always emits a one-cell slot — the character recolored when the blink is on, a plain cell when off — so the field never changes width. A new text-input modal must use it, or (where a value flows through `format_modal_row` / horizontal scroll and the cell can't carry its own style) mirror the constant-width slot with `cursor::CURSOR_BLOCK`.
- **Code body rows render behind one pad cell, and three consumers must agree.** `render_code_block` paints each non-empty body line as `format!(" {:<width$}", …)`, so rendered column `c` shows raw char `c - 1`; an *indented* block adds the indent pulldown-cmark stripped. Both live in `markdown::code_layout` — forward for the two painters (`paint_byte_range_overlay`, `RenderedView`'s cursor indicator), inverse (`code_rendered_col_to_raw_col`) for `mouse_ops::coord`. Only the overlay had it once (issue #28). The cursor arm sits **ahead of the list arm** in the `visual_col` chain, or a code line reading `- foo` is claimed by the list-marker sniff.
  - **Fence rows are outside the mapping** (the opening row renders a ` lang ` label, the closing a placeholder): ask `is_code_fence_row`, then wash the whole row or leave the column alone. A **blank** body row still maps to the pad column, or the cursor jumps on the first keypress.
  - **`raw_lines` must come from `raw_source_lines`, never a bare `split('\n')`** — a block range ends with its newline, so the bare split appends a phantom entry. **The closing fence is identified by its *text*, not its index**: `is_code_fence_row` asks `is_closing_fence_line` (3+ of one delimiter, nothing else), because an *unclosed* fence — every code block while being typed — ends on ordinary code that still needs the mapping.
- **A code body line never de-renders, so "revealed ⇒ 1:1" is wrong inside one.** `code_layout::line_allows_raw_reveal` is the single derivation of which lines get raw source: every non-code line, a fenced block's opening fence plus a closing one actually typed, nothing in an indented block. `cursor_block_revealed()` deliberately doesn't know about it — it answers a block-level, time-based question for four other consumers. `RenderedView`'s reveal gate and `mouse_ops::coord` (click shortcut *and* `revealed_raw_row_count`) both ask the shared predicate.
- **NBSP padding in code blocks.** Blank lines inside fenced blocks use U+00A0, working around a ratatui `WordWrapper` (`trim: false`) bug where an all-whitespace line yields an extra visual row. Don't simplify back to spaces.
- **Word-group undo merging.** `History::record` merges contiguous single alphanumeric inserts into the previous delta; cursor moves break the group. Delta-based, not snapshot-based.
- **Visual line navigation.** `move_up_visual` / `move_down_visual` and `line_render::render_line` must use the same wrap algorithm (`visual_rows_of_str` / `sub_line_of_col`), or the cursor lands in a different column than where it appears.
- **`Action` is the full surface.** Every action lives in `config/keymap.rs::Action`; unimplemented variants are no-ops in `edit_ops` so keybindings stay stable while a feature is in flight.
- **Clipboard is feature-gated.** `arboard` sits behind the default-on `clipboard` feature; disabled, copy/cut/paste use the in-process kill-ring, which is also what tests assert against. Wayland needs `wayland-data-control` for read access.