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;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Scope {
Any,
Editor,
Tree,
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),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Action {
#[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,
#[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,
#[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,
#[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,
#[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,
#[serde(rename = "ai.clear_chat")]
ClearChat,
#[serde(rename = "bund.run_buffer")]
BundRunBuffer,
#[serde(rename = "bund.new_script")]
BundNewScript,
#[serde(rename = "bund.open_eval_modal")]
BundOpenEvalModal,
#[serde(rename = "bund.open_script_picker")]
BundOpenScriptPicker,
#[serde(rename = "help.query")]
HelpQuery,
#[serde(rename = "tree.rename")]
RenameNode,
#[serde(rename = "tree.file_picker_import")]
FilePickerTreeImport,
#[serde(rename = "editor.file_picker_load")]
FilePickerEditorLoad,
#[serde(rename = "editor.toggle_split")]
ToggleSplit,
#[serde(rename = "editor.accept_split_snapshot")]
AcceptSplitSnapshot,
#[serde(rename = "editor.snapshot_picker")]
OpenSnapshotPicker,
#[serde(rename = "editor.grammar_check")]
GrammarCheck,
#[serde(rename = "ai.cycle_mode")]
CycleAiMode,
#[serde(rename = "ai.toggle_inference_mode")]
ToggleInferenceMode,
#[serde(rename = "view.export_markdown_buffer")]
ViewExportMarkdownBuffer,
#[serde(rename = "view.export_markdown_subchapter")]
ViewExportMarkdownSubchapter,
#[serde(rename = "view.export_markdown_subtree")]
ViewExportMarkdownSubtree,
#[serde(rename = "view.toggle_similar_mode")]
ViewToggleSimilarMode,
#[serde(rename = "view.open_progress")]
ViewOpenProgress,
#[serde(rename = "view.open_paragraph_target")]
ViewOpenParagraphTarget,
#[serde(rename = "view.add_link")]
ViewAddLink,
#[serde(rename = "view.add_incoming_link")]
ViewAddIncomingLink,
#[serde(rename = "view.list_links")]
ViewListLinks,
#[serde(rename = "view.list_backlinks")]
ViewListBacklinks,
#[serde(rename = "view.toggle_bookmark")]
ViewToggleBookmark,
#[serde(rename = "view.list_bookmarks")]
ViewListBookmarks,
#[serde(rename = "view.fuzzy_paragraph_picker")]
ViewFuzzyParagraphPicker,
#[serde(rename = "none")]
None,
#[serde(skip)]
BundLambda(Arc<str>),
}
impl Action {
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::ToggleTypewriter => "typewriter".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::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::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::None => String::new(),
Action::BundLambda(name) => format!("λ {name}"),
}
}
pub fn description(&self) -> String {
match self {
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(),
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(),
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::ToggleTypewriter =>
"Toggle full-screen typewriter mode — hides every other pane for focused writing.".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::ClearChat =>
"Clear the chat history and any in-flight inference for a fresh AI session.".into(),
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(),
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::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(),
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 wiki-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::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,
}
#[derive(Debug, Clone)]
pub struct KeyBindings {
pub meta_prefix: KeyChord,
pub bund_prefix: Option<KeyChord>,
pub view_prefix: Option<KeyChord>,
pub meta_sub: Vec<BindingEntry>,
pub bund_sub: Vec<BindingEntry>,
pub view_sub: Vec<BindingEntry>,
pub top_level: Vec<BindingEntry>,
}
impl Default for KeyBindings {
fn default() -> Self {
Self::defaults()
}
}
impl KeyBindings {
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![
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),
entry("u", Action::ReorderUp, Scope::Tree),
entry("j", Action::ReorderDown, Scope::Tree),
entry("s", Action::Save, 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),
entry("c", Action::ClearChat, Scope::Ai),
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),
entry("o", Action::ScheduleTake, 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),
],
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),
],
view_sub: vec![
entry("1", Action::ViewExportMarkdownBuffer, Scope::Editor),
entry("2", Action::ViewExportMarkdownSubchapter, Scope::Editor),
entry("1", Action::ViewExportMarkdownBuffer, Scope::Ai),
entry("2", Action::ViewExportMarkdownSubchapter, Scope::Ai),
entry("1", Action::ViewExportMarkdownSubtree, Scope::Tree),
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),
],
top_level: vec![
entry("F1", Action::HelpQuery, Scope::Any),
entry("F2", Action::RenameNode, Scope::Tree),
entry("F2", Action::RenameNode, Scope::Editor),
entry("F3", Action::FilePickerTreeImport, Scope::Tree),
entry("F3", Action::FilePickerEditorLoad, Scope::Editor),
entry("F4", Action::ToggleSplit, Scope::Editor),
entry("Ctrl+F4", Action::AcceptSplitSnapshot, Scope::Editor),
entry("F5", Action::CreateSnapshot, Scope::Editor),
entry("F6", Action::OpenSnapshotPicker, Scope::Editor),
entry("F7", Action::GrammarCheck, Scope::Editor),
entry("F9", Action::CycleAiMode, Scope::Any),
entry("F10", Action::ToggleInferenceMode, Scope::Any),
],
}
}
pub fn resolve_top_level(&self, ev: &KeyEvent, focus: Focus) -> Option<Action> {
resolve_in(&self.top_level, ev, focus)
}
pub fn resolve_meta_sub(&self, ev: &KeyEvent, focus: Focus) -> Option<Action> {
resolve_in(&self.meta_sub, ev, focus)
}
pub fn resolve_bund_sub(&self, ev: &KeyEvent, focus: Focus) -> Option<Action> {
resolve_in(&self.bund_sub, ev, focus)
}
pub fn resolve_view_sub(&self, ev: &KeyEvent, focus: Focus) -> Option<Action> {
resolve_in(&self.view_sub, ev, focus)
}
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,
}
}
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)
}
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);
}
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()
}
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 {
pub fn meta_hint(&self, focus: Focus) -> String {
self.hint_for(&self.meta_sub, "META", focus)
}
pub fn bund_hint(&self, focus: Focus) -> String {
self.hint_for(&self.bund_sub, "BUND", focus)
}
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;
}
if !seen.insert(label.clone()) {
continue;
}
parts.push(format!("{} {}", entry.chord.to_display_string(), label));
}
parts.push("Esc cancel".into());
parts.join(" · ")
}
}
fn disabled_chord_placeholder() -> KeyChord {
KeyChord {
code: crossterm::event::KeyCode::Null,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Layer {
MetaSub,
BundSub,
ViewSub,
TopLevel,
}
fn parse_overlay(
meta_prefix: KeyChord,
bund_prefix: KeyChord,
view_prefix: KeyChord,
chord: &str,
action: &str,
scope: &Option<String>,
) -> Result<(Layer, BindingEntry), String> {
let parts: Vec<&str> = chord.split_whitespace().collect();
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"
));
};
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> {
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,
}
}
static ACTIVE: LazyLock<RwLock<KeyBindings>> =
LazyLock::new(|| RwLock::new(KeyBindings::defaults()));
pub fn install(bindings: KeyBindings) {
*ACTIVE.write() = bindings;
}
pub fn read() -> RwLockReadGuard<'static, KeyBindings> {
ACTIVE.read()
}
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();
assert_eq!(
k.resolve_meta_sub(&ev('c'), Focus::Tree),
Some(Action::AddChapter)
);
assert_eq!(
k.resolve_meta_sub(&ev('c'), Focus::Editor),
Some(Action::LookupCharacters)
);
assert_eq!(
k.resolve_meta_sub(&ev('c'), Focus::Ai),
Some(Action::ClearChat)
);
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 pane_scope_beats_any() {
let k = KeyBindings::defaults();
assert_eq!(
k.resolve_meta_sub(&ev('p'), Focus::Editor),
Some(Action::LookupPlacesOrImage)
);
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);
}
}