edamame 0.1.2

A fast TUI Markdown editor and viewer
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`.
- **Combined view+edit keybindings overlay.** `OpenKeybinds` owns it; there is no separate cheat-sheet variant, and a user binding to the removed `Action::ShowCheatSheet` fails parsing with `KeyMapError::UnknownAction`.
- **A footer wraps; it never clips.** A clipped button is still focusable and still carries a click rect pointing off the frame — the one outcome that lies. `button_row::button_rows` packs at the width it is given; `render_buttons` paints a row per group, separated by a blank (`ROW_SPACING`), since rows stacked tight read as a list rather than as alternatives — 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 both already agree on, and a bespoke overlay asks `button_row::footer_row_count` and gives the button rect *all* the rows left below it — a flat `height: 1` paints only the first row, leaving a wrapped button unpainted while Tab still focuses it. That is every multi-button overlay (search, save-copy, insert-table, keybinds); a **single**-button footer (diff intro, export theme, welcome) always gets exactly 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` (the keybinds overlay raises it to 8) — because a flat `MIN_PAD_H` subtraction overestimates the inner width by up to `2 * (max_pad_h - MIN_PAD_H)` 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 centred 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-centred page (About's art and title column) off-centre by half the difference. `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. The two modals needing it both have a *picture* for part of their body, and both therefore rebuild it each frame; neither may cache it. The About page drops its pod below that width — one ASCII block, so a wrap shears it rather than shortening it — while everything else centres in a narrower column, the version row falls back to the bare `v0.1.1`, and each rotation is padded to the row count the *longest* tagline needs at that wrap width (a fixed two resized the frame mid-rotation once the wrap tightened). The cheat sheet caps its code-block and block-quote washes there, and only those: a wash is a rectangle standing in for a block, so a wrapped one repeats its surface on a second ragged row, while every other row is content and must wrap like any modal body — clipping the whole body instead truncated the syntax examples the sheet exists to show. The cap is `EXAMPLE_INDENT` short of the body width, because that is where the wash *starts*: flush to the right edge it has a margin down one side and none down the other, which reads as crowding the frame whatever padding the modal has.
- **`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. 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` (1024) — 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. A new text-input modal must override `handle_paste`.
- **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. Do NOT pass literals at any of them; they will 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`. Modals not using `ModalView` (palette, settings, keybinds, save_copy, insert_table, theme_picker, export_theme) 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, and there is no prior choice to protect. Every on-demand opening (`Action::OpenWelcome`, the capabilities notice's "Adjust settings") goes through `WelcomeModal::new` + `with_dismissable(true)`, because reopening carries a risk the first run doesn't: below truecolor `WelcomeState::new` force-sets images and diagrams to `Never` and `save_outcome` *persists* that, so 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` (4); 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 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. Read-only overlay openers (`ShowCommandPalette`, `OpenSettings`, `OpenWelcome`, `OpenKeybinds`, `OpenConfigFolder`, `ShowMarkdownCheatSheet`, `SwitchTheme`, `CreateCustomTheme`) belong on it — a new modal-opening action must be added, or the chord silently no-ops in Preview.
- **Focus vs. persistent selection (styling convention).** When a modal carries a *persistent selection* independent of focus — e.g. the export-theme modal's highlighted theme name — use three tiers: focused → `theme.modal_button_focused` (filled, strongest); selected but unfocused → `theme.modal_item_selected_unfocused` (`secondary` **fg** on `surface_elevated`, outlined); neither → `theme.modal_item`. Don't reuse `modal_item_selected` for the middle tier — it is also a filled `primary` bg and collides with the focused affordance. For composite affordances (checkbox glyph + label), apply the unfocused-selection style to the *glyph only*. Ordinary labeled control rows are *not* this case; their focus styling is `controls::control_label_style`. See [`docs/dev/theming.md`](theming.md) §"Focus vs. persistent selection".