//! Chord-action binding table.
//!
//! Stage 1 of the rebindable-keys roadmap: extract every meta- and
//! bund-sub-chord from the hardcoded `match` arms in `app.rs` into
//! a data-driven `KeyBindings` struct. App dispatch becomes a
//! single table lookup followed by a `run_action` switch.
//!
//! ## What's here (Stage 1)
//!
//! * `Action` — one variant per reachable handler. Names are
//! `snake_case` so they serialise to dotted strings in HJSON
//! (`tree.add_chapter`, `bund.run_buffer`, …).
//! * `Scope` — pane filter on each binding entry.
//! * `BindingEntry` — `(chord, action, scope)` triple.
//! * `KeyBindings::defaults()` — produces the canonical table
//! matching today's hardcoded chord layout exactly.
//! * `KeyBindings::resolve_*` — table lookups consulted by
//! `handle_meta_action` / `handle_bund_action`.
//!
//! ## What's not here yet (Stage 2)
//!
//! * `ink.key.*` Bund stdlib for runtime rebinding.
//! * Auto-generated status-bar hint strings.
//! * Migration of F-keys (F1/F3/F4/F5/F6/F7) into the table.
use crossterm::event::KeyEvent;
use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, LazyLock};
use super::focus::Focus;
use super::keymap::KeyChord;
/// Which pane(s) a binding applies in. The first binding whose
/// scope matches the current focus wins, so narrow-scoped entries
/// (`Editor`) MUST come before broad ones (`Any`) in
/// `KeyBindings::defaults()`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Scope {
/// Active in any pane.
Any,
/// Editor pane only.
Editor,
/// Tree pane + the search bar (which lives above the tree).
Tree,
/// AI pane + the AI prompt input line.
Ai,
}
impl Scope {
pub fn matches(self, focus: Focus) -> bool {
match self {
Scope::Any => true,
Scope::Editor => focus == Focus::Editor,
Scope::Tree => matches!(focus, Focus::Tree | Focus::SearchBar),
Scope::Ai => matches!(focus, Focus::Ai | Focus::AiPrompt),
}
}
}
/// Every user-reachable chord-action. New chord features add a
/// variant here + an arm in `App::run_action`. Variant names
/// serialise (via serde) to the canonical dotted form used in
/// HJSON `keys.bindings`.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Action {
// ── Tree pane ─────────────────────────────────────────────
#[serde(rename = "tree.add_book")]
AddBook,
#[serde(rename = "tree.add_chapter")]
AddChapter,
#[serde(rename = "tree.add_subchapter")]
AddSubchapter,
#[serde(rename = "tree.add_paragraph")]
AddParagraph,
#[serde(rename = "tree.delete_node")]
DeleteNode,
#[serde(rename = "tree.morph_type")]
MorphType,
#[serde(rename = "tree.reorder_up")]
ReorderUp,
#[serde(rename = "tree.reorder_down")]
ReorderDown,
// ── Editor pane ───────────────────────────────────────────
#[serde(rename = "editor.save")]
Save,
#[serde(rename = "editor.create_snapshot")]
CreateSnapshot,
#[serde(rename = "editor.cycle_status")]
CycleStatus,
#[serde(rename = "editor.open_function_picker")]
OpenFunctionPicker,
#[serde(rename = "editor.rename_to_first_sentence")]
RenameToFirstSentence,
/// `P` in the editor — context-sensitive: image-picker when
/// the cursor sits inside `#image(...)`, otherwise Places
/// lexicon lookup.
#[serde(rename = "editor.lookup_places_or_image")]
LookupPlacesOrImage,
#[serde(rename = "editor.lookup_characters")]
LookupCharacters,
#[serde(rename = "editor.lookup_notes")]
LookupNotes,
#[serde(rename = "editor.lookup_artefacts")]
LookupArtefacts,
#[serde(rename = "editor.open_quickref")]
OpenQuickref,
// ── Global meta ───────────────────────────────────────────
#[serde(rename = "global.open_credits")]
OpenCredits,
#[serde(rename = "global.open_book_info")]
OpenBookInfo,
#[serde(rename = "global.open_llm_picker")]
OpenLlmPicker,
#[serde(rename = "global.toggle_sound")]
ToggleSound,
#[serde(rename = "global.schedule_assemble")]
ScheduleAssemble,
#[serde(rename = "global.schedule_build")]
ScheduleBuild,
#[serde(rename = "global.schedule_take")]
ScheduleTake,
/// 1.2.6+ — `Ctrl+B Shift+B`. Trigger an immediate project
/// backup, ignoring the recency cooldown the exit hook uses.
/// Honours `backup.wait_for_key_after_backup`.
#[serde(rename = "global.backup_now")]
BackupNow,
#[serde(rename = "global.toggle_typewriter")]
ToggleTypewriter,
#[serde(rename = "global.toggle_ai_fullscreen")]
ToggleAiFullscreen,
#[serde(rename = "global.status_filter_ready")]
StatusFilterReady,
#[serde(rename = "global.status_filter_final")]
StatusFilterFinal,
#[serde(rename = "global.status_filter_third")]
StatusFilterThird,
#[serde(rename = "global.status_filter_second")]
StatusFilterSecond,
#[serde(rename = "global.status_filter_first")]
StatusFilterFirst,
#[serde(rename = "global.status_filter_napkin")]
StatusFilterNapkin,
#[serde(rename = "global.status_filter_none")]
StatusFilterNone,
/// Ctrl+B ] (1.2.5+) — open the tag picker for the currently
/// open paragraph. Inside: A adds a new tag, D deletes a tag
/// project-wide, Space selects, T applies selected tags.
#[serde(rename = "global.tag_paragraph")]
TagParagraph,
/// Ctrl+B } (1.2.5+) — open the search-by-tag picker. Enter
/// on a tag lists paragraphs that carry it, with a filter
/// input; Enter on a paragraph opens it in the editor.
#[serde(rename = "global.tag_search")]
TagSearch,
// ── AI pane ───────────────────────────────────────────────
#[serde(rename = "ai.clear_chat")]
ClearChat,
// ── Bund prefix ───────────────────────────────────────────
#[serde(rename = "bund.run_buffer")]
BundRunBuffer,
#[serde(rename = "bund.new_script")]
BundNewScript,
#[serde(rename = "bund.open_eval_modal")]
BundOpenEvalModal,
/// Ctrl+Z ? — open the script picker. Lists scripts in the
/// cursor's branch; `A` toggles to the `Scripts` system book.
#[serde(rename = "bund.open_script_picker")]
BundOpenScriptPicker,
// ── Top-level (1.2.4+ F-key migration) ────────────────────
/// F1 anywhere — open the Help-book query modal.
#[serde(rename = "help.query")]
HelpQuery,
/// F2 in Tree — rename the cursor's node.
#[serde(rename = "tree.rename")]
RenameNode,
/// F3 in Tree — file picker, import context.
#[serde(rename = "tree.file_picker_import")]
FilePickerTreeImport,
/// F3 in Editor — file picker, "load into buffer" context.
#[serde(rename = "editor.file_picker_load")]
FilePickerEditorLoad,
/// F4 in Editor — toggle split-edit mode.
#[serde(rename = "editor.toggle_split")]
ToggleSplit,
/// Ctrl+F4 in Editor — accept the snapshot pane into the
/// live buffer.
#[serde(rename = "editor.accept_split_snapshot")]
AcceptSplitSnapshot,
/// F6 in Editor — open the snapshot picker.
#[serde(rename = "editor.snapshot_picker")]
OpenSnapshotPicker,
/// F7 in Editor — grammar check the open paragraph.
#[serde(rename = "editor.grammar_check")]
GrammarCheck,
/// F9 anywhere — cycle AI scope mode.
#[serde(rename = "ai.cycle_mode")]
CycleAiMode,
/// F10 anywhere — toggle inference mode (Local ↔ Full).
#[serde(rename = "ai.toggle_inference_mode")]
ToggleInferenceMode,
/// F8 (1.2.6+) — open the floating typst-diagnostics list
/// modal. Lists every parse / semantic diagnostic in the
/// open paragraph with line:col + message; Enter jumps the
/// editor cursor.
#[serde(rename = "editor.diagnostics_list")]
DiagnosticsList,
/// Ctrl+F12 (1.2.6+) — send the typst diagnostic at the
/// cursor (or nearest one, with surrounding context) to
/// the AI pane with an explain-or-fix prompt. Started life
/// on bare F11 but macOS grabs F11 globally (Show Desktop
/// / Mission Control) so the chord never reached the TUI.
#[serde(rename = "editor.explain_diagnostic")]
ExplainDiagnostic,
/// F12 (1.2.6+) — AI critique of the open paragraph. In the
/// editor: "what's weak" prompt. In split-edit (F4) mode:
/// "evaluate changes" prompt. Prompt resolution: Prompts
/// book paragraph → prompts.hjson entry → embedded default.
#[serde(rename = "editor.critique")]
Critique,
// ── View prefix (1.2.4+, default Ctrl+V) ──────────────────
/// Ctrl+V 1 (Editor) — write the open paragraph's live buffer
/// as markdown to cwd.
#[serde(rename = "view.export_markdown_buffer")]
ViewExportMarkdownBuffer,
/// Ctrl+V 2 (Editor) — write the containing subchapter's
/// subtree as markdown to cwd.
#[serde(rename = "view.export_markdown_subchapter")]
ViewExportMarkdownSubchapter,
/// Ctrl+V 1 (Tree) — write the tree-cursor's node + descendants
/// as markdown to cwd.
#[serde(rename = "view.export_markdown_subtree")]
ViewExportMarkdownSubtree,
/// Ctrl+V S — toggle similar-paragraph mode (vector-similarity
/// picker, side-by-side editor).
#[serde(rename = "view.toggle_similar_mode")]
ViewToggleSimilarMode,
/// Ctrl+V G — open the writing-progress modal.
#[serde(rename = "view.open_progress")]
ViewOpenProgress,
/// Ctrl+V T — open the per-paragraph target-words input modal.
#[serde(rename = "view.open_paragraph_target")]
ViewOpenParagraphTarget,
/// Ctrl+V A — switch the tree pane into "select paragraph
/// to link" mode. Enter on a paragraph adds it to the open
/// paragraph's `linked_paragraphs`.
#[serde(rename = "view.add_link")]
ViewAddLink,
/// Ctrl+V I — reverse of `view.add_link`. Tree pane picker;
/// Enter on a paragraph adds the OPEN paragraph to THAT
/// paragraph's outgoing links (creates an incoming link
/// for current).
#[serde(rename = "view.add_incoming_link")]
ViewAddIncomingLink,
/// Ctrl+V L — open the linked-paragraphs floating modal
/// (`D` removes a link).
#[serde(rename = "view.list_links")]
ViewListLinks,
/// Ctrl+V K — open the backlinks floating modal. Reverse of
/// `view.list_links`: shows paragraphs whose
/// `linked_paragraphs` contains the open paragraph.
#[serde(rename = "view.list_backlinks")]
ViewListBacklinks,
/// Ctrl+V B — toggle bookmark on the open paragraph.
#[serde(rename = "view.toggle_bookmark")]
ViewToggleBookmark,
/// Ctrl+V M — open the bookmark picker.
#[serde(rename = "view.list_bookmarks")]
ViewListBookmarks,
/// Ctrl+V P — fuzzy paragraph picker (1.2.4+).
#[serde(rename = "view.fuzzy_paragraph_picker")]
ViewFuzzyParagraphPicker,
/// Ctrl+V Shift+P (1.2.7+) — same fuzzy paragraph
/// picker but entries are sorted by `modified_at` desc.
/// Answers "what did I work on most recently?" without
/// trawling the tree pane.
#[serde(rename = "view.recent_paragraph_picker")]
ViewRecentParagraphPicker,
/// Ctrl+V Shift+U (1.2.8+) — picker over the deleted-
/// paragraph kill-ring. Ctrl+B U restores the most-
/// recent entry; this chord opens a modal to choose
/// any of the (up to 10) buffered recoveries.
#[serde(rename = "view.kill_ring_picker")]
ViewKillRingPicker,
/// Ctrl+V h (1.2.8+) — one-shot report on the open
/// paragraph's "hidden" characters: tab count, trailing-
/// whitespace line count, CR count. Status-bar only;
/// no buffer rewrite. The visual editor overlay is
/// scheduled for 1.2.9 once the column-bookkeeping
/// integrates cleanly with the existing match /
/// lexicon highlight layers.
#[serde(rename = "view.hidden_chars_report")]
ViewHiddenCharsReport,
/// Ctrl+V Shift+S (1.2.8+) — show the cursor row's
/// breadcrumb path on the status bar: "Book ▸ Chapter
/// ▸ Subchapter ▸ Paragraph". Pane-aware: in the tree
/// it walks from the tree cursor; in the editor it
/// walks from the open paragraph.
#[serde(rename = "view.show_breadcrumb")]
ViewShowBreadcrumb,
/// Ctrl+Z o (1.2.8+) — open or close the embedded
/// nushell pane. Closing preserves the engine state
/// (env vars, defs) and the turn buffer for the next
/// open. No-op (with a status hint) when
/// `shell.enabled = false` in HJSON.
#[serde(rename = "bund.open_shell")]
BundOpenShell,
/// Ctrl+Z O (Shift, 1.2.8+) — drop the cached shell
/// engine + turn buffer and open a fresh shell. Use
/// when the env / scope has drifted into a confusing
/// state.
#[serde(rename = "bund.open_shell_fresh")]
BundOpenShellFresh,
/// Ctrl+Z h (1.2.8+) — inside the shell pane, toggle
/// history-selection mode. ↑↓ walks turn-by-turn,
/// `c` copies the highlighted turn's output to the
/// system clipboard, `i` inserts it into the editor
/// wrapped in the configured typst-box template.
/// Re-press exits selection mode.
#[serde(rename = "bund.shell_selection")]
BundShellSelection,
/// Ctrl+B 0 (1.2.8+) — open the project's
/// `inkhaven.hjson` in a full-screen editor modal.
/// Syntax-highlighted via `hjson_highlight`. Save
/// with Ctrl+S; if the saved bytes differ from the
/// loaded bytes, a "restart inkhaven" overlay pops
/// (config changes apply on next launch). Was bound
/// to `|` originally; reassigned to `0` because some
/// terminals don't forward Shift+\ through the
/// chord-prefix state machine.
#[serde(rename = "bund.edit_project_hjson")]
BundEditProjectHjson,
/// Ctrl+B S in editor scope (1.2.9+) — read the open
/// paragraph aloud via the OS TTS engine (`tts-rs`).
/// Replaces the pre-1.2.9 editor-scope `Ctrl+B S =
/// Save` binding which was a redundant duplicate of
/// plain `Ctrl+S`. Gated on
/// `editor.tts.enabled = true` in HJSON; when
/// disabled, fires a "TTS disabled" explanation
/// modal. On platforms where the TTS engine can't
/// initialise (Linux without `speech-dispatcher`,
/// containers, …), a similar explanation modal
/// fires with the engine-level error string.
#[serde(rename = "editor.tts_read_paragraph")]
TtsReadParagraph,
/// Ctrl+B Shift+R (1.2.9+) — save the open paragraph
/// as an audio file via macOS `say -o <path>`.
/// Opens a save-as picker pre-filled with
/// `<project>/audio/<paragraph-slug>.aiff`; Enter
/// commits, Esc cancels. Output format is AIFF
/// (native to `say`); the user can change the
/// extension to coerce a different container — `.m4a`
/// / `.wav` work on recent macOS.
#[serde(rename = "editor.tts_save_as_audio")]
TtsSaveAsAudio,
/// Ctrl+B Shift+G (1.2.9+) — open the writing-
/// streak heatmap modal. Shows the last 91 days
/// of project-wide word deltas as a GitHub-style
/// 13×7 grid, with current streak + longest
/// streak + monthly totals at the bottom. Esc
/// closes.
#[serde(rename = "view.open_writing_streak_heatmap")]
OpenWritingStreakHeatmap,
/// Ctrl+B < (1.2.9+) — jump the editor cursor to the
/// previous scene-break line in the open paragraph.
/// A "scene break" is a typographic divider line
/// like `* * *`, `***`, `---`, `___`, `###`, `~~~`,
/// or a lone `§`. No match → status warns "no scene
/// break above" and the cursor doesn't move.
#[serde(rename = "editor.scene_break_prev")]
SceneBreakPrev,
/// Ctrl+B > (1.2.9+) — jump to the next scene-break
/// line. Same detector as `SceneBreakPrev`.
#[serde(rename = "editor.scene_break_next")]
SceneBreakNext,
/// Ctrl+B Shift+F (1.2.9+) — toggle the inline
/// style-warning overlays (filter words today;
/// repeated phrases / show-don't-tell / etc. as
/// they land). Session-local toggle on top of the
/// HJSON `editor.style_warnings.enabled` master
/// switch — flip the chord during a writing
/// session without rewriting config.
#[serde(rename = "editor.toggle_style_warnings")]
ToggleStyleWarnings,
/// Ctrl+B Shift+T (1.2.9+) — AI-driven show-
/// don't-tell scan of the open paragraph. Sends
/// the paragraph to the configured LLM with a
/// system prompt asking for telling passages and
/// suggested rewrites. The response streams
/// into the AI pane. The mnemonic is `T` for
/// "tell". Complements the always-on regex
/// overlay (`editor.style_warnings.show_dont_tell`)
/// with deeper analysis.
#[serde(rename = "ai.analyse_show_dont_tell")]
AnalyseShowDontTell,
/// Ctrl+B Shift+H (1.2.9+) — open the sentence-
/// rhythm gauge modal for the open paragraph.
/// Splits prose into sentences, computes word-
/// count mean / stdev / coefficient of variation
/// (CV), maps CV to a verdict (Monotone / Steady
/// / Varied / Choppy), and shows a per-sentence
/// bar chart + outlier callouts. The H is for
/// "heartbeat" — the felt rhythm of the prose.
#[serde(rename = "view.open_sentence_rhythm")]
OpenSentenceRhythm,
/// Ctrl+B Shift+P (1.2.9+) — toggle the POV /
/// character chip on the status bar. Session-local
/// override on top of `editor.pov_chip_enabled` in
/// HJSON. When the chip is on, the status bar shows
/// the most-mentioned character in the open
/// paragraph (the heuristic POV character) + up to
/// three additional named characters present.
#[serde(rename = "view.toggle_pov_chip")]
TogglePovChip,
/// Ctrl+B Shift+L (1.2.9+) — open the project-wide
/// concordance modal. Lists every distinct lexical
/// stem in the project with its total count + KWIC
/// samples. Stop-words and digits are filtered out
/// so the list surfaces the words actually carrying
/// the prose's weight. Type to filter, `s` to
/// toggle sort (count ↔ alphabetical), Esc closes.
#[serde(rename = "view.open_concordance")]
OpenConcordance,
/// Ctrl+V R (1.2.5+) — render the open paragraph in-process
/// via typst-render and float a PNG preview on top of the
/// editor. `Esc` closes, `S` opens a save-as picker for the
/// full-DPI PNG.
#[serde(rename = "view.render_paragraph")]
ViewRenderParagraph,
/// Ctrl+V N (1.2.5+) — jump the editor cursor to the next
/// typst diagnostic in the open buffer (parse or semantic).
/// Wraps at the end. No-op when the diagnostic cache is
/// empty.
#[serde(rename = "view.next_diagnostic")]
ViewNextDiagnostic,
/// Ctrl+V Shift+W (1.2.5+) — story view of the current
/// book: book at the centre, every chapter / subchapter /
/// paragraph + paragraph links + lexicon mentions on
/// concentric rings. Rendered to PNG and floated on top
/// of the editor; `S` saves, `Esc` closes.
#[serde(rename = "view.story_graph")]
ViewStoryGraph,
/// Ctrl+V w (1.2.6+) — paragraph mini story view: the
/// open paragraph at the centre, its paragraph link neighbours
/// (one hop out + one hop in) on the first ring, and any
/// Characters / Places / Artefacts it mentions on the
/// outer ring. Same render pipeline + save flow as the
/// book view.
#[serde(rename = "view.story_graph_paragraph")]
ViewStoryGraphParagraph,
/// Ctrl+V e (1.2.6+) — open the timeline event picker.
/// Lists every event in the project chronologically;
/// the user can filter by track, jump to events, or
/// close with Esc. Requires `timeline.enabled: true` in
/// HJSON.
#[serde(rename = "view.event_picker")]
ViewEventPicker,
/// Ctrl+V Shift+E (1.2.6+) — open the timeline view AND
/// immediately trigger the new-event prompt, so a fresh
/// project can add its first event without going through
/// the CLI's `inkhaven event add`. Honours
/// `timeline.enabled`.
#[serde(rename = "view.new_event_prompt")]
ViewNewEventPrompt,
/// Ctrl+Shift+M (1.2.7+) — toggle TUI mouse capture.
/// Default ON (TUI sees click-to-focus, scroll wheel,
/// etc.). Toggle OFF to let the terminal handle mouse
/// natively: drag-to-select inside the editor / AI
/// pane, system-clipboard copy via Cmd+C (macOS) or
/// Ctrl+Shift+C (Linux/Windows). Status reports the
/// new state.
#[serde(rename = "global.toggle_mouse_capture")]
ToggleMouseCapture,
/// Alt+Left (1.2.7+) — browser-style "back" through
/// the visited-paragraph history. Doesn't push to the
/// history itself (so back/forward is reversible).
#[serde(rename = "global.visited_back")]
VisitedBack,
/// Alt+Right (1.2.7+) — browser-style "forward". Only
/// active when the user has gone back at least once.
#[serde(rename = "global.visited_forward")]
VisitedForward,
/// Ctrl+B U (1.2.7+) — undo the most recent paragraph
/// delete. Single-slot kill-ring; content + tags +
/// linked_paragraphs + event data restored. The
/// restored paragraph gets a NEW uuid — cross-refs
/// from elsewhere that pointed at the deleted id stay
/// broken (status hint flags this on each restore).
/// Only paragraph deletes are recoverable; branch
/// (chapter / book) deletes can't be undone.
#[serde(rename = "global.undo_last_delete")]
UndoLastDelete,
/// Ctrl+V Shift+I (1.2.6+) — open a one-line edit prompt for
/// the open event paragraph's start / end / track (pipe-
/// separated). Precision is re-derived from the start
/// string each commit. No-op when the open paragraph
/// isn't an event.
#[serde(rename = "view.edit_event_metadata")]
ViewEditEventMetadata,
/// Ctrl+V t (1.2.6+) — open the swim-lane timeline view
/// scoped to the current paragraph's nearest Subchapter
/// (or Chapter, or Book). Inside the modal:
/// u/U up-scope · d/D down-scope · b/B book · p/P project
/// ←/→ scroll · +/- zoom · Tab cycle track · Enter open
/// y critique scope · Y all-tracks · Ctrl+Y book-wide
/// Requires `timeline.enabled: true` in HJSON.
#[serde(rename = "view.timeline")]
ViewTimeline,
/// Explicit "this chord does nothing" — overlay entries can
/// set `action: "none"` to disable a default chord.
#[serde(rename = "none")]
None,
/// Runtime-only: a Bund lambda registered under the given
/// name via `ink.key.bind_lambda`. Dispatch routes to
/// `scripting::hooks::fire(name, vec![])`. `#[serde(skip)]` —
/// these can't appear in HJSON; they live only in memory and
/// vanish on process exit.
#[serde(skip)]
BundLambda(Arc<str>),
}
impl Action {
/// Short label used in the auto-generated status-bar meta
/// hint ("add chapter", "morph-type", …). Returns `""` for
/// `None` and the lambda name for `BundLambda`.
pub fn label(&self) -> String {
match self {
Action::AddBook => "add book".into(),
Action::AddChapter => "add chapter".into(),
Action::AddSubchapter => "add subchapter".into(),
Action::AddParagraph => "add paragraph".into(),
Action::DeleteNode => "delete".into(),
Action::MorphType => "morph-type".into(),
Action::ReorderUp => "↑ reorder".into(),
Action::ReorderDown => "↓ reorder".into(),
Action::Save => "save".into(),
Action::CreateSnapshot => "snapshot".into(),
Action::CycleStatus => "status".into(),
Action::OpenFunctionPicker => "func".into(),
Action::RenameToFirstSentence => "retitle".into(),
Action::LookupPlacesOrImage => "place/pic".into(),
Action::LookupCharacters => "character".into(),
Action::LookupNotes => "notes".into(),
Action::LookupArtefacts => "artefacts".into(),
Action::OpenQuickref => "help".into(),
Action::OpenCredits => "credits".into(),
Action::OpenBookInfo => "info".into(),
Action::OpenLlmPicker => "LLM".into(),
Action::ToggleSound => "sound".into(),
Action::ScheduleAssemble => "assemble".into(),
Action::ScheduleBuild => "build".into(),
Action::ScheduleTake => "take".into(),
Action::BackupNow => "backup".into(),
Action::ToggleTypewriter => "focus mode".into(),
Action::ToggleAiFullscreen => "AI-full".into(),
Action::StatusFilterReady => "Ready".into(),
Action::StatusFilterFinal => "Final".into(),
Action::StatusFilterThird => "Third".into(),
Action::StatusFilterSecond => "Second".into(),
Action::StatusFilterFirst => "First".into(),
Action::StatusFilterNapkin => "Napkin".into(),
Action::StatusFilterNone => "None".into(),
Action::TagParagraph => "tag ¶".into(),
Action::TagSearch => "tag search".into(),
Action::ClearChat => "clear chat".into(),
Action::BundRunBuffer => "run buffer".into(),
Action::BundNewScript => "new script".into(),
Action::BundOpenEvalModal => "eval".into(),
Action::BundOpenScriptPicker => "pick script".into(),
Action::HelpQuery => "help".into(),
Action::RenameNode => "rename".into(),
Action::FilePickerTreeImport => "file picker".into(),
Action::FilePickerEditorLoad => "load file".into(),
Action::ToggleSplit => "split".into(),
Action::AcceptSplitSnapshot => "accept snap".into(),
Action::OpenSnapshotPicker => "snapshots".into(),
Action::GrammarCheck => "grammar".into(),
Action::DiagnosticsList => "diags".into(),
Action::ExplainDiagnostic => "explain diag".into(),
Action::Critique => "critique".into(),
Action::CycleAiMode => "AI mode".into(),
Action::ToggleInferenceMode => "infer mode".into(),
Action::ViewExportMarkdownBuffer => "md buffer".into(),
Action::ViewExportMarkdownSubchapter => "md subchap".into(),
Action::ViewExportMarkdownSubtree => "md subtree".into(),
Action::ViewToggleSimilarMode => "similar".into(),
Action::ViewOpenProgress => "progress".into(),
Action::ViewOpenParagraphTarget => "para target".into(),
Action::ViewAddLink => "add link".into(),
Action::ViewAddIncomingLink => "add ← link".into(),
Action::ViewListLinks => "list links".into(),
Action::ViewListBacklinks => "backlinks".into(),
Action::ViewToggleBookmark => "bookmark".into(),
Action::ViewListBookmarks => "bookmarks".into(),
Action::ViewFuzzyParagraphPicker => "find ¶".into(),
Action::ViewRecentParagraphPicker => "recent ¶".into(),
Action::ViewKillRingPicker => "kill-ring".into(),
Action::ViewHiddenCharsReport => "hidden chars".into(),
Action::ViewShowBreadcrumb => "breadcrumb".into(),
Action::BundOpenShell => "shell".into(),
Action::BundOpenShellFresh => "shell fresh".into(),
Action::BundShellSelection => "shell select".into(),
Action::BundEditProjectHjson => "edit hjson".into(),
Action::TtsReadParagraph => "read aloud".into(),
Action::TtsSaveAsAudio => "save audio".into(),
Action::OpenWritingStreakHeatmap => "streak".into(),
Action::SceneBreakPrev => "prev scene break".into(),
Action::SceneBreakNext => "next scene break".into(),
Action::ToggleStyleWarnings => "style warnings".into(),
Action::OpenConcordance => "concordance".into(),
Action::TogglePovChip => "pov chip".into(),
Action::OpenSentenceRhythm => "rhythm".into(),
Action::AnalyseShowDontTell => "show↛tell AI".into(),
Action::ViewRenderParagraph => "render ¶".into(),
Action::ViewNextDiagnostic => "next diag".into(),
Action::ViewStoryGraph => "story view".into(),
Action::ViewStoryGraphParagraph => "story view (¶)".into(),
Action::ViewEventPicker => "events".into(),
Action::ViewNewEventPrompt => "new event".into(),
Action::ToggleMouseCapture => "mouse".into(),
Action::VisitedBack => "← back".into(),
Action::VisitedForward => "fwd →".into(),
Action::UndoLastDelete => "undo del".into(),
Action::ViewEditEventMetadata => "edit event".into(),
Action::ViewTimeline => "timeline".into(),
Action::None => String::new(),
Action::BundLambda(name) => format!("λ {name}"),
}
}
/// Long, user-friendly description used by Ctrl+B H (the
/// quick-reference panel). Where `label()` is squeezed into
/// the status-bar hint and is therefore terse to the point of
/// cryptic, this is a full sentence aimed at someone reading
/// the panel for the first time. Returns `""` for `None` and
/// a generic "user-bound Bund lambda" line for `BundLambda`.
pub fn description(&self) -> String {
match self {
// ── Tree ──────────────────────────────────────────
Action::AddBook => "Add a new top-level Book to the project.".into(),
Action::AddChapter => "Add a Chapter under the current branch.".into(),
Action::AddSubchapter =>
"Add a Subchapter under the current chapter / subchapter.".into(),
Action::AddParagraph =>
"Add a Paragraph leaf under the current branch (typst content).".into(),
Action::DeleteNode =>
"Delete the node under the tree cursor (asks for confirmation).".into(),
Action::MorphType =>
"Cycle the selected leaf's flavour: Paragraph(typst) → Paragraph(hjson) → Script(bund).".into(),
Action::ReorderUp =>
"Move the current node up among its siblings.".into(),
Action::ReorderDown =>
"Move the current node down among its siblings.".into(),
// ── Editor / save / snapshots ─────────────────────
Action::Save =>
"Save the open paragraph to disk (autosave also fires on idle).".into(),
Action::CreateSnapshot =>
"Snapshot the open paragraph (history kept under F6 picker).".into(),
Action::CycleStatus =>
"Cycle the open paragraph's status: None → Napkin → First → Second → Third → Final → Ready.".into(),
Action::OpenFunctionPicker =>
"Open the Typst function picker — type to filter, Enter inserts #name(…).".into(),
Action::RenameToFirstSentence =>
"Rename the open paragraph using its first sentence as the new title.".into(),
Action::LookupPlacesOrImage =>
"Inside #image(\"…\"): pick a sibling image. Otherwise run a Places RAG over the selection.".into(),
Action::LookupCharacters =>
"Character RAG — selection is queried against the Characters book, answer streams in AI pane.".into(),
Action::LookupNotes =>
"Notes RAG — selection is queried against the Notes book, answer streams in AI pane.".into(),
Action::LookupArtefacts =>
"Artefacts RAG — selection is queried against the Artefacts book, answer streams in AI pane.".into(),
Action::OpenQuickref =>
"Open this Quick reference panel (live keymap + static cheatsheet).".into(),
// ── Global / panels ───────────────────────────────
Action::OpenCredits =>
"Show inkhaven version, author, and bundled-component credits.".into(),
Action::OpenBookInfo =>
"Open the current book's info panel: paths, stats, PDF status.".into(),
Action::OpenLlmPicker =>
"Switch the active LLM provider — choice is persisted to inkhaven.hjson.".into(),
Action::ToggleSound =>
"Toggle typewriter SFX (Enter / focus-out clicks). Choice is persisted to inkhaven.hjson.".into(),
Action::ScheduleAssemble =>
"Book assembly — emit a typst-compilable tree under the artefacts dir.".into(),
Action::ScheduleBuild =>
"Build the book — assemble + run `typst compile` (PDF lands in artefacts dir).".into(),
Action::ScheduleTake =>
"Take the book — build then copy the PDF (and any configured extras) into the launch cwd.".into(),
Action::BackupNow =>
"Run a project backup now (Ctrl+B Shift+B). Always fires — ignores the exit-hook recency cooldown.".into(),
Action::ToggleTypewriter =>
"Toggle distraction-free / focus mode — hides every other pane (Tree, AI, Search, AI prompt) and gives the editor the full window. Re-press to restore the four-pane layout. Internally still called \"typewriter mode\" in some log strings + the HJSON config field; the chord serde key is `global.toggle_typewriter` for backward-compat.".into(),
Action::ToggleAiFullscreen =>
"Toggle full-screen AI mode — AI pane | chat history + AI prompt.".into(),
Action::StatusFilterReady =>
"Filter the tree to paragraphs marked Ready under the cursor.".into(),
Action::StatusFilterFinal =>
"Filter the tree to paragraphs marked Final under the cursor.".into(),
Action::StatusFilterThird =>
"Filter the tree to paragraphs marked Third under the cursor.".into(),
Action::StatusFilterSecond =>
"Filter the tree to paragraphs marked Second under the cursor.".into(),
Action::StatusFilterFirst =>
"Filter the tree to paragraphs marked First under the cursor.".into(),
Action::StatusFilterNapkin =>
"Filter the tree to paragraphs marked Napkin under the cursor.".into(),
Action::StatusFilterNone =>
"Filter the tree to paragraphs with no status under the cursor.".into(),
Action::TagParagraph =>
"Open the tag picker scoped to the open paragraph: Space selects, T applies selected tags, A adds a new tag (prompt), D deletes a tag project-wide.".into(),
Action::TagSearch =>
"Open the search-by-tag picker. Enter on a tag lists paragraphs that carry it (with a filter input); Enter on a paragraph opens it in the editor.".into(),
// ── AI ────────────────────────────────────────────
Action::ClearChat =>
"Clear the chat history and any in-flight inference for a fresh AI session.".into(),
// ── Bund prefix ───────────────────────────────────
Action::BundRunBuffer =>
"Evaluate the currently-open .bund script against Adam (Bund VM).".into(),
Action::BundNewScript =>
"Add a new Bund script under the Scripts system book.".into(),
Action::BundOpenEvalModal =>
"Open the one-shot Bund eval modal — type an expression, see its result in the status bar.".into(),
Action::BundOpenScriptPicker =>
"Open the script picker — list scripts in the current branch (A toggles to Scripts book), Enter runs.".into(),
// ── Top-level F-keys (1.2.4+) ──────────────────────
Action::HelpQuery =>
"Open the Help-book RAG query modal — natural-language question against the Help book.".into(),
Action::RenameNode =>
"Rename the tree-cursor's node (paragraphs also rename their .typ on disk).".into(),
Action::FilePickerTreeImport =>
"Open the file picker in import mode — a file becomes a new paragraph, a directory recursively imports as branches.".into(),
Action::FilePickerEditorLoad =>
"Open the file picker in load mode — replaces the open paragraph's buffer with the picked file's content.".into(),
Action::ToggleSplit =>
"Toggle split-edit mode — captures the current buffer as a read-only lower pane.".into(),
Action::AcceptSplitSnapshot =>
"Replace the live buffer with the split's captured snapshot, exit split, mark dirty.".into(),
Action::OpenSnapshotPicker =>
"Open the snapshot picker for the current paragraph (↑↓ navigate · Enter loads · V diff · D delete).".into(),
Action::GrammarCheck =>
"Grammar-check the open paragraph — runs the configured F7 prompt against the AI, applies via `g` in the AI pane.".into(),
Action::DiagnosticsList =>
"Open the floating typst-diagnostics list. Enter on a row jumps the editor cursor to that diagnostic.".into(),
Action::ExplainDiagnostic =>
"Send the typst diagnostic at the cursor (with surrounding context lines) to the AI pane with the configured explain-or-fix prompt.".into(),
Action::Critique =>
"AI critique of the open paragraph. In edit mode: 'what's weak' prompt. In split-edit mode: 'evaluate changes' prompt. Prompt resolution: Prompts book > prompts.hjson > embedded default.".into(),
Action::CycleAiMode =>
"Cycle AI scope: None → Selection → Paragraph → Subchapter → Chapter → Book → None.".into(),
Action::ToggleInferenceMode =>
"Toggle inference mode: Local-only RAG ↔ Full general knowledge (Help is pinned to Local regardless).".into(),
// ── View prefix ────────────────────────────────────
Action::ViewExportMarkdownBuffer =>
"Export the open paragraph's live buffer (including unsaved edits) as markdown to the launch cwd.".into(),
Action::ViewExportMarkdownSubchapter =>
"Export the containing subchapter's subtree as markdown to the launch cwd.".into(),
Action::ViewExportMarkdownSubtree =>
"Export the tree-cursor's node and all descendants as markdown to the launch cwd.".into(),
Action::ViewToggleSimilarMode =>
"Toggle similar-paragraph mode — vector-similarity picker; selecting a hit opens a second editor side-by-side. Re-press to save both and exit.".into(),
Action::ViewOpenProgress =>
"Open the writing-progress modal (today / streak / per-book pace / 30-day sparkline / status-ladder counts).".into(),
Action::ViewOpenParagraphTarget =>
"Set or clear the open paragraph's word-count goal. Saves that cross the target auto-promote status one ladder step.".into(),
Action::ViewAddLink =>
"Add a linked paragraph — tree pane switches to `select paragraph to link` mode; Enter links, Esc cancels. Stored as metadata, never embedded in typst source.".into(),
Action::ViewAddIncomingLink =>
"Add an incoming link — tree pane picker; Enter on a paragraph adds the OPEN paragraph to THAT paragraph's outgoing links (reverse of Ctrl+V A).".into(),
Action::ViewListLinks =>
"Open the linked-paragraphs modal — list outgoing paragraph links for the open paragraph; press D on a row to remove.".into(),
Action::ViewListBacklinks =>
"Open the backlinks modal — list paragraphs that link to the open paragraph (reverse of Ctrl+V L). Enter opens; D removes the source's outgoing link to current.".into(),
Action::ViewToggleBookmark =>
"Toggle bookmark on the open paragraph. Bookmarks are surfaced by the Ctrl+V M picker; survive restart via metadata.".into(),
Action::ViewListBookmarks =>
"Open the bookmark picker — every bookmarked paragraph in the project. Enter opens; D removes the bookmark.".into(),
Action::ViewFuzzyParagraphPicker =>
"Fuzzy paragraph picker — type any substring of the title or slug path, Enter opens the highlighted hit.".into(),
Action::ViewRecentParagraphPicker =>
"Recent paragraph picker (1.2.7+) — same fuzzy picker as Ctrl+V P but sorted by modified_at desc. Answers \"what did I work on most recently?\" without trawling the tree.".into(),
Action::ViewKillRingPicker =>
"Kill-ring picker (1.2.8+) — list of recently-deleted paragraphs (up to 10). Enter restores the highlighted entry at its original position; Esc cancels. Ctrl+B U alone restores the most-recent without opening the picker.".into(),
Action::ViewHiddenCharsReport =>
"Hidden-character report (1.2.8+) — status-bar summary of the open paragraph's tabs / trailing-whitespace lines / CR characters. Useful for spotting import noise (Scrivener / web paste). Visual editor overlay scheduled for 1.2.9.".into(),
Action::ViewShowBreadcrumb =>
"Show breadcrumb (1.2.8+) — print the hierarchy path from project root to the cursor on the status bar (Book ▸ Chapter ▸ Subchapter ▸ Paragraph). Pane-aware: in tree walks from the tree cursor, in editor walks from the open paragraph.".into(),
Action::BundOpenShell =>
"Open / close the embedded nushell pane (1.2.8+). Floating fullscreen; engine state and turn buffer preserved across close+reopen. No-op when shell.enabled = false in HJSON.".into(),
Action::BundOpenShellFresh =>
"Drop the cached shell engine + turn buffer and open a fresh shell (1.2.8+). Use when env / scope has drifted into a confusing state.".into(),
Action::BundShellSelection =>
"Inside the shell pane, toggle history-selection mode (1.2.8+) — ↑↓ walks turn-by-turn, `c` copies output to clipboard, `i` inserts wrapped in the configured typst-box template. Re-press exits.".into(),
Action::BundEditProjectHjson =>
"Open `<project>/inkhaven.hjson` in a full-screen editor (1.2.8+, Ctrl+B 0). Syntax-highlighted via the hand-rolled HJSON lexer. Ctrl+S saves; when saved bytes differ from the loaded bytes, a restart-required overlay pops up (config applies on next launch). Esc closes; unsaved-edit warnings fire on close.".into(),
Action::TtsReadParagraph =>
"Read the open paragraph aloud via the OS TTS engine (1.2.9+, Ctrl+B S in editor scope). Cross-platform via `tts-rs`: AVFoundation on macOS, SAPI / WinRT on Windows, Speech Dispatcher on Linux. Gated by `editor.tts.enabled = true` in HJSON; default is off. Default voice is `Milena` (Russian female; ships free with macOS + Windows). When TTS is disabled, or the engine fails to initialise (Linux without speech-dispatcher, missing voices, etc.), a friendly explanation modal fires instead.".into(),
Action::TtsSaveAsAudio =>
"Save the open paragraph as an audio file via macOS `say -o <path>` (1.2.9+, Ctrl+B Shift+R). Opens a path picker pre-filled with `<project>/audio/<paragraph-slug>.aiff`; Enter commits, Esc cancels. Output is AIFF by default; coerce another format with the file extension (`.m4a`, `.wav` work on macOS 13+). Same voice + rate as the configured chord-driven TTS. macOS-only; non-macOS hosts surface the same `TTS unavailable` modal as Ctrl+B S.".into(),
Action::OpenWritingStreakHeatmap =>
"Open the writing-streak heatmap modal (1.2.9+, Ctrl+B Shift+G). GitHub-style 13×7 grid of the last 91 days of project-wide word deltas, plus current streak + longest streak in the window + per-month totals. Data comes from the existing progress store (the same source feeding the startup pulse splash + Ctrl+V G modal). Esc closes.".into(),
Action::SceneBreakPrev =>
"Jump editor cursor to the previous scene-break line (1.2.9+, Ctrl+B <). Scene breaks are typographic divider lines: `* * *`, `***`, `---`, `___`, `###`, `~~~`, or a lone `§`. Detection is hand-rolled — any line consisting only of 3+ copies of `*`/`-`/`_`/`~`/`#` (optionally space-separated) counts, plus `§` alone. Useful for navigating multi-scene paragraphs in a single pass.".into(),
Action::SceneBreakNext =>
"Jump editor cursor to the next scene-break line (1.2.9+, Ctrl+B >). Same detector as `SceneBreakPrev`.".into(),
Action::ToggleStyleWarnings =>
"Toggle the inline style-warning overlays (1.2.9+, Ctrl+B Shift+F). Currently flags filter words — intensifier crutches like `just`, `really`, `very`, `просто`, `очень` — drawn in amber + underlined. Session-local override on top of `editor.style_warnings.enabled` in HJSON. Per-language defaults ship for English, Russian, French, German, Spanish; the active list is keyed by the project's top-level `language` field. Add more via `editor.style_warnings.filter_words.extra_words`. Repeated-phrase / show-don't-tell / sentence-rhythm detectors will share this toggle as they land.".into(),
Action::OpenConcordance =>
"Open the project-wide concordance modal (1.2.9+, Ctrl+B Shift+L). Lists every distinct lexical stem in the project with its total count plus up to three KWIC samples. Stop-words, single-character tokens, and pure-digit runs are filtered out so the list surfaces the words actually carrying the prose's weight. Multilingual via the same Snowball stemmer + stop-list plumbing as the repeated-phrase detector — `language` in HJSON drives the algorithm choice. Type to filter (substring match); s toggles sort (count ↔ alphabetical); Esc closes.".into(),
Action::TogglePovChip =>
"Toggle the POV / character chip on the status bar (1.2.9+, Ctrl+B Shift+P). When enabled, the status bar shows the most-mentioned character in the open paragraph (the heuristic POV character) plus up to three additional named characters present. Driven by the project's existing `characters` lexicon — no separate tagging needed. Ties broken by first-mention order. Session-local override on top of `editor.pov_chip_enabled` in HJSON.".into(),
Action::OpenSentenceRhythm =>
"Open the sentence-rhythm gauge modal for the open paragraph (1.2.9+, Ctrl+B Shift+H). Splits prose into sentences (hand-rolled walker with abbreviation suppression), tallies word counts, computes mean / stdev / coefficient of variation (CV), and maps CV to a verdict: Monotone (CV < 0.25 — drones), Steady (0.25-0.45 — workable), Varied (0.45-0.80 — strong prose rhythm), Choppy (≥ 0.80 — fragments + long sentences mixed). Shows a per-sentence bar list and the three shortest + three longest outliers. Mnemonic: H for heartbeat — the felt rhythm of the prose.".into(),
Action::AnalyseShowDontTell =>
"AI-driven show-don't-tell scan of the open paragraph (1.2.9+, Ctrl+B Shift+T). Sends the paragraph to the configured LLM with a system prompt asking for telling passages plus suggested rewrites. The response streams into the AI pane. Complements the always-on regex overlay (`editor.style_warnings.show_dont_tell`) with deeper analysis — the regex catches the obvious 2-grams (`was angry`, `realised`); the AI scan catches subtler instances and proposes alternatives. Mnemonic: T for tell.".into(),
Action::ViewRenderParagraph =>
"Render the open paragraph in-process and float the PNG preview on top of the editor. Esc closes; S opens a save-as picker for the full-DPI PNG.".into(),
Action::ViewNextDiagnostic =>
"Jump the editor cursor to the next typst diagnostic (parse or semantic) in the open buffer. Wraps around at the end; no-op when there are no diagnostics.".into(),
Action::ViewStoryGraph =>
"Story view of the current user book — book at the centre, every chapter / subchapter / paragraph + paragraph links + lexicon mentions on concentric rings. Float a PNG on top of the editor; S saves, Esc closes.".into(),
Action::ViewStoryGraphParagraph =>
"Paragraph mini story view — the open paragraph at the centre, its paragraph link neighbours (one hop out + one hop in) on the first ring, and any Characters / Places / Artefacts it mentions on the outer ring. Same render + save flow as the book view.".into(),
Action::ViewEventPicker =>
"Open the timeline event picker (1.2.6+). Lists every event in the project sorted by start time; Enter jumps to the event paragraph. Requires `timeline.enabled: true` in HJSON.".into(),
Action::ViewNewEventPrompt =>
"Open the swim-lane timeline view and immediately prompt for a new event title (1.2.6+). Same flow as opening the timeline then pressing `n`. Requires `timeline.enabled: true`.".into(),
Action::ToggleMouseCapture =>
"Toggle TUI mouse capture (1.2.7+). Default ON. When OFF, the terminal handles mouse natively: drag-to-select text in the editor / AI pane, system-clipboard copy via Cmd+C (macOS) or Ctrl+Shift+C (Linux/Windows). Toggle back to re-enable click-to-focus + scroll-wheel inside the TUI.".into(),
Action::VisitedBack =>
"Browser-style back (1.2.7+) — re-open the previously-visited paragraph. Default chord: Alt+Left. History persists across sessions in .session.json.".into(),
Action::VisitedForward =>
"Browser-style forward (1.2.7+) — re-open the next paragraph in the visit history. Default chord: Alt+Right. Only active after at least one back-press.".into(),
Action::UndoLastDelete =>
"Undo the most-recent paragraph delete (1.2.7+) — single-slot kill-ring. Restores content + tags + linked_paragraphs + event data, but the restored ¶ gets a NEW uuid so paragraph links from elsewhere stay broken. Branch deletes (chapter / book) can't be undone. Default chord: Ctrl+B U.".into(),
Action::ViewEditEventMetadata =>
"Edit the open event paragraph's start / end / track (pipe-separated, 1.2.6+). Pre-fills with current values; empty middle = no end; empty trailing = drop track. Precision re-derived from start on commit. No-op when the open paragraph isn't an event.".into(),
Action::ViewTimeline =>
"Open the swim-lane timeline view (1.2.6+). Scope-aware: anchors to the current paragraph's nearest Subchapter / Chapter / Book by default. Inside: u/U up-scope, d/D down-scope picker, b/B book, p/P project; ←/→ scroll, +/- zoom, Tab cycle track, Enter open event. Requires `timeline.enabled: true`.".into(),
Action::None => String::new(),
Action::BundLambda(name) =>
format!("User-bound Bund lambda `{name}` (registered via ink.key.bind_lambda)."),
}
}
}
#[derive(Debug, Clone)]
pub struct BindingEntry {
pub chord: KeyChord,
pub action: Action,
pub scope: Scope,
}
/// Live binding table. Held in the process-wide `ACTIVE` slot
/// and consulted on every meta- / bund-sub-chord dispatch.
/// `ink.key.*` stdlib words mutate the same struct under the
/// shared RwLock.
#[derive(Debug, Clone)]
pub struct KeyBindings {
/// Prefix chord that gates the meta sub-chord table (default
/// `Ctrl+B`). Stored here so `ink.key.*` stdlib words can
/// parse `"Ctrl+b m"` shorthand without taking a separate
/// dependency on the App.
pub meta_prefix: KeyChord,
/// Same for the Bund sub-chord table (default `Ctrl+Z`).
/// `None` when the user disabled it via empty config.
pub bund_prefix: Option<KeyChord>,
/// View-prefix chord (1.2.4+, default `Ctrl+V`). Gates the
/// markdown-export / similar-mode / progress / paragraph-target
/// sub-chords. `None` disables the layer entirely.
pub view_prefix: Option<KeyChord>,
pub meta_sub: Vec<BindingEntry>,
pub bund_sub: Vec<BindingEntry>,
pub view_sub: Vec<BindingEntry>,
/// Top-level (no-prefix) chords. 1.2.4+ home for the F-keys
/// that used to be hardcoded in `handle_key`. Single-token
/// chord strings in HJSON `keys.bindings` (e.g. `"F1"`,
/// `"Shift+F4"`) route here.
pub top_level: Vec<BindingEntry>,
}
impl Default for KeyBindings {
fn default() -> Self {
Self::defaults()
}
}
impl KeyBindings {
/// The canonical chord layout — must reproduce the behaviour
/// of the hardcoded match arms `app.rs` had before Stage 1.
/// Narrow-scoped entries come BEFORE broad ones (`Any`) so
/// pane-specific bindings beat global ones when both match.
pub fn defaults() -> Self {
Self {
meta_prefix: KeyChord::parse("Ctrl+b").expect("default meta_prefix"),
bund_prefix: Some(KeyChord::parse("Ctrl+z").expect("default bund_prefix")),
view_prefix: Some(KeyChord::parse("Ctrl+v").expect("default view_prefix")),
meta_sub: vec![
// ── Tree pane ─────────────────────────────────
entry("c", Action::AddChapter, Scope::Tree),
entry("s", Action::AddSubchapter, Scope::Tree),
entry("p", Action::AddParagraph, Scope::Tree),
entry("d", Action::DeleteNode, Scope::Tree),
entry("m", Action::MorphType, Scope::Tree),
entry("Up", Action::ReorderUp, Scope::Tree),
entry("Down", Action::ReorderDown, Scope::Tree),
// Reorder aliases used in the old keymap.
entry("u", Action::ReorderUp, Scope::Tree),
entry("j", Action::ReorderDown, Scope::Tree),
// ── Editor pane ───────────────────────────────
// 1.2.9+ — editor-scope Ctrl+B S was a redundant
// duplicate of plain Ctrl+S; reclaimed for the new
// TTS read-aloud action. Tree-scope Ctrl+B S =
// AddSubchapter stays.
entry("s", Action::TtsReadParagraph, Scope::Editor),
entry("n", Action::CreateSnapshot, Scope::Editor),
entry("r", Action::CycleStatus, Scope::Editor),
entry("f", Action::OpenFunctionPicker, Scope::Editor),
entry("t", Action::RenameToFirstSentence, Scope::Editor),
entry("m", Action::MorphType, Scope::Editor),
entry("p", Action::LookupPlacesOrImage, Scope::Editor),
entry("c", Action::LookupCharacters, Scope::Editor),
entry("g", Action::LookupNotes, Scope::Editor),
entry("y", Action::LookupArtefacts, Scope::Editor),
// ── AI pane ───────────────────────────────────
entry("c", Action::ClearChat, Scope::Ai),
// ── Global (Any) ──────────────────────────────
// H is pane-aware-content but pane-agnostic-binding —
// every pane gets a "quickref" overlay tailored to
// the focused area.
entry("h", Action::OpenQuickref, Scope::Any),
entry("v", Action::OpenCredits, Scope::Any),
entry("i", Action::OpenBookInfo, Scope::Any),
entry("l", Action::OpenLlmPicker, Scope::Any),
entry("e", Action::ToggleSound, Scope::Any),
entry("a", Action::ScheduleAssemble, Scope::Any),
entry("b", Action::ScheduleBuild, Scope::Any),
// 1.2.6+: Ctrl+B Shift+B → manual project backup.
// Distinct chord from `Ctrl+B b` (lowercase build)
// because the matcher tracks SHIFT separately.
entry("Shift+b", Action::BackupNow, Scope::Any),
entry("o", Action::ScheduleTake, Scope::Any),
// 1.2.7+ — Ctrl+B U undoes the most-recent
// paragraph delete (single-slot kill-ring).
entry("u", Action::UndoLastDelete, Scope::Any),
entry("w", Action::ToggleTypewriter, Scope::Any),
entry("k", Action::ToggleAiFullscreen, Scope::Any),
entry("1", Action::StatusFilterReady, Scope::Any),
entry("2", Action::StatusFilterFinal, Scope::Any),
entry("3", Action::StatusFilterThird, Scope::Any),
entry("4", Action::StatusFilterSecond, Scope::Any),
entry("5", Action::StatusFilterFirst, Scope::Any),
entry("6", Action::StatusFilterNapkin, Scope::Any),
entry("7", Action::StatusFilterNone, Scope::Any),
// Tag picker (1.2.5+). `]` opens the per-paragraph
// tag picker; `}` opens the search-by-tag picker.
entry("]", Action::TagParagraph, Scope::Any),
entry("}", Action::TagSearch, Scope::Any),
// 1.2.8+ — Ctrl+B 0 → full-screen HJSON config
// editor for `<project>/inkhaven.hjson`. Digit
// row, no modifier — unambiguous on every
// terminal layout (previous `|` binding was
// dropped on some terminals' chord state).
entry("0", Action::BundEditProjectHjson, Scope::Any),
// 1.2.9+ — Ctrl+B Shift+F toggles inline
// style-warning overlays (filter words).
entry("Shift+f", Action::ToggleStyleWarnings, Scope::Any),
// 1.2.9+ — Ctrl+B Shift+R saves the
// current paragraph as an audio file
// via macOS `say -o`.
entry("Shift+r", Action::TtsSaveAsAudio, Scope::Editor),
// 1.2.9+ — Ctrl+B Shift+G opens the
// writing-streak heatmap modal.
entry("Shift+g", Action::OpenWritingStreakHeatmap, Scope::Any),
// 1.2.9+ — Ctrl+B < / Ctrl+B > scene-break
// navigation in the editor. Originally
// requested as `Shift+{` / `Shift+}`, but
// `}` is already TagSearch (1.2.5). `<`
// and `>` are vim-style and free.
entry("<", Action::SceneBreakPrev, Scope::Editor),
entry(">", Action::SceneBreakNext, Scope::Editor),
// 1.2.9+ — Ctrl+B Shift+L opens the project-
// wide concordance modal.
entry("Shift+l", Action::OpenConcordance, Scope::Any),
// 1.2.9+ — Ctrl+B Shift+P toggles the
// status-bar POV / character chip.
entry("Shift+p", Action::TogglePovChip, Scope::Any),
// 1.2.9+ — Ctrl+B Shift+H opens the
// sentence-rhythm gauge modal.
entry("Shift+h", Action::OpenSentenceRhythm, Scope::Editor),
// 1.2.9+ — Ctrl+B Shift+T sends the
// open paragraph to the LLM for a
// show-don't-tell scan.
entry("Shift+t", Action::AnalyseShowDontTell, Scope::Editor),
],
bund_sub: vec![
entry("r", Action::BundRunBuffer, Scope::Any),
entry("n", Action::BundNewScript, Scope::Any),
entry("e", Action::BundOpenEvalModal, Scope::Any),
entry("?", Action::BundOpenScriptPicker, Scope::Any),
// 1.2.8+ — embedded nushell pane.
entry("o", Action::BundOpenShell, Scope::Any),
entry("Shift+o", Action::BundOpenShellFresh, Scope::Any),
entry("h", Action::BundShellSelection, Scope::Any),
],
view_sub: vec![
// Editor / AI-prompt: 1 = buffer markdown, 2 =
// containing-subchapter subtree markdown.
entry("1", Action::ViewExportMarkdownBuffer, Scope::Editor),
entry("2", Action::ViewExportMarkdownSubchapter, Scope::Editor),
entry("1", Action::ViewExportMarkdownBuffer, Scope::Ai),
entry("2", Action::ViewExportMarkdownSubchapter, Scope::Ai),
// Tree: 1 = subtree markdown.
entry("1", Action::ViewExportMarkdownSubtree, Scope::Tree),
// Global suffixes.
entry("s", Action::ViewToggleSimilarMode, Scope::Any),
entry("g", Action::ViewOpenProgress, Scope::Any),
entry("t", Action::ViewOpenParagraphTarget, Scope::Any),
entry("a", Action::ViewAddLink, Scope::Any),
entry("i", Action::ViewAddIncomingLink, Scope::Any),
entry("l", Action::ViewListLinks, Scope::Any),
entry("k", Action::ViewListBacklinks, Scope::Any),
entry("b", Action::ViewToggleBookmark, Scope::Any),
entry("m", Action::ViewListBookmarks, Scope::Any),
entry("p", Action::ViewFuzzyParagraphPicker, Scope::Any),
// 1.2.7+ — same picker sorted by modified_at desc.
entry("Shift+p", Action::ViewRecentParagraphPicker, Scope::Any),
// 1.2.8+ — kill-ring picker (paragraph undelete history).
entry("Shift+u", Action::ViewKillRingPicker, Scope::Any),
// 1.2.8+ — hidden-character report on the open paragraph.
entry("h", Action::ViewHiddenCharsReport, Scope::Any),
// 1.2.8+ — show cursor breadcrumb on the status bar.
entry("Shift+s", Action::ViewShowBreadcrumb, Scope::Any),
entry("r", Action::ViewRenderParagraph, Scope::Any),
entry("n", Action::ViewNextDiagnostic, Scope::Any),
// 1.2.6+: case differentiates view scope. Plain
// `w` opens the paragraph mini story view;
// Shift+W opens the full book story view.
entry("w", Action::ViewStoryGraphParagraph, Scope::Any),
entry("Shift+W", Action::ViewStoryGraph, Scope::Any),
// 1.2.6+ — timeline event picker.
entry("e", Action::ViewEventPicker, Scope::Any),
// 1.2.6+ — new event from any pane. Opens the
// timeline view and immediately triggers the
// new-event prompt, so a fresh project (zero
// events) can add its first event without going
// through `inkhaven event add` on the CLI.
entry("Shift+e", Action::ViewNewEventPrompt, Scope::Any),
// 1.2.6+ — edit timing of the open event ¶.
entry("Shift+i", Action::ViewEditEventMetadata, Scope::Any),
// 1.2.6+ — swim-lane timeline view. Bound to
// Shift+T so the lowercase `t` chord stays free
// for `ViewOpenParagraphTarget` (open the
// paragraph link target under the cursor) — the two
// used to collide on plain `t`, with the
// earlier-listed `ViewOpenParagraphTarget`
// shadowing this entry entirely.
entry("Shift+t", Action::ViewTimeline, Scope::Any),
],
top_level: vec![
// F1 anywhere: Help-book RAG modal.
entry("F1", Action::HelpQuery, Scope::Any),
// F2: rename — pane-aware-content but bound in Tree
// (where the cursor lives) + Editor (where rename
// can still be triggered for the open paragraph).
entry("F2", Action::RenameNode, Scope::Tree),
entry("F2", Action::RenameNode, Scope::Editor),
// F3: pane-specific file picker. Tree → import,
// Editor → load.
entry("F3", Action::FilePickerTreeImport, Scope::Tree),
entry("F3", Action::FilePickerEditorLoad, Scope::Editor),
// F4 / Ctrl+F4 — split-edit and "accept split".
entry("F4", Action::ToggleSplit, Scope::Editor),
entry("Ctrl+F4", Action::AcceptSplitSnapshot, Scope::Editor),
// F5 — snapshot the open paragraph (same as
// Ctrl+B N inside meta_sub).
entry("F5", Action::CreateSnapshot, Scope::Editor),
// F6 — snapshot picker.
entry("F6", Action::OpenSnapshotPicker, Scope::Editor),
// F7 — grammar check.
entry("F7", Action::GrammarCheck, Scope::Editor),
// F8 (1.2.6+) — typst diagnostics list modal.
// 1.2.7+ — scope widened to `Any` so the chord
// fires from Tree / AI / Search panes too,
// not just from a focused editor. The handler
// re-focuses the editor when the modal opens.
entry("F8", Action::DiagnosticsList, Scope::Any),
// 1.2.7+ — Ctrl+Shift+M toggles mouse capture
// so the user can drag-select text in the
// editor / AI pane via the terminal's native
// selection model + system clipboard copy.
entry("Ctrl+Shift+m", Action::ToggleMouseCapture, Scope::Any),
// 1.2.7+ — Alt+Left / Alt+Right back/forward
// through visited-paragraph history.
entry("Alt+Left", Action::VisitedBack, Scope::Any),
entry("Alt+Right", Action::VisitedForward, Scope::Any),
// F9 / F10 — global AI mode + inference toggle.
entry("F9", Action::CycleAiMode, Scope::Any),
entry("F10", Action::ToggleInferenceMode, Scope::Any),
// Ctrl+F12 (1.2.6+) — AI explain the diagnostic
// at the cursor. Used to live on bare F11, but
// macOS grabs F11 (Show Desktop / Mission
// Control) so it never reached the TUI. Anyone
// who wants F11 back can rebind via HJSON
// `keys.bindings` — see KEYS_REASSIGNMENT.md.
entry("Ctrl+F12", Action::ExplainDiagnostic, Scope::Editor),
// F12 (1.2.6+) — AI critique (mode-aware).
entry("F12", Action::Critique, Scope::Editor),
],
}
}
/// Resolve a single (top-level) keystroke against the
/// `top_level` table — the home for F-keys after 1.2.4's
/// migration.
pub fn resolve_top_level(&self, ev: &KeyEvent, focus: Focus) -> Option<Action> {
resolve_in(&self.top_level, ev, focus)
}
/// Resolve a meta sub-chord against the current focus. Returns
/// `None` when no binding matches, `Some(Action::None)` when a
/// binding was explicitly disabled by the user overlay.
pub fn resolve_meta_sub(&self, ev: &KeyEvent, focus: Focus) -> Option<Action> {
resolve_in(&self.meta_sub, ev, focus)
}
/// Same as `resolve_meta_sub` for chords after the bund_prefix.
pub fn resolve_bund_sub(&self, ev: &KeyEvent, focus: Focus) -> Option<Action> {
resolve_in(&self.bund_sub, ev, focus)
}
/// Same as `resolve_meta_sub` for chords after the view_prefix
/// (1.2.4+, default Ctrl+V).
pub fn resolve_view_sub(&self, ev: &KeyEvent, focus: Focus) -> Option<Action> {
resolve_in(&self.view_sub, ev, focus)
}
/// Apply a list of `(layer, entry)` overlay pairs on top of
/// the existing table. Each new entry replaces any existing
/// `(chord, scope)` match in the same layer and gets
/// prepended so it wins resolution against the defaults.
pub fn apply_overlay(&mut self, overlay: Vec<(Layer, BindingEntry)>) {
for (layer, new) in overlay {
let table = self.layer_table_mut(layer);
table.retain(|b| !(b.chord == new.chord && b.scope == new.scope));
table.insert(0, new);
}
}
fn layer_table_mut(&mut self, layer: Layer) -> &mut Vec<BindingEntry> {
match layer {
Layer::MetaSub => &mut self.meta_sub,
Layer::BundSub => &mut self.bund_sub,
Layer::ViewSub => &mut self.view_sub,
Layer::TopLevel => &mut self.top_level,
}
}
/// Build a `KeyBindings` from `defaults()` overlaid with the
/// parsed HJSON `keys.bindings` entries. Caller supplies the
/// already-parsed meta + bund + view prefixes so the overlay
/// parser can route `"Ctrl+b m"` → meta_sub table by prefix
/// match.
pub fn from_overrides(
meta_prefix: KeyChord,
bund_prefix: Option<KeyChord>,
view_prefix: Option<KeyChord>,
overrides: &[(String, String, Option<String>)],
) -> Result<Self, String> {
let mut bindings = Self::defaults();
bindings.meta_prefix = meta_prefix;
bindings.bund_prefix = bund_prefix;
bindings.view_prefix = view_prefix;
let mut overlay: Vec<(Layer, BindingEntry)> = Vec::new();
for (chord_str, action_str, scope_str) in overrides {
let entry = parse_overlay(
meta_prefix,
bund_prefix.unwrap_or_else(disabled_chord_placeholder),
view_prefix.unwrap_or_else(disabled_chord_placeholder),
chord_str,
action_str,
scope_str,
)?;
overlay.push(entry);
}
bindings.apply_overlay(overlay);
Ok(bindings)
}
/// Add or replace a single binding. Used by `ink.key.bind` /
/// `ink.key.bind_lambda`. Same `(chord, scope)` uniqueness
/// semantics as the HJSON overlay: a new entry shadows any
/// existing one with matching key.
pub fn add(&mut self, layer: Layer, entry: BindingEntry) {
let table = self.layer_table_mut(layer);
table.retain(|b| !(b.chord == entry.chord && b.scope == entry.scope));
table.insert(0, entry);
}
/// Remove every entry whose `(chord, scope)` matches. Returns
/// the number of entries removed (zero when nothing matched).
pub fn remove(&mut self, layer: Layer, chord: &KeyChord, scope: Scope) -> usize {
let table = self.layer_table_mut(layer);
let before = table.len();
table.retain(|b| !(b.chord == *chord && b.scope == scope));
before - table.len()
}
/// Parse a `"<prefix> <suffix>"` shorthand and return
/// `(layer, suffix_chord)`. Used by `ink.key.*` stdlib words
/// AND the HJSON overlay parser via `parse_overlay`.
pub fn parse_sub_chord(&self, s: &str) -> Result<(Layer, KeyChord), String> {
let parts: Vec<&str> = s.split_whitespace().collect();
let (prefix_str, suffix_str) = match parts.as_slice() {
[single] => {
return Err(format!(
"chord `{single}`: top-level (no-prefix) binding not yet supported \
— use `<meta_prefix> <key>` or `<bund_prefix> <key>`"
));
}
[prefix, suffix] => (*prefix, *suffix),
_ => return Err(format!("chord `{s}`: expected `<prefix> <suffix>`")),
};
let prefix = KeyChord::parse(prefix_str)
.map_err(|e| format!("chord `{s}` prefix: {e}"))?;
let suffix = KeyChord::parse(suffix_str)
.map_err(|e| format!("chord `{s}` suffix: {e}"))?;
let layer = if prefix == self.meta_prefix {
Layer::MetaSub
} else if Some(prefix) == self.bund_prefix {
Layer::BundSub
} else if Some(prefix) == self.view_prefix {
Layer::ViewSub
} else {
return Err(format!(
"chord `{s}`: prefix `{prefix_str}` is not meta_prefix / bund_prefix / view_prefix"
));
};
if suffix == self.meta_prefix
|| Some(suffix) == self.bund_prefix
|| Some(suffix) == self.view_prefix
{
return Err(format!(
"chord `{s}`: suffix collides with a prefix chord"
));
}
Ok((layer, suffix))
}
}
impl KeyBindings {
/// Build the status-bar hint string for the meta-prefix
/// chord on the given focus. Iterates `meta_sub` in
/// registration order, skipping disabled entries and
/// deduplicating actions (so `Up` + `u` for ReorderUp
/// surface as one entry).
pub fn meta_hint(&self, focus: Focus) -> String {
self.hint_for(&self.meta_sub, "META", focus)
}
/// Same for the bund-prefix chord.
pub fn bund_hint(&self, focus: Focus) -> String {
self.hint_for(&self.bund_sub, "BUND", focus)
}
/// Same for the view-prefix chord (1.2.4+, default Ctrl+V).
pub fn view_hint(&self, focus: Focus) -> String {
self.hint_for(&self.view_sub, "VIEW", focus)
}
fn hint_for(&self, table: &[BindingEntry], prefix: &str, focus: Focus) -> String {
use std::collections::HashSet;
let mut parts: Vec<String> = vec![prefix.to_string()];
let mut seen: HashSet<String> = HashSet::new();
for entry in table {
if !entry.scope.matches(focus) {
continue;
}
if matches!(entry.action, Action::None) {
continue;
}
let label = entry.action.label();
if label.is_empty() {
continue;
}
// De-dupe by action label: a user who bound the same
// action to two chords (e.g. ReorderUp on Up and u)
// only sees the action once in the hint.
if !seen.insert(label.clone()) {
continue;
}
parts.push(format!("{} {}", entry.chord.to_display_string(), label));
}
parts.push("Esc cancel".into());
parts.join(" · ")
}
}
/// Placeholder chord matched by nothing real — used to satisfy
/// `parse_overlay`'s `bund_prefix` arg when the user disabled the
/// bund prefix via empty config.
fn disabled_chord_placeholder() -> KeyChord {
KeyChord {
code: crossterm::event::KeyCode::Null,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
/// Which sub-chord table the overlay entry targets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Layer {
MetaSub,
BundSub,
/// 1.2.4+: Ctrl+V family — markdown export / similar mode /
/// progress / paragraph target.
ViewSub,
/// 1.2.4+: top-level (no-prefix) chords — home for the
/// F-keys after the migration. HJSON `keys.bindings` chord
/// strings that contain a single token (no prefix) land here.
TopLevel,
}
fn parse_overlay(
meta_prefix: KeyChord,
bund_prefix: KeyChord,
view_prefix: KeyChord,
chord: &str,
action: &str,
scope: &Option<String>,
) -> Result<(Layer, BindingEntry), String> {
// Shorthand split: "Ctrl+b y" → ["Ctrl+b", "y"]. Trim runs of
// whitespace so "Ctrl+b y" also parses cleanly.
let parts: Vec<&str> = chord.split_whitespace().collect();
// 1.2.4+: single-token chord strings (e.g. `"F1"`, `"Shift+F4"`)
// bind into the `top_level` table — no prefix required.
if parts.len() == 1 {
let single = KeyChord::parse(parts[0])
.map_err(|e| format!("binding chord `{chord}`: {e}"))?;
let action_enum = parse_action(action)?;
let scope_enum = parse_scope(scope.as_deref())?;
return Ok((
Layer::TopLevel,
BindingEntry {
chord: single,
action: action_enum,
scope: scope_enum,
},
));
}
let (prefix_str, suffix_str) = match parts.as_slice() {
[prefix, suffix] => (*prefix, *suffix),
_ => {
return Err(format!(
"binding chord `{chord}`: expected `<prefix> <suffix>` (two tokens) or single top-level chord"
));
}
};
let prefix = KeyChord::parse(prefix_str)
.map_err(|e| format!("binding chord `{chord}` prefix: {e}"))?;
let suffix = KeyChord::parse(suffix_str)
.map_err(|e| format!("binding chord `{chord}` suffix: {e}"))?;
let layer = if prefix == meta_prefix {
Layer::MetaSub
} else if prefix == bund_prefix {
Layer::BundSub
} else if prefix == view_prefix {
Layer::ViewSub
} else {
return Err(format!(
"binding chord `{chord}`: prefix `{prefix_str}` is not meta_prefix / bund_prefix / view_prefix"
));
};
// Reject rebinding the prefixes themselves and the hard-quit
// chord — those are configured via top-level `keys.*` slots,
// not the bindings overlay.
if suffix == meta_prefix || suffix == bund_prefix || suffix == view_prefix {
return Err(format!(
"binding chord `{chord}`: suffix collides with a prefix chord"
));
}
let scope = parse_scope(scope.as_deref())?;
let action = parse_action(action)?;
Ok((
layer,
BindingEntry {
chord: suffix,
action,
scope,
},
))
}
fn parse_scope(s: Option<&str>) -> Result<Scope, String> {
match s {
None | Some("any") => Ok(Scope::Any),
Some("editor") => Ok(Scope::Editor),
Some("tree") => Ok(Scope::Tree),
Some("ai") => Ok(Scope::Ai),
Some(other) => Err(format!(
"scope `{other}`: expected one of any / editor / tree / ai"
)),
}
}
fn parse_action(s: &str) -> Result<Action, String> {
// Round-trip via serde: variant rename attributes give us the
// canonical dotted form. `serde_json::from_str` reads a JSON
// string literal and matches it against the rename map.
serde_json::from_str::<Action>(&format!("\"{s}\""))
.map_err(|e| format!("action `{s}`: {e}"))
}
fn resolve_in(table: &[BindingEntry], ev: &KeyEvent, focus: Focus) -> Option<Action> {
table
.iter()
.find(|b| b.scope.matches(focus) && b.chord.matches(ev))
.map(|b| b.action.clone())
}
fn entry(chord: &str, action: Action, scope: Scope) -> BindingEntry {
BindingEntry {
chord: KeyChord::parse(chord).expect("invalid default chord — programmer error"),
action,
scope,
}
}
// ── Shared active KeyBindings ────────────────────────────────────────
//
// App reads from this on every chord dispatch; `ink.key.*` Bund
// stdlib writes to it. Lazily initialised with `KeyBindings::defaults()`
// on first access — so CLI subcommands (`inkhaven bund`) that don't
// build an `App` still see a functioning binding table.
//
// `install` replaces the contents under the write lock, so TUI
// startup (which parses the HJSON overlay) wins over the lazy
// defaults whenever it runs.
static ACTIVE: LazyLock<RwLock<KeyBindings>> =
LazyLock::new(|| RwLock::new(KeyBindings::defaults()));
/// Replace the active KeyBindings. Called by `App::new` after
/// applying the HJSON overlay. Cheap because the new value is
/// move-swapped under the write lock.
pub fn install(bindings: KeyBindings) {
*ACTIVE.write() = bindings;
}
/// Read access. Lazy default-init means this never blocks on
/// missing installation — CLI smoke usage gets defaults.
pub fn read() -> RwLockReadGuard<'static, KeyBindings> {
ACTIVE.read()
}
/// Write access for `ink.key.*` Bund stdlib words.
pub fn write() -> RwLockWriteGuard<'static, KeyBindings> {
ACTIVE.write()
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn ev(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
#[test]
fn defaults_resolve_known_chords() {
let k = KeyBindings::defaults();
// Tree pane: C → add chapter
assert_eq!(
k.resolve_meta_sub(&ev('c'), Focus::Tree),
Some(Action::AddChapter)
);
// Editor pane: C → character lookup (different action,
// same key — scope discriminates).
assert_eq!(
k.resolve_meta_sub(&ev('c'), Focus::Editor),
Some(Action::LookupCharacters)
);
// AI pane: C → clear chat
assert_eq!(
k.resolve_meta_sub(&ev('c'), Focus::Ai),
Some(Action::ClearChat)
);
// V is global → open credits regardless of pane
assert_eq!(
k.resolve_meta_sub(&ev('v'), Focus::Tree),
Some(Action::OpenCredits)
);
assert_eq!(
k.resolve_meta_sub(&ev('v'), Focus::Editor),
Some(Action::OpenCredits)
);
}
#[test]
fn view_sub_t_and_shift_t_route_to_distinct_actions() {
// 1.2.6+ — `Ctrl+V t` opens the paragraph link target,
// `Ctrl+V Shift+T` opens the timeline. They used to
// collide on plain `t` (the second binding was shadowed
// and dead).
let k = KeyBindings::defaults();
let lower = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE);
assert_eq!(
k.resolve_view_sub(&lower, Focus::Editor),
Some(Action::ViewOpenParagraphTarget)
);
let upper = KeyEvent::new(KeyCode::Char('T'), KeyModifiers::SHIFT);
assert_eq!(
k.resolve_view_sub(&upper, Focus::Editor),
Some(Action::ViewTimeline)
);
}
#[test]
fn lowercase_b_and_shift_b_are_distinct_actions() {
let k = KeyBindings::defaults();
// Ctrl+B b (lowercase) → build the book (unchanged).
let lower = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE);
assert_eq!(
k.resolve_meta_sub(&lower, Focus::Editor),
Some(Action::ScheduleBuild)
);
// Ctrl+B Shift+B (uppercase) → manual backup. Different
// chord, different action — the matcher uppercases the
// event's char when SHIFT is set so 'B'+SHIFT and 'b'+none
// route to different entries.
let upper = KeyEvent::new(KeyCode::Char('B'), KeyModifiers::SHIFT);
assert_eq!(
k.resolve_meta_sub(&upper, Focus::Editor),
Some(Action::BackupNow)
);
}
#[test]
fn pane_scope_beats_any() {
let k = KeyBindings::defaults();
// In editor, P → places-or-image (Editor scope), NOT add
// paragraph (Tree scope). Both are listed; narrow scope
// wins.
assert_eq!(
k.resolve_meta_sub(&ev('p'), Focus::Editor),
Some(Action::LookupPlacesOrImage)
);
// In tree, P → add paragraph.
assert_eq!(
k.resolve_meta_sub(&ev('p'), Focus::Tree),
Some(Action::AddParagraph)
);
}
#[test]
fn status_filter_digits() {
let k = KeyBindings::defaults();
for (c, expected) in [
('1', Action::StatusFilterReady),
('2', Action::StatusFilterFinal),
('3', Action::StatusFilterThird),
('4', Action::StatusFilterSecond),
('5', Action::StatusFilterFirst),
('6', Action::StatusFilterNapkin),
('7', Action::StatusFilterNone),
] {
assert_eq!(
k.resolve_meta_sub(&ev(c), Focus::Editor),
Some(expected),
"digit {c}"
);
}
}
#[test]
fn bund_sub_known_chords() {
let k = KeyBindings::defaults();
assert_eq!(
k.resolve_bund_sub(&ev('r'), Focus::Tree),
Some(Action::BundRunBuffer)
);
assert_eq!(
k.resolve_bund_sub(&ev('n'), Focus::Editor),
Some(Action::BundNewScript)
);
assert_eq!(
k.resolve_bund_sub(&ev('e'), Focus::Ai),
Some(Action::BundOpenEvalModal)
);
}
#[test]
fn unknown_chord_is_none() {
let k = KeyBindings::defaults();
assert_eq!(k.resolve_meta_sub(&ev('z'), Focus::Editor), None);
}
}