# 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/`](.).
- **The rope is always pure `\n`; `Buffer::line_ending` records the on-disk convention.** Every path that ingests document text normalizes CRLF away first (`buffer::normalize_newlines`): `Buffer::load_file` / `from_str` / `reload`, the filesystem watcher (`handle_file_changed`, *before* it hashes, diffs or stores — or a CRLF file spuriously diffs as wholly-changed and the own-write echo filter never matches), the `--diff` sides (`difftool::read_side`), the OS clipboard read (`clipboard_text`) and every bracketed paste (`paste_text`). Every *egress* re-encodes to the buffer's convention: saves stream through `write_lines` (`\n`→`\r\n` chunk by chunk, never materializing a translated `String`), and the *external* clipboard copy widens through `encode_newlines` while the kill-ring stays `\n`-only. So downstream code — cursor math, wrap, diff, `(?m)$` anchoring — may assume no `\r`; a bare `strip_suffix('\n')` is enough. **`reload` takes the ending as a parameter rather than re-detecting it** — its bytes are already normalized upstream, so detection would always report `Lf` and silently flip a CRLF document. A new disk-read or clipboard path owes the same normalize-in / encode-out pair; a lone `\r` (classic-Mac, essentially extinct) is deliberately left intact.
- **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. The extended ranges (`build_extended_ranges`) are built *after* the virtual blanks are pushed; never derive cursor-mapping ranges that absorb blank-line bytes into adjacent blocks (the removed `parse_offsets::covering_ranges` did).
- **`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.
- **Outside a reflowed paragraph, the reveal depends on rendered rows staying 1:1 with source lines.** (Prose reflow is the exception — see below.) `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.
- **Prose reflow breaks the 1:1 the other way, and `EffectiveRows` is how the reveal survives it.** A soft break inside a paragraph is not semantic (CommonMark collapses it to a space), so with `config.editor.reflow` on (default) `render_paragraph` joins soft-break segments into one flow wrapped to the viewport; a hard break still splits. A paragraph's *rendered* form is then **shorter** than its raw source (six source lines → two rows) — the reverse of every other block, where raw ≤ rendered. The single gate is `ParsedDoc::is_reflowed_paragraph_at(byte)` (reflow on, a top-level `Block::Paragraph` via `real_block_for_byte`, collapsed to one rendered line), and it rides in `RenderSettings` so a toggle clears the render cache. **Scope: top-level paragraphs only** (the `top_level` flag on `render_block`) — list-item and blockquote paragraphs don't reflow, since the consumers below key on a top-level paragraph; extending them is [`plans/nested-reflow.md`](plans/nested-reflow.md).
- **The reveal makes the block *taller* while the cursor is in it** (source lines shown stacked), so document height is reveal-dependent — the height-neutral padding the other reveals rely on can't express it. `editor::effective_rows::EffectiveRows` is a per-frame overlay answering the viewport's visual-row questions (`total_visual_rows`, `line_at_visual_row`) with the revealed block's `N` rendered lines replaced by its `M` raw lines, as a cheap delta over the base `VisualRowCache`. It is the identity unless a reflowed cursor block is revealed in Rendered mode, so the consumers routed through `EditorState::effective_rows` (viewport sizing, `rendered_cursor_visual_row`, `char_offset_at_visual_row`, the gutter, the reveal loop's start, `mouse_ops::coord`) are unchanged elsewhere. `EditorState` memoises it per `(parse, width, revealed block)`, so a frame's repeated queries build it once.
- **The revealed block uses `revealed_source_lines`, not `raw_source_lines`** — a paragraph's byte range absorbs the blank line after it, which owns its own rendered row, so stacking it too would over-count the block by one row. `EffectiveRows`, the reveal loop, and the two mouse paths all use the trailing-blank-stripped set so they agree.
- **The height change is timer-driven** (reveal after `RAW_REVEAL_DELAY`, un-reveal on exit), so `EditorState::anchor_reflow_reveal` (from `App::prepare_viewport`, beside `sync_image_reveal`) reconciles it. The expansion is entirely *below* the block's first row, so leaving `scroll` alone pins that row and everything above it and lets only the content below reflow (the image/mermaid feel); the toggle frame just re-runs `ensure_cursor_visible` (scrolls the minimum, usually nothing) rather than re-pinning the cursor's exact row.
- **A reflowed paragraph and a mermaid diagram are *reveal-as-one-unit* blocks** (`state_cursor_block`): the delay re-arms on every buffer-line move like any block, so **scrolling through one never dwells long enough to reveal it**; once a dwell reveals it, `cursor_reveal_latched` holds it revealed while the cursor stays inside (`latch_cursor_reveal` per frame, reset on leaving the block), so moving between its lines never flashes it collapsed. **Exception: entering on a line other than the first** (upward move or click) reveals and latches immediately — the collapsed flow row can't show the cursor on its true line, which would sit at the top and drop when the block expands. A first-line (downward) entry keeps the delay, since its line *is* the flow row.
- **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. `App::next_deadline` wakes the loop when the delay expires, so the reveal draws without a keypress.
- **Single-pass parse.** `ParsedDoc::build` gets blocks and top-level byte ranges from one `parse_raw_with_ranges` call, so blocks↔ranges stay 1:1 by construction — don't add a second ranges pass ([performance.md](performance.md#the-two-optimizations-and-why-they-must-not-be-undone)).
- **Block-level render memoization.** `EditorState` owns a `markdown::RenderCache` threaded into every `refresh_parsed`. The key is the `Block` *value* plus a `RenderSettings` 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. Costs, the cache-worthy gate and the remaining ceilings are in [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 — through `cursor::scrolled_field_spans` when the value can outgrow the field.
- **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.