# 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.
- **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 is approximate for formatted text.** `coord::rendered_sub_line_to_offset` maps the visual column 1:1 to the raw source column — exact for unformatted lines, off by a few chars for styled spans; `RAW_REVEAL_DELAY` then turns the cursor's line raw so the user can correct on a second click. `markdown::InlineColMap` provides the exact map where precision matters (selection highlight projection in `RenderedView`).
- **A click on an already-revealed line is exact and must stay that way.** The user is looking at raw source, so `coord`'s `revealed_cursor_line` branch maps the column straight onto raw chars — which means laying the line out exactly as the painter did: `render_line` derives a *hanging indent* from the leading marker, so a revealed `- item` wraps continuations two cells in and against a narrower budget. Use `coord::revealed_raw_rows` for both the row count and the column mapping (shifting `col` by the indent on any sub-row past the first) — never bare `visual_rows_of_str`, whose indent-0 assumption drifts further with every wrap. The indent it returns is the *effective* one: when the marker is as wide as the viewport (`indent + 1 >= width`) the layout falls back to indent 0, and reporting the raw marker width there pushes continuation columns into `char_idx_at_cell_col`'s forbidden-indent zone. Any new caller of `compute_hanging_indent*` that pairs the indent with a wrap layout owes the same clamp.
- **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 0 (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: `hanging_indent_for_mode` returns 0 in `Mode::Raw` and detects otherwise.
- **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 (` 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.