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 = "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::None => String::new(),
Action::BundLambda(name) => format!("λ {name}"),
}
}
}
#[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 meta_sub: Vec<BindingEntry>,
pub bund_sub: 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")),
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),
],
}
}
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 apply_overlay(&mut self, overlay: Vec<(Layer, BindingEntry)>) {
for (layer, new) in overlay {
let table = match layer {
Layer::MetaSub => &mut self.meta_sub,
Layer::BundSub => &mut self.bund_sub,
};
table.retain(|b| !(b.chord == new.chord && b.scope == new.scope));
table.insert(0, new);
}
}
pub fn from_overrides(
meta_prefix: KeyChord,
bund_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;
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),
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 = match layer {
Layer::MetaSub => &mut self.meta_sub,
Layer::BundSub => &mut self.bund_sub,
};
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 = match layer {
Layer::MetaSub => &mut self.meta_sub,
Layer::BundSub => &mut self.bund_sub,
};
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 {
return Err(format!(
"chord `{s}`: prefix `{prefix_str}` is not meta_prefix or bund_prefix"
));
};
if suffix == self.meta_prefix || Some(suffix) == self.bund_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)
}
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,
}
fn parse_overlay(
meta_prefix: KeyChord,
bund_prefix: KeyChord,
chord: &str,
action: &str,
scope: &Option<String>,
) -> Result<(Layer, BindingEntry), String> {
let parts: Vec<&str> = chord.split_whitespace().collect();
let (prefix_str, suffix_str) = match parts.as_slice() {
[single] => {
return Err(format!(
"binding chord `{single}`: top-level (no-prefix) rebinding isn't supported \
in Stage 1 — use `<meta_prefix> <key>` or `<bund_prefix> <key>`"
));
}
[prefix, suffix] => (*prefix, *suffix),
_ => {
return Err(format!(
"binding chord `{chord}`: expected `<prefix> <suffix>` (two tokens)"
));
}
};
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 {
return Err(format!(
"binding chord `{chord}`: prefix `{prefix_str}` is not meta_prefix or bund_prefix"
));
};
if suffix == meta_prefix || suffix == bund_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);
}
}