edamame 0.1.6

A Terminal Markdown editor that stays rendered while you edit
Documentation
# Modals, overlays, and the keybinds editor

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


- **Live `KeyMap` on `App`, draft inside the overlay.** `App::keymap` is built once in `run()` and held for the process's life. The keybinds overlay opens with a *clone* (`KeybindsState::draft_keymap`) plus a cloned `KeyBindingOverrides`, and every rebind mutates only the draft; nothing reaches `App::keymap` / `App::keybindings` or `keybindings.toml` until `[ Save ]` returns `KeybindsResponse::Save { keymap, overrides }`, and Esc / `[ Cancel ]` discard it. Don't regress to mutating the live keymap per keystroke — a fumbled chord would then only be recoverable by hand-editing `keybindings.toml`.
- **One combined view+edit keybindings overlay.** `OpenKeybinds` owns it; there is no separate cheat-sheet variant.
- **A footer wraps; it never clips.** A clipped button is still focusable and still carries a click rect pointing off the frame. `button_row::button_rows` packs at the width it is given; `render_buttons` paints a row per group, separated by a blank (`ROW_SPACING`) so stacked rows read as alternatives rather than a list — so `button_rows_height` reports the *painted* height, not the row count. Both consumers must ask it at the width the render packs against: `ModalView` sizes `pinned_bottom` from the prospective inner width, and a bespoke multi-button overlay (search, save-copy, insert-table, keybinds) asks `button_row::footer_row_count` and gives the button rect *all* the rows left below it — a flat `height: 1` leaves a wrapped button unpainted while Tab still focuses it. A **single**-button footer (diff intro, export theme, welcome) always gets one row and is deliberately left alone. `footer_row_count` runs the real sizing arithmetic — `modal_dimensions_for`'s clamp, then `compute_pad_h` with the caller's own `max_pad_h` — because a flat `MIN_PAD_H` subtraction overestimates the inner width and reserves one row for a footer that wraps onto two. `render_buttons` returns a rect per button whatever fits: callers index the result by button.
- **A body narrower than the modal is centered as a block.** The footer is often the widest thing in a modal, and left-aligning the body inside the width it bought pushes a self-centered page (About's art and title column) off-center. `ModalView` renders *and measures* the body in a `body_render_w`-wide sub-rect: text stays left-aligned *within* the block, only the block moves.
- **A body that has to know its own width gets it from `modal::chrome::body_columns`** — the terminal less `MIN_PAD_H` a side, the widest body `ModalView` can hand back. Both modals using it (About, the Markdown cheat sheet) draw a *picture* for part of their body, so they rebuild it each frame and must not cache it. About drops its ASCII pod below that width (a wrap would shear it) and pads every tagline to the row count the *longest* needs at that width, so the frame doesn't resize mid-rotation. The cheat sheet caps only its code-block and block-quote washes there — a wrapped wash repeats its surface on a ragged second row, while every other row is content and must wrap like any modal body. The cap is `EXAMPLE_INDENT` short of the body width so the wash has the same margin on both sides.
- **`ModalView` is scrollable; the bespoke overlays are not.** `ModalState` carries `scroll`, `last_total`, `last_visible`, `scroll_by(i32)`. Up / Down / PgUp / PgDn / Home / End route to scroll, never to button focus — Left / Right and Tab / Shift-Tab still cycle buttons. Wheel events reach open `ModalView` slots via `modal_wheel_delta`. The palette / settings / keybinds overlays don't scroll because their bodies fit.
- **Bracketed paste routes to the top modal's focused field.** With a modal open, `dispatch_modal_event` forwards `Event::Paste` to `Modal::handle_paste` (pop-dispatch-push, like `handle_wheel`/`handle_click`); otherwise it goes to the buffer via `dispatch_paste`. The trait default is a no-op, so button-only modals ignore pastes; **a new text-input modal must override `handle_paste`**. Every text-input modal's `paste()` runs the payload through `ui::sanitize_paste` first — one source of truth that strips control chars (a multi-line clipboard collapses to one line) and caps at `PASTE_CHAR_CAP` — then layers field-specific policy and *mirrors that field's keyboard `Char` arm exactly* (append vs. cursor-insert, focus gating, digits-only, live preview). The search modal is the one field that transforms *before* sanitizing (`search::escape::escape`). Don't flatten in the editor path: `dispatch_paste` keeps newlines because the buffer is multi-line.
- **External-editor flow needs three things, in order.** Before `Command::new($EDITOR).status()`: (1) pause the crossterm read thread, (2) drain the rx channel, (3) suspend the terminal. Skip any and the editor races our read thread for stdin — bytes get split, keystrokes lag, and OSC responses to startup-time queries leak into the buffer. The read thread is poll-based (`event::poll(100ms)`) precisely so a `read_paused: Arc<AtomicBool>` can stop it without interrupting a blocked `read()`. Afterwards `terminal::re_enter(mouse, keyboard_enhancement)` reinstates alt-screen + raw mode + transient features and `Config::load()` re-runs. See `src/app/external_editor.rs`.
- **`Modal::kind` and `Modal::dismissable` are stored as struct fields, not hard-coded.** Every `ModalView` modal sets `kind: ModalKind` and `dismissable: bool` once in `new()`/`from_*()`, and all three read sites — the `ModalView::new(..)` call, the `state.handle_key(..)` call, and the trait methods — read those fields; literals at any of them drift. `dismissable` controls three things together: the rendered `esc` close-hint, the cached `esc_button_rect` for click hit-testing, and whether `Esc`/`n`/`N` fire `ModalResponse::Cancelled`. Bespoke (non-`ModalView`) modals inherit the trait defaults (`Normal` / `true`); don't add no-op overrides.
- **The welcome modal is dismissable only when it wasn't a first run.** Same field-not-literal rule: `WelcomeState::dismissable` is read by the `Esc` arm, by `show_close_hint` in `render` (which populates `esc_button_rect`, so the click path needs no second gate), and by `Modal::dismissable`. `WelcomeModal::from_state` (first run) leaves it `false` — Cancel is replaced by an explicit "Show on next launch" toggle. Every on-demand opening (`Action::OpenWelcome`, the capabilities notice's "Adjust settings") goes through `WelcomeModal::new` + `with_dismissable(true)`, because below truecolor `WelcomeState::new` force-sets images and diagrams to `Never` and `save_outcome` would persist that: without a write-nothing exit, merely looking at the surface from a weaker terminal overwrites settings chosen on a capable one. `WelcomeResponse::Cancel` → a plain `ModalOutcome::Close`, with deliberately no fingerprint seeding.
- **Construct `ModalView` via `ModalView::new(...)`, not a struct literal** — the constructor pre-fills `max_pad_h` to `MAX_PAD_H`; chain `.with_max_pad_h(n)` to override.
- **A prose modal must cap its content width; a tabular one must not.** `ModalView` sizes itself to its longest *unwrapped* body line — right for tables (capability rows, keybindings), wrong for prose, where a wrapped paragraph's natural width is the whole sentence. Chain `.with_max_content_width(PROSE_CONTENT_WIDTH)`, a count of *text* columns, so the modal is that plus `2 * max_pad_h`; it matches the welcome modal's `CONTENT_WIDTH` so both prose surfaces read at the same measure, and is raised to the button-row width before clamping so it can't clip the footer. Don't pre-wrap prose into short `Line`s — `ModalView` wraps and sizes with `wrapped_rows`, so hand-splitting double-wraps at narrow widths.
- **Horizontal padding lives on `ContentSize`, not on `FrameOpts`.** `FrameOpts.content` embeds the same `ContentSize` fed to `centered_rect_for_content`, so the sizing pass and `draw_frame`'s padding can't disagree: set `max_pad_h` once (or take `MAX_PAD_H` via `..Default::default()`) and pass that one value to both. Do NOT reintroduce a parallel `FrameOpts.max_pad_h`. The keybinds overlay raises it to `KEYBINDS_MAX_PAD_H` (8) so the "Already bound to …" string can't reflow its dense table during capture.
- **Preview-mode Ctrl-key allowlist.** `input::mode_handler::default::preview_safe_action` decides which Ctrl-* chords fire in Preview. Overlay openers belong on it — a new modal-opening action must be added, or the chord silently no-ops in Preview.
- **Focus vs. persistent selection.** A modal whose selection persists independent of focus (the export-theme modal's highlighted theme name) uses the three-tier styling in [theming.md](theming.md#focus-vs-persistent-selection) — never `modal_item_selected` for the selected-but-unfocused tier.