edamame 0.1.6

A Terminal Markdown editor that stays rendered while you edit
Documentation
# Keyboard and mouse input

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


- **Two-layer mouse dispatch.** `MouseDispatcher` (`src/input/mouse.rs`) is a pure state machine turning crossterm `MouseEvent`s into semantic `MouseAction`s; `mouse_ops::apply` mutates `EditorState`. Keep the split strict — coordinate translation in `mouse_ops::coord`, click counting in `MouseDispatcher`.
- **Mouse enable is gated by capabilities.** `terminal::enable_mouse()` is called from `main` only when `capabilities.mouse`, and the app gates `MouseDispatcher::dispatch` on it too, so a fake mouse event can't drive the editor.
- **Drag state lives in `App`, not `EditorState`** — `App::drag_target: Option<mouse_ops::DragTarget>` is decided at mouse-down (text-selection anchor, table row, column border), read by each `Drag`, and cleared on `Release`. A UI-layer fact; clearing it doesn't go through the undo stack.
- **A drag never changes the reveal mid-drag.** `drag_in_progress` turns `cursor_block_revealed()` off until mouse-up. A press sets it unless it lands on the cursor's own line (outside a table) or in the cursor's diagram, so a plain click there doesn't flash the line rendered and back; the first `Drag` that moves the cursor sets it then, whichever line the press was on, unless the drag stays inside the press's diagram, which reveals as one unit and would put its image back over the selection (`in_one_diagram_block`). Revealing as the drag went would move text under a stationary pointer on every row, and a stacked paragraph would change height. The cost is one collapse of the press's row as the drag starts. Each moving `Drag` also re-arms the reveal as a fresh entry (`EditorState::rearm_cursor_reveal`), so after mouse-up the cursor's line reveals `RAW_REVEAL_DELAY` after the last move, even for a drag that never left its line (at once if it ended off a stacked row's first line, as on any entry there). Keyboard selection (vim `v`, Shift+arrows) keeps its live reveal.
- **Mouse scroll uses a different bound than keyboard scroll.** `mouse_ops::scroll_by_mouse` allows `max = total - 1` (plus `MOUSE_SCROLL_OVERSHOOT`, currently 0) and never calls `clamp_cursor_to_viewport_top`; keyboard `ScrollDown` uses `EditorState::scroll_down`, which keeps the cursor visible. Do not merge them — mouse scroll must not move the cursor. Keyboard `ScrollUp`/`ScrollDown` always step one line; `editor.mouse_scroll_lines` applies to the wheel only.
- **Click-to-offset goes through the row's origin.** `coord::rendered_sub_line_to_offset` resolves the wrap to a rendered char of the logical row, then asks `row_map::rendered_to_raw_col` which source position it shows — exact wherever the row's inline map renders what the row shows, including styled spans, markers, and a list item's or quote's prefix (see [editing-model.md](editing-model.md)). Rows painted as raw source (the revealed cursor row, a revealed reflowed paragraph's stacked lines, a diagram's rows) map against the raw line's own wrap instead. A table's row is found the same way, by its origin (`row_map::table_row`, which snaps a border or separator onto the table row above or below it), at any nesting depth; its columns then map cell for cell (`table_click_to_raw_col`, over `table_layout::paired_raw_cells` from the row's content column, so edge pipes are optional and a prefix's pipes never count), a border's through its table row's, so a click on one lands in a cell.
- **A click on an already-revealed line is exact and must stay that way.** The user is looking at raw source, so `coord` maps the column straight onto raw chars (`raw_click_col`, for the revealed cursor row and a reflowed paragraph's stacked lines alike) — which means laying the line out exactly as the painter did: behind the line's `row_map::revealed_indent`, so a revealed `- item` wraps continuations two cells in and against a narrower budget, and a ` 6.` padded beside `10.` starts its first row one cell in. Use `wrap::revealed_rows_of_str` with that indent for both the row count and the column mapping (offsetting `col` by `Indent::row(sub)`) — never bare `visual_rows_of_str`, whose indent-0 assumption drifts further with every wrap. The indent it returns is the *effective* one (`Indent::at`): when a part is as wide as the viewport (`indent + 1 >= width`) the layout drops it to 0, and reporting the stated width there pushes columns into `char_idx_at_cell_col`'s forbidden-indent zone. Any new caller pairing an `Indent` with a wrap layout owes the same clamp: `Indent::at`, or `wrap::PaintedRows`, which applies it. The Rendered/Preview click mapping and `patch_char_cols` once skipped it, misplacing clicks and highlights on a list item's continuation rows at narrow widths.
- **Raw mode wraps flat — no hanging indent — and three consumers depend on that.** Raw shows the file, so indenting continuations would paint whitespace the document doesn't contain. It is also the only self-consistent choice: the scroll cache (`raw_line_at_visual_row`, `raw_total_visual_rows`, `char_offset_at_visual_row`) and the click mapping (`coord::raw_click_to_offset`) both wrap through `visual_rows_of_str`, hardcoded to indent 0. An indent-detecting painter would disagree with them on the column *and* the row count at narrow widths, misplacing every line below. `RawView` therefore calls `line_render::render_raw_line_with_cursor`, which forces `Indent::NONE` (and thereby drops the blockquote-bar repaint, correct here because in Raw the `> ` is real source). The navigation half is mode-gated rather than forced, since `move_up_visual` / `move_down_visual` / `current_visual_col` are shared with the rendered views where the revealed line *is* indented: `ShownLine::of` wraps the whole line flat in `Mode::Raw`, and otherwise as `row_map::source_line_layout` says the view paints it — a revealing line from its block's range start (an indented paragraph's leading spaces aren't painted) behind its `revealed_indent`; a code, frontmatter or raw-HTML line, whose row never de-renders, from its origin's `raw_col` behind the row's own indent (a code row's pad cell as `lead`). Wrapping the whole buffer line put the cursor two cells right on `  para`, and one cell left on every code line. `tests/ui.rs`'s `vertical_navigation_lands_where_the_cursor_paints` holds navigation to the painted cursor, char by char.
- **One resolver answers "is there a link here, and which one": `mouse_ops::links::link_at_rendered_pos`.** The hand pointer (`hit_test_clickable`), the hint-line hover tooltip and the click-to-follow path all ask it, so a cell that shows a hand, names a URL and opens that URL is one decision rather than three agreeing by luck. Two things make it exact:
  - **The position comes from `coord::rendered_click_to_line_col_on_text`**, which is scroll- *and* wrap-aware. Counting one rendered line per screen row instead shifts the lookup by every wrapped line above the pointer. The `_on_text` variant additionally *declines* a cell past the row's last character instead of clamping onto it the way cursor placement does — otherwise a line ending in a link claims every blank cell to its right.
  - **Link runs are counted per *block*, not per line**: a block's N-th link-styled run pairs with the block's N-th `ui::link_view::LinkRun`, the same list `build_snapshots` pairs its own runs against. A per-*line* index restarts at zero on every line of a multi-line block, so every item of a link list would resolve to the list's first URL.
- **The AST side of that pairing counts *runs*, not links, so an inline image gets an entry.** `Theme::image_placeholder` is the link foreground with the alt text underlined, so `[Image: alt]` is a run under *both* run-finding walks while being no link at all. `LinkRun::ImagePlaceholder` consumes the run and resolves to `None` (the raw-source scan then gets its turn); without it every later link in the block pairs with the wrong URL (`![logo](l.png) see [docs](d.md)` would open `d.md` from the image). An image *inside* a link is deliberately one run and one entry: the walk does not descend into a link's own text.
- **The two run-finding walks are not interchangeable, and neither is wrong.** `mouse_ops::links::link_run_ranges` keys on the foreground; `link_view::underlined_char_ranges` keys on `Modifier::UNDERLINED`. An in-document heading anchor carries the link color and no underline, so it is a run for the resolver and not one for the snapshots — which is why clicks resolve anchors and the (currently unread) snapshots do not. Both consume one `LinkRun` per run they find, so each stays internally aligned; a change to either predicate has to be argued against both.
- **`is_link_style` keys on the link *foreground*, not on `Modifier::UNDERLINED`.** The underline is not theirs alone — the default theme underlines H2–H6 — so an underline test classifies headings as links and the run coalescing swallows a real link inside one. Every theme paints link text in the link color inside a heading and a blockquote alike (`link_text` / `link_file`, and the deliberately underline-free `link_heading` for an in-document anchor), which makes the color the honest signal. It is only a *candidate* test: an unrelated slot may share the color (`syntax_function` is derived from it) and a colorless theme (`Monochrome Dark`) has nothing but the underline, so the AST pairing above is what turns a candidate into an answer.
- **The resolver runs at pointer-report cadence, so it owes three economies.** It is asked on every `Moved` event, so nothing in it may cost O(document). (1) `hit_test_clickable` is split: `App::dispatch_mouse_event` resolves the hovered link for the hint line and then composes `hit_test_clickable_non_link` with its own `hovered_link` — the full `hit_test_clickable` remains the whole answer for tests and any other caller, but the app must not ask it to resolve a link it just resolved. The substitution is exact only because both reads use the same position *and* the same viewport width. (2) `rendered_click_to_line_col_on_text` gets the `LineLayout` (row table, hanging indent, char count) back from the call it inverts, rather than rebuilding the char vector and wrap table from `state`. (3) The block source comes from `Buffer::byte_slice_to_string`, not `Buffer::contents()` — slicing one block out of a materialized document is O(document) per hover. The remaining `contents()` in `hit_test_clickable_non_link`'s checkbox / footnote arm is the same cost and still unpaid; `footnotes::footnote_at_offset` and `list_edit::find_list_at` want whole-source byte offsets.
- **`links::link_at_offset` is the raw-source fallback, not the primary path.** It scans a line's bytes for a balanced `[...](...)`, so it catches a click on the revealed `[text](url)` source of the cursor's block and in Raw mode; it sees no reference-style links or autolinks, and it is consulted only after `link_at_rendered_pos` declines.
- **Checkbox toggling short-circuits cursor placement.** `checkbox::toggle_checkbox_at` runs BEFORE `click_to_char_offset` in the `Click` arm: a click on the `[ ]` glyph toggles and returns without moving the cursor; clicks elsewhere on the line fall through.
- **A table drag resolves its target on one axis, geometrically — never by asking `hit_test` for a classification.** `col_ranges` / `row_ranges` cover only *content*; borders, vertices and separators sit in one-cell gaps that `hit_test` answers with `ColumnBorder` or nothing, so a classification-based drag stalls wherever the pointer crosses a border. `data_row_at_y` / `column_at_x` snap to the nearest row / column within the table's extent instead: a row drag follows the pointer's **y** alone, a column drag its **x** alone. They also back the two grab hit-tests, so the whole gutter height and top border are live. Outside the extent both return `None` — the hover freezes rather than snapping to an edge.
- **A handle acts only on the focused table, because that is the only table it is drawn on.** `paint_handles` draws `⠿` / `⇔` / `✕` only on the table the cursor is inside, while `hit_test` runs against every visible snapshot — so `dispatch_table_click` routes *every* handle hit through `focus_table_first`, which moves the cursor into that table and consumes the click without acting. Otherwise a press drives a control that was never painted (a click on another table's right border deletes a row from it). Only `Cell` and the inert leftmost outer border are exempt, falling through to ordinary cursor placement.
- **The `✕` pair is scoped tighter, spatially and temporally.** The column-delete hitbox is `column_glyph_x` ±1, not the column's full width, so a click aimed at a border from the bottom row resizes rather than dropping a column (`column_glyph_x` is shared with `paint_handles`). `TABLE_DELETE_COOLDOWN` (250 ms) swallows a second delete landing too soon, so a habitual double-click removes one row. **The cooldown is anchored to the delete, not to the click chord** — `MULTI_CLICK_WINDOW` restarts on every press, so a chord gate never expires under steady clicking. `EditorState::last_table_delete_at` is stamped by `table_drag`'s two delete helpers, only on a delete that applied.
- **Multi-click arms must dispatch table hits too, because a drag *is* a click-chord member otherwise.** `dispatch_table_click` is shared by the `Click` / `DoubleClick` / `TripleClick` arms, which behave identically: a table interaction is a gesture users retry, and the second press arrives as `DoubleClick`. `MouseDispatcher::dragged_since_down` closes the other half — a press that turned into a drag doesn't start a chord, so the retry is a fresh `Click`. Both halves are needed; the flag can't help a retry whose *first* press never dragged.