aboutsummaryrefslogtreecommitdiffstats
path: root/crates/turtle/src/command/client/search/keybindings
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-06-11 00:54:30 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-06-11 00:54:30 +0200
commit5c39e7cf284a1f6e9a1657f2deb44e359fc47eb8 (patch)
treec64baa8d5866c8e339eaf660dd3f94f30a3f7d8a /crates/turtle/src/command/client/search/keybindings
parentchore: Somewhat simplify sync code (diff)
downloadatuin-5c39e7cf284a1f6e9a1657f2deb44e359fc47eb8.zip
chore: Move everything into one big crate
That helps remove duplicated code and rustc/cargo will now also show dead code correctly.
Diffstat (limited to 'crates/turtle/src/command/client/search/keybindings')
-rw-r--r--crates/turtle/src/command/client/search/keybindings/actions.rs322
-rw-r--r--crates/turtle/src/command/client/search/keybindings/conditions.rs801
-rw-r--r--crates/turtle/src/command/client/search/keybindings/defaults.rs1286
-rw-r--r--crates/turtle/src/command/client/search/keybindings/key.rs629
-rw-r--r--crates/turtle/src/command/client/search/keybindings/keymap.rs233
-rw-r--r--crates/turtle/src/command/client/search/keybindings/mod.rs14
6 files changed, 3285 insertions, 0 deletions
diff --git a/crates/turtle/src/command/client/search/keybindings/actions.rs b/crates/turtle/src/command/client/search/keybindings/actions.rs
new file mode 100644
index 00000000..ff2ef7de
--- /dev/null
+++ b/crates/turtle/src/command/client/search/keybindings/actions.rs
@@ -0,0 +1,322 @@
+use std::fmt;
+
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+/// All possible actions that can be triggered by a keybinding.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Action {
+ // Cursor movement
+ CursorLeft,
+ CursorRight,
+ CursorWordLeft,
+ CursorWordRight,
+ CursorWordEnd,
+ CursorStart,
+ CursorEnd,
+
+ // Editing
+ DeleteCharBefore,
+ DeleteCharAfter,
+ DeleteWordBefore,
+ DeleteWordAfter,
+ DeleteToWordBoundary,
+ ClearLine,
+ ClearToStart,
+ ClearToEnd,
+
+ // List navigation
+ SelectNext,
+ SelectPrevious,
+ ScrollHalfPageUp,
+ ScrollHalfPageDown,
+ ScrollPageUp,
+ ScrollPageDown,
+ ScrollToTop,
+ ScrollToBottom,
+ ScrollToScreenTop,
+ ScrollToScreenMiddle,
+ ScrollToScreenBottom,
+
+ // Commands — accept selection and execute immediately
+ Accept,
+ AcceptNth(u8),
+ // Commands — return selection to command line without executing
+ ReturnSelection,
+ ReturnSelectionNth(u8),
+ // Commands — other
+ Copy,
+ Delete,
+ DeleteAll,
+ ReturnOriginal,
+ ReturnQuery,
+ Exit,
+ Redraw,
+ CycleFilterMode,
+ CycleSearchMode,
+ SwitchContext,
+ ClearContext,
+ ToggleTab,
+
+ // Mode changes
+ VimEnterNormal,
+ VimEnterInsert,
+ VimEnterInsertAfter,
+ VimEnterInsertAtStart,
+ VimEnterInsertAtEnd,
+ VimSearchInsert,
+ VimChangeToEnd,
+ EnterPrefixMode,
+
+ // Inspector
+ InspectPrevious,
+ InspectNext,
+
+ // Special
+ Noop,
+}
+
+impl Action {
+ /// Convert from a kebab-case string.
+ pub fn from_str(s: &str) -> Result<Self, String> {
+ // Handle accept-N and return-selection-N patterns
+ if let Some(rest) = s.strip_prefix("accept-")
+ && let Ok(n) = rest.parse::<u8>()
+ && (1..=9).contains(&n)
+ {
+ return Ok(Action::AcceptNth(n));
+ }
+ if let Some(rest) = s.strip_prefix("return-selection-")
+ && let Ok(n) = rest.parse::<u8>()
+ && (1..=9).contains(&n)
+ {
+ return Ok(Action::ReturnSelectionNth(n));
+ }
+
+ match s {
+ "cursor-left" => Ok(Action::CursorLeft),
+ "cursor-right" => Ok(Action::CursorRight),
+ "cursor-word-left" => Ok(Action::CursorWordLeft),
+ "cursor-word-right" => Ok(Action::CursorWordRight),
+ "cursor-word-end" => Ok(Action::CursorWordEnd),
+ "cursor-start" => Ok(Action::CursorStart),
+ "cursor-end" => Ok(Action::CursorEnd),
+
+ "delete-char-before" => Ok(Action::DeleteCharBefore),
+ "delete-char-after" => Ok(Action::DeleteCharAfter),
+ "delete-word-before" => Ok(Action::DeleteWordBefore),
+ "delete-word-after" => Ok(Action::DeleteWordAfter),
+ "delete-to-word-boundary" => Ok(Action::DeleteToWordBoundary),
+ "clear-line" => Ok(Action::ClearLine),
+ "clear-to-start" => Ok(Action::ClearToStart),
+ "clear-to-end" => Ok(Action::ClearToEnd),
+
+ "select-next" => Ok(Action::SelectNext),
+ "select-previous" => Ok(Action::SelectPrevious),
+ "scroll-half-page-up" => Ok(Action::ScrollHalfPageUp),
+ "scroll-half-page-down" => Ok(Action::ScrollHalfPageDown),
+ "scroll-page-up" => Ok(Action::ScrollPageUp),
+ "scroll-page-down" => Ok(Action::ScrollPageDown),
+ "scroll-to-top" => Ok(Action::ScrollToTop),
+ "scroll-to-bottom" => Ok(Action::ScrollToBottom),
+ "scroll-to-screen-top" => Ok(Action::ScrollToScreenTop),
+ "scroll-to-screen-middle" => Ok(Action::ScrollToScreenMiddle),
+ "scroll-to-screen-bottom" => Ok(Action::ScrollToScreenBottom),
+
+ "accept" => Ok(Action::Accept),
+ "return-selection" => Ok(Action::ReturnSelection),
+ "copy" => Ok(Action::Copy),
+ "delete" => Ok(Action::Delete),
+ "delete-all" => Ok(Action::DeleteAll),
+ "return-original" => Ok(Action::ReturnOriginal),
+ "return-query" => Ok(Action::ReturnQuery),
+ "exit" => Ok(Action::Exit),
+ "redraw" => Ok(Action::Redraw),
+ "cycle-filter-mode" => Ok(Action::CycleFilterMode),
+ "cycle-search-mode" => Ok(Action::CycleSearchMode),
+ "switch-context" => Ok(Action::SwitchContext),
+ "clear-context" => Ok(Action::ClearContext),
+ "toggle-tab" => Ok(Action::ToggleTab),
+
+ "vim-enter-normal" => Ok(Action::VimEnterNormal),
+ "vim-enter-insert" => Ok(Action::VimEnterInsert),
+ "vim-enter-insert-after" => Ok(Action::VimEnterInsertAfter),
+ "vim-enter-insert-at-start" => Ok(Action::VimEnterInsertAtStart),
+ "vim-enter-insert-at-end" => Ok(Action::VimEnterInsertAtEnd),
+ "vim-search-insert" => Ok(Action::VimSearchInsert),
+ "vim-change-to-end" => Ok(Action::VimChangeToEnd),
+ "enter-prefix-mode" => Ok(Action::EnterPrefixMode),
+
+ "inspect-previous" => Ok(Action::InspectPrevious),
+ "inspect-next" => Ok(Action::InspectNext),
+
+ "noop" => Ok(Action::Noop),
+
+ _ => Err(format!("unknown action: {s}")),
+ }
+ }
+
+ /// Convert to a kebab-case string.
+ pub fn as_str(&self) -> String {
+ match self {
+ Action::CursorLeft => "cursor-left".to_string(),
+ Action::CursorRight => "cursor-right".to_string(),
+ Action::CursorWordLeft => "cursor-word-left".to_string(),
+ Action::CursorWordRight => "cursor-word-right".to_string(),
+ Action::CursorWordEnd => "cursor-word-end".to_string(),
+ Action::CursorStart => "cursor-start".to_string(),
+ Action::CursorEnd => "cursor-end".to_string(),
+
+ Action::DeleteCharBefore => "delete-char-before".to_string(),
+ Action::DeleteCharAfter => "delete-char-after".to_string(),
+ Action::DeleteWordBefore => "delete-word-before".to_string(),
+ Action::DeleteWordAfter => "delete-word-after".to_string(),
+ Action::DeleteToWordBoundary => "delete-to-word-boundary".to_string(),
+ Action::ClearLine => "clear-line".to_string(),
+ Action::ClearToStart => "clear-to-start".to_string(),
+ Action::ClearToEnd => "clear-to-end".to_string(),
+
+ Action::SelectNext => "select-next".to_string(),
+ Action::SelectPrevious => "select-previous".to_string(),
+ Action::ScrollHalfPageUp => "scroll-half-page-up".to_string(),
+ Action::ScrollHalfPageDown => "scroll-half-page-down".to_string(),
+ Action::ScrollPageUp => "scroll-page-up".to_string(),
+ Action::ScrollPageDown => "scroll-page-down".to_string(),
+ Action::ScrollToTop => "scroll-to-top".to_string(),
+ Action::ScrollToBottom => "scroll-to-bottom".to_string(),
+ Action::ScrollToScreenTop => "scroll-to-screen-top".to_string(),
+ Action::ScrollToScreenMiddle => "scroll-to-screen-middle".to_string(),
+ Action::ScrollToScreenBottom => "scroll-to-screen-bottom".to_string(),
+
+ Action::Accept => "accept".to_string(),
+ Action::AcceptNth(n) => format!("accept-{n}"),
+ Action::ReturnSelection => "return-selection".to_string(),
+ Action::ReturnSelectionNth(n) => format!("return-selection-{n}"),
+ Action::Copy => "copy".to_string(),
+ Action::Delete => "delete".to_string(),
+ Action::DeleteAll => "delete-all".to_string(),
+ Action::ReturnOriginal => "return-original".to_string(),
+ Action::ReturnQuery => "return-query".to_string(),
+ Action::Exit => "exit".to_string(),
+ Action::Redraw => "redraw".to_string(),
+ Action::CycleFilterMode => "cycle-filter-mode".to_string(),
+ Action::CycleSearchMode => "cycle-search-mode".to_string(),
+ Action::SwitchContext => "switch-context".to_string(),
+ Action::ClearContext => "clear-context".to_string(),
+ Action::ToggleTab => "toggle-tab".to_string(),
+
+ Action::VimEnterNormal => "vim-enter-normal".to_string(),
+ Action::VimEnterInsert => "vim-enter-insert".to_string(),
+ Action::VimEnterInsertAfter => "vim-enter-insert-after".to_string(),
+ Action::VimEnterInsertAtStart => "vim-enter-insert-at-start".to_string(),
+ Action::VimEnterInsertAtEnd => "vim-enter-insert-at-end".to_string(),
+ Action::VimSearchInsert => "vim-search-insert".to_string(),
+ Action::VimChangeToEnd => "vim-change-to-end".to_string(),
+ Action::EnterPrefixMode => "enter-prefix-mode".to_string(),
+
+ Action::InspectPrevious => "inspect-previous".to_string(),
+ Action::InspectNext => "inspect-next".to_string(),
+
+ Action::Noop => "noop".to_string(),
+ }
+ }
+}
+
+impl fmt::Display for Action {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.as_str())
+ }
+}
+
+impl Serialize for Action {
+ fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+ serializer.serialize_str(&self.as_str())
+ }
+}
+
+impl<'de> Deserialize<'de> for Action {
+ fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+ let s = String::deserialize(deserializer)?;
+ Action::from_str(&s).map_err(serde::de::Error::custom)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_basic_actions() {
+ assert_eq!(Action::from_str("cursor-left").unwrap(), Action::CursorLeft);
+ assert_eq!(Action::from_str("accept").unwrap(), Action::Accept);
+ assert_eq!(Action::from_str("exit").unwrap(), Action::Exit);
+ assert_eq!(Action::from_str("noop").unwrap(), Action::Noop);
+ assert_eq!(
+ Action::from_str("vim-enter-normal").unwrap(),
+ Action::VimEnterNormal
+ );
+ }
+
+ #[test]
+ fn parse_accept_nth() {
+ assert_eq!(Action::from_str("accept-1").unwrap(), Action::AcceptNth(1));
+ assert_eq!(Action::from_str("accept-9").unwrap(), Action::AcceptNth(9));
+ }
+
+ #[test]
+ fn parse_return_selection() {
+ assert_eq!(
+ Action::from_str("return-selection").unwrap(),
+ Action::ReturnSelection
+ );
+ assert_eq!(
+ Action::from_str("return-selection-1").unwrap(),
+ Action::ReturnSelectionNth(1)
+ );
+ assert_eq!(
+ Action::from_str("return-selection-9").unwrap(),
+ Action::ReturnSelectionNth(9)
+ );
+ }
+
+ #[test]
+ fn parse_unknown_action() {
+ assert!(Action::from_str("unknown-action").is_err());
+ assert!(Action::from_str("accept-0").is_err());
+ assert!(Action::from_str("accept-10").is_err());
+ assert!(Action::from_str("return-selection-0").is_err());
+ assert!(Action::from_str("return-selection-10").is_err());
+ }
+
+ #[test]
+ fn round_trip() {
+ let actions = vec![
+ Action::CursorLeft,
+ Action::Accept,
+ Action::AcceptNth(5),
+ Action::ReturnSelection,
+ Action::ReturnSelectionNth(3),
+ Action::VimSearchInsert,
+ Action::ScrollToScreenMiddle,
+ ];
+ for action in actions {
+ let s = action.as_str();
+ let parsed = Action::from_str(&s).unwrap();
+ assert_eq!(action, parsed);
+ }
+ }
+
+ #[test]
+ fn serde_round_trip() {
+ let action = Action::CursorLeft;
+ let json = serde_json::to_string(&action).unwrap();
+ assert_eq!(json, "\"cursor-left\"");
+ let parsed: Action = serde_json::from_str(&json).unwrap();
+ assert_eq!(parsed, Action::CursorLeft);
+
+ let action = Action::AcceptNth(3);
+ let json = serde_json::to_string(&action).unwrap();
+ assert_eq!(json, "\"accept-3\"");
+ let parsed: Action = serde_json::from_str(&json).unwrap();
+ assert_eq!(parsed, Action::AcceptNth(3));
+ }
+}
diff --git a/crates/turtle/src/command/client/search/keybindings/conditions.rs b/crates/turtle/src/command/client/search/keybindings/conditions.rs
new file mode 100644
index 00000000..055ae905
--- /dev/null
+++ b/crates/turtle/src/command/client/search/keybindings/conditions.rs
@@ -0,0 +1,801 @@
+use std::fmt;
+
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+/// Atomic (leaf) conditions that can be evaluated against state.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ConditionAtom {
+ CursorAtStart,
+ CursorAtEnd,
+ InputEmpty,
+ OriginalInputEmpty,
+ ListAtEnd,
+ ListAtStart,
+ NoResults,
+ HasResults,
+ HasContext,
+}
+
+/// Boolean expression tree over condition atoms.
+///
+/// Supports negation, conjunction, and disjunction with standard precedence:
+/// `!` binds tightest, then `&&`, then `||`.
+///
+/// Examples of valid expression strings:
+/// - `"cursor-at-start"` (bare atom)
+/// - `"!no-results"` (negation)
+/// - `"cursor-at-start && input-empty"` (conjunction)
+/// - `"list-at-start || no-results"` (disjunction)
+/// - `"(cursor-at-start && !input-empty) || no-results"` (grouping)
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ConditionExpr {
+ Atom(ConditionAtom),
+ Not(Box<ConditionExpr>),
+ And(Box<ConditionExpr>, Box<ConditionExpr>),
+ Or(Box<ConditionExpr>, Box<ConditionExpr>),
+}
+
+/// Context needed to evaluate conditions. This is a pure snapshot of state —
+/// no references to mutable data.
+pub struct EvalContext {
+ /// Current cursor position (unicode width units).
+ pub cursor_position: usize,
+ /// Width of the input string in unicode width units.
+ pub input_width: usize,
+ /// Byte length of the input string.
+ pub input_byte_len: usize,
+ /// Currently selected index in the results list.
+ pub selected_index: usize,
+ /// Total number of results.
+ pub results_len: usize,
+ /// Whether the original input (query passed to the TUI) was empty.
+ pub original_input_empty: bool,
+ /// Whether we use a search context of a command from the history.
+ pub has_context: bool,
+}
+
+// ---------------------------------------------------------------------------
+// ConditionAtom
+// ---------------------------------------------------------------------------
+
+impl ConditionAtom {
+ /// Evaluate this atom against the given context.
+ pub fn evaluate(&self, ctx: &EvalContext) -> bool {
+ match self {
+ ConditionAtom::CursorAtStart => ctx.cursor_position == 0,
+ ConditionAtom::CursorAtEnd => ctx.cursor_position == ctx.input_width,
+ ConditionAtom::InputEmpty => ctx.input_byte_len == 0,
+ ConditionAtom::OriginalInputEmpty => ctx.original_input_empty,
+ ConditionAtom::ListAtEnd => {
+ ctx.results_len == 0 || ctx.selected_index >= ctx.results_len.saturating_sub(1)
+ }
+ ConditionAtom::ListAtStart => ctx.results_len == 0 || ctx.selected_index == 0,
+ ConditionAtom::NoResults => ctx.results_len == 0,
+ ConditionAtom::HasResults => ctx.results_len > 0,
+ ConditionAtom::HasContext => ctx.has_context,
+ }
+ }
+
+ /// Parse from a kebab-case string.
+ pub fn from_str(s: &str) -> Result<Self, String> {
+ match s {
+ "cursor-at-start" => Ok(ConditionAtom::CursorAtStart),
+ "cursor-at-end" => Ok(ConditionAtom::CursorAtEnd),
+ "input-empty" => Ok(ConditionAtom::InputEmpty),
+ "original-input-empty" => Ok(ConditionAtom::OriginalInputEmpty),
+ "list-at-end" => Ok(ConditionAtom::ListAtEnd),
+ "list-at-start" => Ok(ConditionAtom::ListAtStart),
+ "no-results" => Ok(ConditionAtom::NoResults),
+ "has-results" => Ok(ConditionAtom::HasResults),
+ "has-context" => Ok(ConditionAtom::HasContext),
+ _ => Err(format!("unknown condition: {s}")),
+ }
+ }
+
+ /// Convert to a kebab-case string.
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ ConditionAtom::CursorAtStart => "cursor-at-start",
+ ConditionAtom::CursorAtEnd => "cursor-at-end",
+ ConditionAtom::InputEmpty => "input-empty",
+ ConditionAtom::OriginalInputEmpty => "original-input-empty",
+ ConditionAtom::ListAtEnd => "list-at-end",
+ ConditionAtom::ListAtStart => "list-at-start",
+ ConditionAtom::NoResults => "no-results",
+ ConditionAtom::HasResults => "has-results",
+ ConditionAtom::HasContext => "has-context",
+ }
+ }
+}
+
+impl fmt::Display for ConditionAtom {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.as_str())
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ConditionExpr — evaluation
+// ---------------------------------------------------------------------------
+
+impl ConditionExpr {
+ /// Evaluate this expression against the given context.
+ pub fn evaluate(&self, ctx: &EvalContext) -> bool {
+ match self {
+ ConditionExpr::Atom(atom) => atom.evaluate(ctx),
+ ConditionExpr::Not(inner) => !inner.evaluate(ctx),
+ ConditionExpr::And(lhs, rhs) => lhs.evaluate(ctx) && rhs.evaluate(ctx),
+ ConditionExpr::Or(lhs, rhs) => lhs.evaluate(ctx) || rhs.evaluate(ctx),
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ConditionExpr — ergonomic builders
+// ---------------------------------------------------------------------------
+
+impl From<ConditionAtom> for ConditionExpr {
+ fn from(atom: ConditionAtom) -> Self {
+ ConditionExpr::Atom(atom)
+ }
+}
+
+#[expect(dead_code)]
+impl ConditionExpr {
+ /// Negate this expression: `!self`.
+ pub fn not(self) -> Self {
+ ConditionExpr::Not(Box::new(self))
+ }
+
+ /// Conjoin with another expression: `self && other`.
+ pub fn and(self, other: ConditionExpr) -> Self {
+ ConditionExpr::And(Box::new(self), Box::new(other))
+ }
+
+ /// Disjoin with another expression: `self || other`.
+ pub fn or(self, other: ConditionExpr) -> Self {
+ ConditionExpr::Or(Box::new(self), Box::new(other))
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ConditionExpr — parser
+// ---------------------------------------------------------------------------
+
+/// Recursive descent parser for boolean condition expressions.
+///
+/// Grammar (standard boolean precedence):
+/// ```text
+/// expr = or_expr
+/// or_expr = and_expr ("||" and_expr)*
+/// and_expr = unary ("&&" unary)*
+/// unary = "!" unary | primary
+/// primary = atom | "(" expr ")"
+/// atom = [a-z][a-z0-9-]*
+/// ```
+struct ExprParser<'a> {
+ input: &'a str,
+ pos: usize,
+}
+
+impl<'a> ExprParser<'a> {
+ fn new(input: &'a str) -> Self {
+ Self { input, pos: 0 }
+ }
+
+ fn skip_whitespace(&mut self) {
+ while self.pos < self.input.len() && self.input.as_bytes()[self.pos].is_ascii_whitespace() {
+ self.pos += 1;
+ }
+ }
+
+ fn starts_with(&mut self, s: &str) -> bool {
+ self.skip_whitespace();
+ self.input[self.pos..].starts_with(s)
+ }
+
+ fn consume(&mut self, s: &str) -> bool {
+ self.skip_whitespace();
+ if self.input[self.pos..].starts_with(s) {
+ self.pos += s.len();
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Parse a full expression, expecting to consume all input.
+ fn parse(mut self) -> Result<ConditionExpr, String> {
+ let expr = self.parse_or()?;
+ self.skip_whitespace();
+ if self.pos < self.input.len() {
+ return Err(format!(
+ "unexpected input at position {}: {:?}",
+ self.pos,
+ &self.input[self.pos..]
+ ));
+ }
+ Ok(expr)
+ }
+
+ /// `or_expr` = `and_expr` ("||" `and_expr`)*
+ fn parse_or(&mut self) -> Result<ConditionExpr, String> {
+ let mut left = self.parse_and()?;
+ while self.starts_with("||") {
+ self.consume("||");
+ let right = self.parse_and()?;
+ left = ConditionExpr::Or(Box::new(left), Box::new(right));
+ }
+ Ok(left)
+ }
+
+ /// `and_expr` = unary ("&&" unary)*
+ fn parse_and(&mut self) -> Result<ConditionExpr, String> {
+ let mut left = self.parse_unary()?;
+ while self.starts_with("&&") {
+ self.consume("&&");
+ let right = self.parse_unary()?;
+ left = ConditionExpr::And(Box::new(left), Box::new(right));
+ }
+ Ok(left)
+ }
+
+ /// unary = "!" unary | primary
+ fn parse_unary(&mut self) -> Result<ConditionExpr, String> {
+ if self.consume("!") {
+ let inner = self.parse_unary()?;
+ Ok(ConditionExpr::Not(Box::new(inner)))
+ } else {
+ self.parse_primary()
+ }
+ }
+
+ /// primary = "(" expr ")" | atom
+ fn parse_primary(&mut self) -> Result<ConditionExpr, String> {
+ if self.consume("(") {
+ let expr = self.parse_or()?;
+ if !self.consume(")") {
+ return Err(format!("expected ')' at position {}", self.pos));
+ }
+ Ok(expr)
+ } else {
+ self.parse_atom()
+ }
+ }
+
+ /// atom = [a-z][a-z0-9-]*
+ fn parse_atom(&mut self) -> Result<ConditionExpr, String> {
+ self.skip_whitespace();
+ let start = self.pos;
+ while self.pos < self.input.len() {
+ let b = self.input.as_bytes()[self.pos];
+ if b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' {
+ self.pos += 1;
+ } else {
+ break;
+ }
+ }
+ if self.pos == start {
+ return Err(format!("expected condition name at position {}", self.pos));
+ }
+ let name = &self.input[start..self.pos];
+ let atom = ConditionAtom::from_str(name)?;
+ Ok(ConditionExpr::Atom(atom))
+ }
+}
+
+impl ConditionExpr {
+ /// Parse a condition expression from a string.
+ pub fn parse(s: &str) -> Result<Self, String> {
+ let parser = ExprParser::new(s);
+ parser.parse()
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ConditionExpr — Display
+// ---------------------------------------------------------------------------
+
+/// Precedence levels for minimal-parentheses display.
+#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
+enum Prec {
+ Or = 0,
+ And = 1,
+ Not = 2,
+ Atom = 3,
+}
+
+impl ConditionExpr {
+ fn prec(&self) -> Prec {
+ match self {
+ ConditionExpr::Or(..) => Prec::Or,
+ ConditionExpr::And(..) => Prec::And,
+ ConditionExpr::Not(..) => Prec::Not,
+ ConditionExpr::Atom(..) => Prec::Atom,
+ }
+ }
+
+ fn fmt_with_prec(&self, f: &mut fmt::Formatter<'_>, parent_prec: Prec) -> fmt::Result {
+ let needs_parens = self.prec() < parent_prec;
+ if needs_parens {
+ write!(f, "(")?;
+ }
+ match self {
+ ConditionExpr::Atom(atom) => write!(f, "{atom}")?,
+ ConditionExpr::Not(inner) => {
+ write!(f, "!")?;
+ inner.fmt_with_prec(f, Prec::Not)?;
+ }
+ ConditionExpr::And(lhs, rhs) => {
+ lhs.fmt_with_prec(f, Prec::And)?;
+ write!(f, " && ")?;
+ rhs.fmt_with_prec(f, Prec::And)?;
+ }
+ ConditionExpr::Or(lhs, rhs) => {
+ lhs.fmt_with_prec(f, Prec::Or)?;
+ write!(f, " || ")?;
+ rhs.fmt_with_prec(f, Prec::Or)?;
+ }
+ }
+ if needs_parens {
+ write!(f, ")")?;
+ }
+ Ok(())
+ }
+}
+
+impl fmt::Display for ConditionExpr {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.fmt_with_prec(f, Prec::Or)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Serde
+// ---------------------------------------------------------------------------
+
+impl Serialize for ConditionExpr {
+ fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+ serializer.serialize_str(&self.to_string())
+ }
+}
+
+impl<'de> Deserialize<'de> for ConditionExpr {
+ fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+ let s = String::deserialize(deserializer)?;
+ ConditionExpr::parse(&s).map_err(serde::de::Error::custom)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn ctx(
+ cursor: usize,
+ width: usize,
+ byte_len: usize,
+ selected: usize,
+ len: usize,
+ ) -> EvalContext {
+ ctx_with_original(cursor, width, byte_len, selected, len, false)
+ }
+
+ fn ctx_with_original(
+ cursor: usize,
+ width: usize,
+ byte_len: usize,
+ selected: usize,
+ len: usize,
+ original_input_empty: bool,
+ ) -> EvalContext {
+ EvalContext {
+ cursor_position: cursor,
+ input_width: width,
+ input_byte_len: byte_len,
+ selected_index: selected,
+ results_len: len,
+ original_input_empty,
+ has_context: false,
+ }
+ }
+
+ // -- Atom evaluation (carried over from Phase 0) --
+
+ #[test]
+ fn atom_cursor_at_start() {
+ assert!(ConditionAtom::CursorAtStart.evaluate(&ctx(0, 5, 5, 0, 10)));
+ assert!(!ConditionAtom::CursorAtStart.evaluate(&ctx(3, 5, 5, 0, 10)));
+ }
+
+ #[test]
+ fn atom_cursor_at_end() {
+ assert!(ConditionAtom::CursorAtEnd.evaluate(&ctx(5, 5, 5, 0, 10)));
+ assert!(!ConditionAtom::CursorAtEnd.evaluate(&ctx(3, 5, 5, 0, 10)));
+ assert!(ConditionAtom::CursorAtEnd.evaluate(&ctx(0, 0, 0, 0, 10)));
+ }
+
+ #[test]
+ fn atom_input_empty() {
+ assert!(ConditionAtom::InputEmpty.evaluate(&ctx(0, 0, 0, 0, 10)));
+ assert!(!ConditionAtom::InputEmpty.evaluate(&ctx(0, 5, 5, 0, 10)));
+ }
+
+ #[test]
+ fn atom_original_input_empty() {
+ // original_input_empty = true
+ assert!(
+ ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 0, 0, 0, 10, true))
+ );
+ // original_input_empty = false
+ assert!(
+ !ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 0, 0, 0, 10, false))
+ );
+ // original_input_empty is independent of current input state
+ assert!(
+ ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 5, 5, 0, 10, true))
+ );
+ }
+
+ #[test]
+ fn atom_list_at_end() {
+ assert!(ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 99, 100)));
+ assert!(!ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 50, 100)));
+ assert!(ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 0, 0)));
+ }
+
+ #[test]
+ fn atom_list_at_start() {
+ assert!(ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 0, 100)));
+ assert!(!ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 50, 100)));
+ assert!(ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 0, 0)));
+ }
+
+ #[test]
+ fn atom_no_results_and_has_results() {
+ assert!(ConditionAtom::NoResults.evaluate(&ctx(0, 0, 0, 0, 0)));
+ assert!(!ConditionAtom::NoResults.evaluate(&ctx(0, 0, 0, 0, 5)));
+ assert!(ConditionAtom::HasResults.evaluate(&ctx(0, 0, 0, 0, 5)));
+ assert!(!ConditionAtom::HasResults.evaluate(&ctx(0, 0, 0, 0, 0)));
+ }
+
+ #[test]
+ fn atom_has_context() {
+ let mut context = ctx(0, 0, 0, 0, 0);
+ assert!(!ConditionAtom::HasContext.evaluate(&context));
+ context.has_context = true;
+ assert!(ConditionAtom::HasContext.evaluate(&context));
+ }
+
+ #[test]
+ fn atom_parse_round_trip() {
+ let conditions = [
+ "cursor-at-start",
+ "cursor-at-end",
+ "input-empty",
+ "original-input-empty",
+ "list-at-end",
+ "list-at-start",
+ "no-results",
+ "has-results",
+ ];
+ for s in conditions {
+ let c = ConditionAtom::from_str(s).unwrap();
+ assert_eq!(c.as_str(), s);
+ }
+ }
+
+ #[test]
+ fn atom_parse_unknown() {
+ assert!(ConditionAtom::from_str("unknown-condition").is_err());
+ }
+
+ // -- Parser tests --
+
+ #[test]
+ fn parse_bare_atom() {
+ let expr = ConditionExpr::parse("cursor-at-start").unwrap();
+ assert_eq!(expr, ConditionExpr::Atom(ConditionAtom::CursorAtStart));
+ }
+
+ #[test]
+ fn parse_negation() {
+ let expr = ConditionExpr::parse("!no-results").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::Not(Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)))
+ );
+ }
+
+ #[test]
+ fn parse_double_negation() {
+ let expr = ConditionExpr::parse("!!no-results").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::Not(Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom(
+ ConditionAtom::NoResults
+ )))))
+ );
+ }
+
+ #[test]
+ fn parse_and() {
+ let expr = ConditionExpr::parse("cursor-at-start && input-empty").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::And(
+ Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
+ Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)),
+ )
+ );
+ }
+
+ #[test]
+ fn parse_or() {
+ let expr = ConditionExpr::parse("list-at-start || no-results").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::Or(
+ Box::new(ConditionExpr::Atom(ConditionAtom::ListAtStart)),
+ Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
+ )
+ );
+ }
+
+ #[test]
+ fn parse_precedence_and_binds_tighter_than_or() {
+ // "a || b && c" should parse as "a || (b && c)"
+ let expr = ConditionExpr::parse("cursor-at-start || input-empty && no-results").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::Or(
+ Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
+ Box::new(ConditionExpr::And(
+ Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)),
+ Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
+ )),
+ )
+ );
+ }
+
+ #[test]
+ fn parse_parens_override_precedence() {
+ // "(a || b) && c"
+ let expr = ConditionExpr::parse("(cursor-at-start || input-empty) && no-results").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::And(
+ Box::new(ConditionExpr::Or(
+ Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
+ Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)),
+ )),
+ Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
+ )
+ );
+ }
+
+ #[test]
+ fn parse_complex_nested() {
+ // "(a && !b) || c"
+ let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::Or(
+ Box::new(ConditionExpr::And(
+ Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
+ Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom(
+ ConditionAtom::InputEmpty
+ )))),
+ )),
+ Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
+ )
+ );
+ }
+
+ #[test]
+ fn parse_whitespace_tolerance() {
+ let a = ConditionExpr::parse("cursor-at-start||input-empty").unwrap();
+ let b = ConditionExpr::parse("cursor-at-start || input-empty").unwrap();
+ let c = ConditionExpr::parse(" cursor-at-start || input-empty ").unwrap();
+ assert_eq!(a, b);
+ assert_eq!(b, c);
+ }
+
+ #[test]
+ fn parse_error_unknown_atom() {
+ assert!(ConditionExpr::parse("unknown-thing").is_err());
+ }
+
+ #[test]
+ fn parse_error_trailing_input() {
+ assert!(ConditionExpr::parse("cursor-at-start blah").is_err());
+ }
+
+ #[test]
+ fn parse_error_unmatched_paren() {
+ assert!(ConditionExpr::parse("(cursor-at-start").is_err());
+ }
+
+ #[test]
+ fn parse_error_empty() {
+ assert!(ConditionExpr::parse("").is_err());
+ }
+
+ // -- Expression evaluation --
+
+ #[test]
+ fn eval_not() {
+ let expr = ConditionExpr::parse("!no-results").unwrap();
+ // Has results → !no-results is true
+ assert!(expr.evaluate(&ctx(0, 0, 0, 0, 5)));
+ // No results → !no-results is false
+ assert!(!expr.evaluate(&ctx(0, 0, 0, 0, 0)));
+ }
+
+ #[test]
+ fn eval_and() {
+ let expr = ConditionExpr::parse("cursor-at-start && input-empty").unwrap();
+ // Both true
+ assert!(expr.evaluate(&ctx(0, 0, 0, 0, 10)));
+ // First true, second false (non-empty input)
+ assert!(!expr.evaluate(&ctx(0, 5, 5, 0, 10)));
+ // First false (cursor not at start)
+ assert!(!expr.evaluate(&ctx(3, 5, 5, 0, 10)));
+ }
+
+ #[test]
+ fn eval_or() {
+ let expr = ConditionExpr::parse("list-at-start || no-results").unwrap();
+ // list at bottom (selected=0)
+ assert!(expr.evaluate(&ctx(0, 0, 0, 0, 10)));
+ // no results
+ assert!(expr.evaluate(&ctx(0, 0, 0, 0, 0)));
+ // neither
+ assert!(!expr.evaluate(&ctx(0, 0, 0, 5, 10)));
+ }
+
+ #[test]
+ fn eval_complex_nested() {
+ // (cursor-at-start && !input-empty) || no-results
+ let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap();
+
+ // cursor at start, input not empty → true (left branch)
+ assert!(expr.evaluate(&ctx(0, 5, 5, 0, 10)));
+ // no results → true (right branch)
+ assert!(expr.evaluate(&ctx(3, 5, 5, 0, 0)));
+ // cursor not at start, has results → false
+ assert!(!expr.evaluate(&ctx(3, 5, 5, 0, 10)));
+ // cursor at start, input empty → false (left: && fails; right: has results)
+ assert!(!expr.evaluate(&ctx(0, 0, 0, 0, 10)));
+ }
+
+ // -- Display --
+
+ #[test]
+ fn display_atom() {
+ let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart);
+ assert_eq!(expr.to_string(), "cursor-at-start");
+ }
+
+ #[test]
+ fn display_not() {
+ let expr = ConditionExpr::Atom(ConditionAtom::NoResults).not();
+ assert_eq!(expr.to_string(), "!no-results");
+ }
+
+ #[test]
+ fn display_and() {
+ let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart)
+ .and(ConditionExpr::Atom(ConditionAtom::InputEmpty));
+ assert_eq!(expr.to_string(), "cursor-at-start && input-empty");
+ }
+
+ #[test]
+ fn display_or() {
+ let expr = ConditionExpr::Atom(ConditionAtom::ListAtStart)
+ .or(ConditionExpr::Atom(ConditionAtom::NoResults));
+ assert_eq!(expr.to_string(), "list-at-start || no-results");
+ }
+
+ #[test]
+ fn display_parens_when_needed() {
+ // (a || b) && c — the Or inside And needs parens
+ let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart)
+ .or(ConditionExpr::Atom(ConditionAtom::InputEmpty))
+ .and(ConditionExpr::Atom(ConditionAtom::NoResults));
+ assert_eq!(
+ expr.to_string(),
+ "(cursor-at-start || input-empty) && no-results"
+ );
+ }
+
+ #[test]
+ fn display_no_parens_when_not_needed() {
+ // a || b && c — no parens needed (and binds tighter)
+ let inner_and = ConditionExpr::Atom(ConditionAtom::InputEmpty)
+ .and(ConditionExpr::Atom(ConditionAtom::NoResults));
+ let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart).or(inner_and);
+ assert_eq!(
+ expr.to_string(),
+ "cursor-at-start || input-empty && no-results"
+ );
+ }
+
+ // -- Display round-trip --
+
+ #[test]
+ fn display_round_trip() {
+ let cases = [
+ "cursor-at-start",
+ "!no-results",
+ "cursor-at-start && input-empty",
+ "list-at-start || no-results",
+ "(cursor-at-start || input-empty) && no-results",
+ "(cursor-at-start && !input-empty) || no-results",
+ ];
+ for s in cases {
+ let expr = ConditionExpr::parse(s).unwrap();
+ let displayed = expr.to_string();
+ let reparsed = ConditionExpr::parse(&displayed).unwrap();
+ assert_eq!(expr, reparsed, "round-trip failed for: {s}");
+ }
+ }
+
+ // -- Serde --
+
+ #[test]
+ fn serde_simple_atom() {
+ let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart);
+ let json = serde_json::to_string(&expr).unwrap();
+ assert_eq!(json, "\"cursor-at-start\"");
+ let parsed: ConditionExpr = serde_json::from_str(&json).unwrap();
+ assert_eq!(parsed, expr);
+ }
+
+ #[test]
+ fn serde_compound_expression() {
+ let json = "\"cursor-at-start && !input-empty\"";
+ let parsed: ConditionExpr = serde_json::from_str(json).unwrap();
+ let expected = ConditionExpr::And(
+ Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
+ Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom(
+ ConditionAtom::InputEmpty,
+ )))),
+ );
+ assert_eq!(parsed, expected);
+ }
+
+ #[test]
+ fn serde_round_trip() {
+ let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap();
+ let json = serde_json::to_string(&expr).unwrap();
+ let parsed: ConditionExpr = serde_json::from_str(&json).unwrap();
+ assert_eq!(expr, parsed);
+ }
+
+ // -- From<ConditionAtom> --
+
+ #[test]
+ fn from_atom_into_expr() {
+ let expr: ConditionExpr = ConditionAtom::CursorAtStart.into();
+ assert_eq!(expr, ConditionExpr::Atom(ConditionAtom::CursorAtStart));
+ }
+
+ // -- Builder helpers --
+
+ #[test]
+ fn builder_chain() {
+ let expr = ConditionExpr::from(ConditionAtom::CursorAtStart)
+ .and(ConditionExpr::from(ConditionAtom::InputEmpty).not())
+ .or(ConditionExpr::from(ConditionAtom::NoResults));
+ // And binds tighter than Or, so no parens needed around the And
+ assert_eq!(
+ expr.to_string(),
+ "cursor-at-start && !input-empty || no-results"
+ );
+ }
+}
diff --git a/crates/turtle/src/command/client/search/keybindings/defaults.rs b/crates/turtle/src/command/client/search/keybindings/defaults.rs
new file mode 100644
index 00000000..c8401e37
--- /dev/null
+++ b/crates/turtle/src/command/client/search/keybindings/defaults.rs
@@ -0,0 +1,1286 @@
+use std::collections::HashMap;
+
+use crate::atuin_client::settings::{KeyBindingConfig, Settings};
+use tracing::warn;
+
+use super::actions::Action;
+use super::conditions::{ConditionAtom, ConditionExpr};
+use super::key::KeyInput;
+use super::keymap::{KeyBinding, KeyRule, Keymap};
+
+/// Helper to bind a scroll key with optional exit behavior.
+///
+/// When `scroll_exits` is true AND the key scrolls toward index 0 (the newest
+/// entry), we add a conditional rule: at `ListAtStart` → `Exit`, otherwise →
+/// the scroll action.
+///
+/// Whether a key scrolls toward index 0 depends on the `invert` setting:
+/// - Non-inverted: "down" / "j" move toward index 0, "up" / "k" move away
+/// - Inverted: "up" / "k" move toward index 0, "down" / "j" move away
+///
+/// If `toward_index_zero` is false, or `scroll_exits` is false, we just bind
+/// the key to the plain scroll action (no exit).
+fn bind_scroll_key(
+ km: &mut Keymap,
+ key_str: &str,
+ action: Action,
+ toward_index_zero: bool,
+ scroll_exits: bool,
+) {
+ let k = key(key_str);
+ if scroll_exits && toward_index_zero {
+ km.bind_conditional(
+ k,
+ vec![
+ KeyRule::when(ConditionAtom::ListAtStart, Action::Exit),
+ KeyRule::always(action),
+ ],
+ );
+ } else {
+ km.bind(k, action);
+ }
+}
+
+/// Helper to parse a key string, panicking on invalid keys (these are all
+/// compile-time-known strings).
+fn key(s: &str) -> KeyInput {
+ KeyInput::parse(s).unwrap_or_else(|e| panic!("invalid default key {s:?}: {e}"))
+}
+
+/// All five keymaps bundled together.
+#[derive(Debug, Clone)]
+pub struct KeymapSet {
+ pub emacs: Keymap,
+ pub vim_normal: Keymap,
+ pub vim_insert: Keymap,
+ pub inspector: Keymap,
+ pub prefix: Keymap,
+}
+
+// ---------------------------------------------------------------------------
+// Common bindings shared across search-tab keymaps
+// ---------------------------------------------------------------------------
+
+/// Add the bindings that are common to all search-tab keymaps:
+/// ctrl-c, ctrl-g, ctrl-o, and tab.
+///
+/// Note: `esc`/`ctrl-[` are NOT included here because their behavior differs
+/// between emacs (exit), vim-normal (exit), and vim-insert (enter normal mode).
+fn add_common_bindings(km: &mut Keymap) {
+ km.bind(key("ctrl-c"), Action::ReturnOriginal);
+ km.bind(key("ctrl-g"), Action::ReturnOriginal);
+ km.bind(key("ctrl-o"), Action::ToggleTab);
+
+ // Tab: always returns selection without executing (unlike Enter which respects enter_accept)
+ km.bind(key("tab"), Action::ReturnSelection);
+}
+
+/// Returns `Accept` or `ReturnSelection` based on the `enter_accept` setting.
+fn accept_action(settings: &Settings) -> Action {
+ if settings.enter_accept {
+ Action::Accept
+ } else {
+ Action::ReturnSelection
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Emacs keymap (also base for vim-insert)
+// ---------------------------------------------------------------------------
+
+/// Build the default emacs keymap. This encodes the behavior from
+/// `handle_key_input` common section + `handle_search_input` shared section.
+///
+/// The `settings` parameter is used for:
+/// - `keys.prefix` — which ctrl-key enters prefix mode
+/// - `keys.scroll_exits`, `invert` — scroll-at-boundary exit behavior
+/// - `keys.accept_past_line_end` — right arrow at end of line accepts
+/// - `keys.exit_past_line_start` — left arrow at start of line exits
+/// - `keys.accept_past_line_start` — left arrow at start accepts (overrides exit)
+/// - `keys.accept_with_backspace` — backspace at start of line accepts
+/// - `ctrl_n_shortcuts` — whether alt or ctrl is used for numeric shortcuts
+// Keymap builder that enumerates every default binding; not worth splitting.
+#[expect(clippy::too_many_lines)]
+pub fn default_emacs_keymap(settings: &Settings) -> Keymap {
+ let mut km = Keymap::new();
+ add_common_bindings(&mut km);
+
+ let accept = accept_action(settings);
+
+ // esc / ctrl-[ → exit
+ km.bind(key("esc"), Action::Exit);
+ km.bind(key("ctrl-["), Action::Exit);
+
+ // Prefix key: ctrl-<prefix_char> → enter prefix mode
+ let prefix_char = settings.keys.prefix.chars().next().unwrap_or('a');
+ km.bind(key(&format!("ctrl-{prefix_char}")), Action::EnterPrefixMode);
+
+ // --- Accept / navigation edge behaviors (from [keys] settings) ---
+
+ // right: behavior at end of line
+ if settings.keys.accept_past_line_end {
+ km.bind_conditional(
+ key("right"),
+ vec![
+ KeyRule::when(ConditionAtom::CursorAtEnd, Action::ReturnSelection),
+ KeyRule::always(Action::CursorRight),
+ ],
+ );
+ } else {
+ km.bind(key("right"), Action::CursorRight);
+ }
+
+ // left: behavior at start of line
+ // accept_past_line_start takes precedence over exit_past_line_start
+ if settings.keys.accept_past_line_start {
+ km.bind_conditional(
+ key("left"),
+ vec![
+ KeyRule::when(ConditionAtom::CursorAtStart, Action::ReturnSelection),
+ KeyRule::always(Action::CursorLeft),
+ ],
+ );
+ } else if settings.keys.exit_past_line_start {
+ km.bind_conditional(
+ key("left"),
+ vec![
+ KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit),
+ KeyRule::always(Action::CursorLeft),
+ ],
+ );
+ } else {
+ km.bind(key("left"), Action::CursorLeft);
+ }
+
+ // down/up: scroll with optional exit at boundary.
+ // Non-inverted: down moves toward index 0 (can exit); up moves away (no exit).
+ // Inverted: up moves toward index 0 (can exit); down moves away (no exit).
+ let scroll_exits = settings.keys.scroll_exits;
+ let invert = settings.invert;
+ bind_scroll_key(&mut km, "down", Action::SelectNext, !invert, scroll_exits);
+ bind_scroll_key(&mut km, "up", Action::SelectPrevious, invert, scroll_exits);
+
+ // backspace: behavior at start of line
+ if settings.keys.accept_with_backspace {
+ km.bind_conditional(
+ key("backspace"),
+ vec![
+ KeyRule::when(ConditionAtom::CursorAtStart, Action::ReturnSelection),
+ KeyRule::always(Action::DeleteCharBefore),
+ ],
+ );
+ } else {
+ km.bind(key("backspace"), Action::DeleteCharBefore);
+ }
+
+ // --- Accept ---
+ km.bind(key("enter"), accept.clone());
+ km.bind(key("ctrl-m"), accept);
+
+ // --- Copy ---
+ km.bind(key("ctrl-y"), Action::Copy);
+
+ // --- Numeric shortcuts (alt-1..9 by default, ctrl-1..9 if ctrl_n_shortcuts) ---
+ // These return the selection without executing, regardless of enter_accept.
+ let num_mod = if settings.ctrl_n_shortcuts {
+ "ctrl"
+ } else {
+ "alt"
+ };
+ for n in 1..=9u8 {
+ km.bind(
+ key(&format!("{num_mod}-{n}")),
+ Action::ReturnSelectionNth(n),
+ );
+ }
+
+ // --- Cursor movement ---
+ km.bind(key("ctrl-left"), Action::CursorWordLeft);
+ km.bind(key("alt-b"), Action::CursorWordLeft);
+ km.bind(key("ctrl-b"), Action::CursorLeft);
+ km.bind(key("ctrl-right"), Action::CursorWordRight);
+ km.bind(key("alt-f"), Action::CursorWordRight);
+ km.bind(key("ctrl-f"), Action::CursorRight);
+ km.bind(key("home"), Action::CursorStart);
+ // ctrl-a → CursorStart only if prefix char is NOT 'a'
+ // (otherwise ctrl-a is already bound to EnterPrefixMode above)
+ if prefix_char != 'a' {
+ km.bind(key("ctrl-a"), Action::CursorStart);
+ }
+ km.bind(key("ctrl-e"), Action::CursorEnd);
+ km.bind(key("end"), Action::CursorEnd);
+
+ // --- Editing ---
+ km.bind(key("ctrl-backspace"), Action::DeleteWordBefore);
+ km.bind(key("ctrl-h"), Action::DeleteCharBefore);
+ km.bind(key("ctrl-?"), Action::DeleteCharBefore);
+ km.bind(key("ctrl-delete"), Action::DeleteWordAfter);
+ km.bind(key("delete"), Action::DeleteCharAfter);
+ // ctrl-d: if input empty → return original, otherwise delete char
+ km.bind_conditional(
+ key("ctrl-d"),
+ vec![
+ KeyRule::when(ConditionAtom::InputEmpty, Action::ReturnOriginal),
+ KeyRule::always(Action::DeleteCharAfter),
+ ],
+ );
+ km.bind(key("ctrl-w"), Action::DeleteToWordBoundary);
+ km.bind(key("ctrl-u"), Action::ClearLine);
+
+ // --- Search mode ---
+ km.bind(key("ctrl-r"), Action::CycleFilterMode);
+ km.bind(key("ctrl-s"), Action::CycleSearchMode);
+
+ // --- Scroll (no exit) ---
+ km.bind(key("ctrl-n"), Action::SelectNext);
+ km.bind(key("ctrl-j"), Action::SelectNext);
+ km.bind(key("ctrl-p"), Action::SelectPrevious);
+ km.bind(key("ctrl-k"), Action::SelectPrevious);
+
+ // --- Redraw ---
+ km.bind(key("ctrl-l"), Action::Redraw);
+
+ // --- Page scroll ---
+ km.bind(key("pagedown"), Action::ScrollPageDown);
+ km.bind(key("pageup"), Action::ScrollPageUp);
+
+ km
+}
+
+// ---------------------------------------------------------------------------
+// Vim Normal keymap
+// ---------------------------------------------------------------------------
+
+/// Build the default vim-normal keymap.
+pub fn default_vim_normal_keymap(settings: &Settings) -> Keymap {
+ let mut km = Keymap::new();
+ add_common_bindings(&mut km);
+
+ // esc / ctrl-[ → exit (vim-normal exits, unlike vim-insert)
+ km.bind(key("esc"), Action::Exit);
+ km.bind(key("ctrl-["), Action::Exit);
+
+ // Prefix key
+ let prefix_char = settings.keys.prefix.chars().next().unwrap_or('a');
+ km.bind(key(&format!("ctrl-{prefix_char}")), Action::EnterPrefixMode);
+
+ // --- Vim navigation ---
+ // j/k: scroll with optional exit at boundary.
+ let scroll_exits = settings.keys.scroll_exits;
+ let invert = settings.invert;
+ bind_scroll_key(&mut km, "j", Action::SelectNext, !invert, scroll_exits);
+ bind_scroll_key(&mut km, "k", Action::SelectPrevious, invert, scroll_exits);
+ km.bind(key("h"), Action::CursorLeft);
+ km.bind(key("l"), Action::CursorRight);
+
+ // --- Vim cursor movement ---
+ km.bind(key("0"), Action::CursorStart);
+ km.bind(key("$"), Action::CursorEnd);
+ km.bind(key("w"), Action::CursorWordRight);
+ km.bind(key("b"), Action::CursorWordLeft);
+ km.bind(key("e"), Action::CursorWordEnd);
+
+ // --- Vim editing ---
+ km.bind(key("x"), Action::DeleteCharAfter);
+ km.bind(key("d d"), Action::ClearLine);
+ km.bind(key("D"), Action::ClearToEnd);
+ km.bind(key("C"), Action::VimChangeToEnd);
+
+ // --- Mode switching ---
+ km.bind(key("?"), Action::VimSearchInsert);
+ km.bind(key("/"), Action::VimSearchInsert);
+ km.bind(key("a"), Action::VimEnterInsertAfter);
+ km.bind(key("A"), Action::VimEnterInsertAtEnd);
+ km.bind(key("i"), Action::VimEnterInsert);
+ km.bind(key("I"), Action::VimEnterInsertAtStart);
+
+ // --- Numeric shortcuts (return selection without executing) ---
+ for n in 1..=9u8 {
+ km.bind(key(&n.to_string()), Action::ReturnSelectionNth(n));
+ }
+
+ // --- Half/full page scroll ---
+ km.bind(key("ctrl-u"), Action::ScrollHalfPageUp);
+ km.bind(key("ctrl-d"), Action::ScrollHalfPageDown);
+ km.bind(key("ctrl-b"), Action::ScrollPageUp);
+ km.bind(key("ctrl-f"), Action::ScrollPageDown);
+
+ // --- Jump ---
+ km.bind(key("G"), Action::ScrollToBottom);
+ km.bind(key("g g"), Action::ScrollToTop);
+ km.bind(key("H"), Action::ScrollToScreenTop);
+ km.bind(key("M"), Action::ScrollToScreenMiddle);
+ km.bind(key("L"), Action::ScrollToScreenBottom);
+
+ // --- Arrow keys (same as emacs for convenience) ---
+ bind_scroll_key(&mut km, "down", Action::SelectNext, !invert, scroll_exits);
+ bind_scroll_key(&mut km, "up", Action::SelectPrevious, invert, scroll_exits);
+
+ // --- Page scroll ---
+ km.bind(key("pagedown"), Action::ScrollPageDown);
+ km.bind(key("pageup"), Action::ScrollPageUp);
+
+ // --- Accept ---
+ let accept = accept_action(settings);
+ km.bind(key("enter"), accept);
+
+ km
+}
+
+// ---------------------------------------------------------------------------
+// Vim Insert keymap
+// ---------------------------------------------------------------------------
+
+/// Build the default vim-insert keymap. This clones the emacs keymap and
+/// overlays vim-insert-specific bindings (esc → enter normal mode).
+pub fn default_vim_insert_keymap(settings: &Settings) -> Keymap {
+ let mut km = default_emacs_keymap(settings);
+
+ // Override esc and ctrl-[ to enter normal mode instead of exiting
+ km.bind(key("esc"), Action::VimEnterNormal);
+ km.bind(key("ctrl-["), Action::VimEnterNormal);
+
+ km
+}
+
+// ---------------------------------------------------------------------------
+// Inspector keymap
+// ---------------------------------------------------------------------------
+
+/// Build the default inspector keymap (tab index 1).
+///
+/// The inspector shows details about the selected history item and has no
+/// text input, so we build a minimal keymap with only inspector-relevant
+/// bindings. We respect the user's `keymap_mode` to provide vim-style j/k
+/// navigation for vim users.
+pub fn default_inspector_keymap(settings: &Settings) -> Keymap {
+ use crate::atuin_client::settings::KeymapMode;
+
+ let mut km = Keymap::new();
+
+ // Common bindings (same as search tab)
+ km.bind(key("ctrl-c"), Action::ReturnOriginal);
+ km.bind(key("ctrl-g"), Action::ReturnOriginal);
+ km.bind(key("esc"), Action::Exit);
+ km.bind(key("ctrl-["), Action::Exit);
+ km.bind(key("tab"), Action::ReturnSelection);
+ km.bind(key("ctrl-o"), Action::ToggleTab);
+
+ // Accept behavior respects enter_accept setting
+ let accept = if settings.enter_accept {
+ Action::Accept
+ } else {
+ Action::ReturnSelection
+ };
+ km.bind(key("enter"), accept);
+
+ // Inspector-specific: delete history entry
+ km.bind(key("ctrl-d"), Action::Delete);
+
+ // Inspector navigation
+ km.bind(key("up"), Action::InspectPrevious);
+ km.bind(key("down"), Action::InspectNext);
+ km.bind(key("pageup"), Action::InspectPrevious);
+ km.bind(key("pagedown"), Action::InspectNext);
+
+ // For vim users, add j/k navigation
+ if matches!(
+ settings.keymap_mode,
+ KeymapMode::VimNormal | KeymapMode::VimInsert
+ ) {
+ km.bind(key("j"), Action::InspectNext);
+ km.bind(key("k"), Action::InspectPrevious);
+ }
+
+ km
+}
+
+// ---------------------------------------------------------------------------
+// Prefix keymap
+// ---------------------------------------------------------------------------
+
+/// Build the default prefix keymap (active after ctrl-a prefix).
+pub fn default_prefix_keymap() -> Keymap {
+ let mut km = Keymap::new();
+
+ km.bind(key("d"), Action::Delete);
+ km.bind(key("D"), Action::DeleteAll);
+ km.bind(key("a"), Action::CursorStart);
+ km.bind_conditional(
+ key("c"),
+ vec![
+ KeyRule::when(ConditionAtom::HasContext, Action::ClearContext),
+ KeyRule::always(Action::SwitchContext),
+ ],
+ );
+
+ km
+}
+
+// ---------------------------------------------------------------------------
+// KeymapSet construction
+// ---------------------------------------------------------------------------
+
+// ---------------------------------------------------------------------------
+// Config → Keymap conversion
+// ---------------------------------------------------------------------------
+
+/// Convert a `KeyBindingConfig` (from TOML) into a `KeyBinding`.
+/// Returns `Err` if an action name or condition expression is invalid.
+fn parse_binding_config(config: &KeyBindingConfig) -> Result<KeyBinding, String> {
+ match config {
+ KeyBindingConfig::Simple(action_str) => {
+ let action = Action::from_str(action_str)?;
+ Ok(KeyBinding::simple(action))
+ }
+ KeyBindingConfig::Rules(rules) => {
+ let mut parsed_rules = Vec::with_capacity(rules.len());
+ for rule_cfg in rules {
+ let action = Action::from_str(&rule_cfg.action)?;
+ let rule = match &rule_cfg.when {
+ None => KeyRule::always(action),
+ Some(cond_str) => {
+ let cond = ConditionExpr::parse(cond_str)?;
+ KeyRule::when(cond, action)
+ }
+ };
+ parsed_rules.push(rule);
+ }
+ Ok(KeyBinding::conditional(parsed_rules))
+ }
+ }
+}
+
+/// Apply a map of key-string → binding-config overrides to a keymap.
+/// Per-key override replaces the entire rule list for that key.
+/// Invalid keys or action names are logged and skipped.
+fn apply_config_to_keymap(keymap: &mut Keymap, overrides: &HashMap<String, KeyBindingConfig>) {
+ for (key_str, binding_cfg) in overrides {
+ let key = match KeyInput::parse(key_str) {
+ Ok(k) => k,
+ Err(e) => {
+ warn!("invalid key in keymap config: {key_str:?}: {e}");
+ continue;
+ }
+ };
+ match parse_binding_config(binding_cfg) {
+ Ok(binding) => {
+ keymap.bindings.insert(key, binding);
+ }
+ Err(e) => {
+ warn!("invalid binding for {key_str:?} in keymap config: {e}");
+ }
+ }
+ }
+}
+
+impl KeymapSet {
+ /// Build the complete set of default keymaps from settings.
+ pub fn defaults(settings: &Settings) -> Self {
+ KeymapSet {
+ emacs: default_emacs_keymap(settings),
+ vim_normal: default_vim_normal_keymap(settings),
+ vim_insert: default_vim_insert_keymap(settings),
+ inspector: default_inspector_keymap(settings),
+ prefix: default_prefix_keymap(),
+ }
+ }
+
+ /// Build keymaps from settings, applying any user `[keymap]` overrides.
+ ///
+ /// Precedence rules:
+ /// - If `[keymap]` has any entries, `[keys]` is **ignored entirely**.
+ /// Defaults are built with standard `[keys]` values, then `[keymap]`
+ /// overrides are applied per-key.
+ /// - If `[keymap]` is empty/absent, `[keys]` customizes the defaults
+ /// (current behavior for backward compatibility).
+ pub fn from_settings(settings: &Settings) -> Self {
+ use crate::atuin_client::settings::Keys;
+
+ if settings.keymap.is_empty() {
+ // No [keymap] section → use [keys] to customize defaults
+ Self::defaults(settings)
+ } else {
+ // [keymap] present → ignore [keys], use standard defaults as base
+ let mut base_settings = settings.clone();
+ base_settings.keys = Keys::standard_defaults();
+ let mut set = Self::defaults(&base_settings);
+ set.apply_config(settings);
+ set
+ }
+ }
+
+ /// Apply user keymap config overrides to all modes.
+ fn apply_config(&mut self, settings: &Settings) {
+ let config = &settings.keymap;
+ apply_config_to_keymap(&mut self.emacs, &config.emacs);
+ apply_config_to_keymap(&mut self.vim_normal, &config.vim_normal);
+ apply_config_to_keymap(&mut self.vim_insert, &config.vim_insert);
+ apply_config_to_keymap(&mut self.inspector, &config.inspector);
+ apply_config_to_keymap(&mut self.prefix, &config.prefix);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::command::client::search::keybindings::conditions::EvalContext;
+
+ fn make_ctx(cursor: usize, width: usize, selected: usize, len: usize) -> EvalContext {
+ EvalContext {
+ cursor_position: cursor,
+ input_width: width,
+ input_byte_len: width,
+ selected_index: selected,
+ results_len: len,
+ original_input_empty: false,
+ has_context: false,
+ }
+ }
+
+ fn default_settings() -> Settings {
+ Settings::utc()
+ }
+
+ // -- Emacs keymap tests --
+
+ #[test]
+ fn emacs_ctrl_c_returns_original() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("ctrl-c"), &ctx),
+ Some(Action::ReturnOriginal)
+ );
+ }
+
+ #[test]
+ fn emacs_esc_exits() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn emacs_tab_returns_selection() {
+ // enter_accept=false in test defaults → ReturnSelection
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection));
+ }
+
+ #[test]
+ fn emacs_enter_returns_selection() {
+ // enter_accept=false in test defaults → ReturnSelection
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("enter"), &ctx),
+ Some(Action::ReturnSelection)
+ );
+ }
+
+ #[test]
+ fn emacs_enter_accept_true_uses_accept() {
+ let mut settings = default_settings();
+ settings.enter_accept = true;
+ let km = default_emacs_keymap(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("enter"), &ctx), Some(Action::Accept));
+ assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection));
+ }
+
+ #[test]
+ fn emacs_right_at_end_returns_selection() {
+ let km = default_emacs_keymap(&default_settings());
+ // cursor at end of "hello" (width 5)
+ let ctx = make_ctx(5, 5, 0, 10);
+ assert_eq!(
+ km.resolve(&key("right"), &ctx),
+ Some(Action::ReturnSelection)
+ );
+ }
+
+ #[test]
+ fn emacs_right_not_at_end_moves() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(2, 5, 0, 10);
+ assert_eq!(km.resolve(&key("right"), &ctx), Some(Action::CursorRight));
+ }
+
+ #[test]
+ fn emacs_left_at_start_exits() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(0, 5, 0, 10);
+ assert_eq!(km.resolve(&key("left"), &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn emacs_left_not_at_start_moves() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(3, 5, 0, 10);
+ assert_eq!(km.resolve(&key("left"), &ctx), Some(Action::CursorLeft));
+ }
+
+ #[test]
+ fn emacs_down_at_start_exits() {
+ let km = default_emacs_keymap(&default_settings());
+ // selected=0 → ListAtStart → Exit
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn emacs_down_not_at_start_selects_next() {
+ let km = default_emacs_keymap(&default_settings());
+ // selected=5 → not at start → SelectNext
+ let ctx = make_ctx(0, 0, 5, 10);
+ assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::SelectNext));
+ }
+
+ #[test]
+ fn emacs_up_selects_previous() {
+ let km = default_emacs_keymap(&default_settings());
+ // Non-inverted: up never exits (moves away from index 0)
+ let ctx = make_ctx(0, 0, 5, 10);
+ assert_eq!(km.resolve(&key("up"), &ctx), Some(Action::SelectPrevious));
+ }
+
+ #[test]
+ fn emacs_ctrl_d_empty_returns_original() {
+ let km = default_emacs_keymap(&default_settings());
+ // input empty (byte_len = 0)
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("ctrl-d"), &ctx),
+ Some(Action::ReturnOriginal)
+ );
+ }
+
+ #[test]
+ fn emacs_ctrl_d_nonempty_deletes() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(2, 5, 0, 10);
+ assert_eq!(
+ km.resolve(&key("ctrl-d"), &ctx),
+ Some(Action::DeleteCharAfter)
+ );
+ }
+
+ #[test]
+ fn emacs_ctrl_n_selects_next_no_exit_condition() {
+ let km = default_emacs_keymap(&default_settings());
+ // at start, but ctrl-n should NOT exit (no exit condition bound)
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("ctrl-n"), &ctx), Some(Action::SelectNext));
+ }
+
+ #[test]
+ fn emacs_prefix_key_enters_prefix() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("ctrl-a"), &ctx),
+ Some(Action::EnterPrefixMode)
+ );
+ }
+
+ #[test]
+ fn emacs_home_cursor_start() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(5, 10, 0, 10);
+ assert_eq!(km.resolve(&key("home"), &ctx), Some(Action::CursorStart));
+ }
+
+ // -- Vim Normal keymap tests --
+
+ #[test]
+ fn vim_normal_j_at_start_exits() {
+ let km = default_vim_normal_keymap(&default_settings());
+ // selected=0 → ListAtStart → Exit (non-inverted: j moves toward index 0)
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("j"), &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn vim_normal_j_not_at_start_selects_next() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 5, 10);
+ assert_eq!(km.resolve(&key("j"), &ctx), Some(Action::SelectNext));
+ }
+
+ #[test]
+ fn vim_normal_k_selects_previous() {
+ let km = default_vim_normal_keymap(&default_settings());
+ // Non-inverted: k never exits (moves away from index 0)
+ let ctx = make_ctx(0, 0, 5, 10);
+ assert_eq!(km.resolve(&key("k"), &ctx), Some(Action::SelectPrevious));
+ }
+
+ #[test]
+ fn vim_normal_i_enters_insert() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("i"), &ctx), Some(Action::VimEnterInsert));
+ }
+
+ #[test]
+ fn vim_normal_slash_search_insert() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("/"), &ctx), Some(Action::VimSearchInsert));
+ }
+
+ #[test]
+ fn vim_normal_gg_scroll_to_top() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 50, 100);
+ assert_eq!(km.resolve(&key("g g"), &ctx), Some(Action::ScrollToTop));
+ }
+
+ #[test]
+ fn vim_normal_big_g_scroll_to_bottom() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 50, 100);
+ assert_eq!(km.resolve(&key("G"), &ctx), Some(Action::ScrollToBottom));
+ }
+
+ #[test]
+ fn vim_normal_numeric_returns_selection() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("3"), &ctx),
+ Some(Action::ReturnSelectionNth(3))
+ );
+ }
+
+ #[test]
+ fn vim_normal_ctrl_u_half_page_up() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 50, 100);
+ assert_eq!(
+ km.resolve(&key("ctrl-u"), &ctx),
+ Some(Action::ScrollHalfPageUp)
+ );
+ }
+
+ #[test]
+ fn vim_normal_screen_jumps() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 50, 100);
+ assert_eq!(km.resolve(&key("H"), &ctx), Some(Action::ScrollToScreenTop));
+ assert_eq!(
+ km.resolve(&key("M"), &ctx),
+ Some(Action::ScrollToScreenMiddle)
+ );
+ assert_eq!(
+ km.resolve(&key("L"), &ctx),
+ Some(Action::ScrollToScreenBottom)
+ );
+ }
+
+ #[test]
+ fn vim_normal_enter_returns_selection() {
+ // enter_accept=false in test defaults → ReturnSelection
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("enter"), &ctx),
+ Some(Action::ReturnSelection)
+ );
+ }
+
+ #[test]
+ fn vim_normal_enter_accept_true_uses_accept() {
+ let mut settings = default_settings();
+ settings.enter_accept = true;
+ let km = default_vim_normal_keymap(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("enter"), &ctx), Some(Action::Accept));
+ }
+
+ // -- Vim Insert keymap tests --
+
+ #[test]
+ fn vim_insert_inherits_emacs_enter() {
+ let km = default_vim_insert_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ // enter_accept=false → ReturnSelection
+ assert_eq!(
+ km.resolve(&key("enter"), &ctx),
+ Some(Action::ReturnSelection)
+ );
+ }
+
+ #[test]
+ fn vim_insert_esc_enters_normal() {
+ let km = default_vim_insert_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::VimEnterNormal));
+ }
+
+ #[test]
+ fn vim_insert_ctrl_bracket_enters_normal() {
+ let km = default_vim_insert_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("ctrl-["), &ctx),
+ Some(Action::VimEnterNormal)
+ );
+ }
+
+ #[test]
+ fn vim_insert_inherits_emacs_ctrl_d() {
+ let km = default_vim_insert_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ // input empty → return original
+ assert_eq!(
+ km.resolve(&key("ctrl-d"), &ctx),
+ Some(Action::ReturnOriginal)
+ );
+ }
+
+ // -- Inspector keymap tests --
+
+ #[test]
+ fn inspector_ctrl_d_deletes() {
+ let km = default_inspector_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("ctrl-d"), &ctx), Some(Action::Delete));
+ }
+
+ #[test]
+ fn inspector_up_inspects_previous() {
+ let km = default_inspector_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("up"), &ctx), Some(Action::InspectPrevious));
+ }
+
+ #[test]
+ fn inspector_down_inspects_next() {
+ let km = default_inspector_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::InspectNext));
+ }
+
+ #[test]
+ fn inspector_esc_exits() {
+ let km = default_inspector_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn inspector_tab_returns_selection() {
+ // enter_accept=false → ReturnSelection
+ let km = default_inspector_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection));
+ }
+
+ // -- Prefix keymap tests --
+
+ #[test]
+ fn prefix_d_deletes() {
+ let km = default_prefix_keymap();
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("d"), &ctx), Some(Action::Delete));
+ }
+
+ #[test]
+ fn prefix_a_cursor_start() {
+ let km = default_prefix_keymap();
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("a"), &ctx), Some(Action::CursorStart));
+ }
+
+ #[test]
+ fn prefix_unknown_key_returns_none() {
+ let km = default_prefix_keymap();
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("x"), &ctx), None);
+ }
+
+ // -- KeymapSet tests --
+
+ #[test]
+ fn keymap_set_defaults_builds() {
+ let settings = default_settings();
+ let set = KeymapSet::defaults(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+
+ // Sanity check each keymap has bindings
+ assert!(set.emacs.resolve(&key("ctrl-c"), &ctx).is_some());
+ assert!(set.vim_normal.resolve(&key("ctrl-c"), &ctx).is_some());
+ assert!(set.vim_insert.resolve(&key("ctrl-c"), &ctx).is_some());
+ assert!(set.inspector.resolve(&key("ctrl-c"), &ctx).is_some());
+ assert!(set.prefix.resolve(&key("d"), &ctx).is_some());
+ }
+
+ // -- Settings-dependent behavior --
+
+ #[test]
+ fn custom_prefix_char() {
+ let mut settings = default_settings();
+ settings.keys.prefix = "x".to_string();
+ let km = default_emacs_keymap(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+
+ // ctrl-x should be prefix mode
+ assert_eq!(
+ km.resolve(&key("ctrl-x"), &ctx),
+ Some(Action::EnterPrefixMode)
+ );
+ // ctrl-a should now be CursorStart (not prefix)
+ assert_eq!(km.resolve(&key("ctrl-a"), &ctx), Some(Action::CursorStart));
+ }
+
+ #[test]
+ fn ctrl_n_shortcuts_changes_numeric_modifier() {
+ let mut settings = default_settings();
+ settings.ctrl_n_shortcuts = true;
+ let km = default_emacs_keymap(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+
+ // ctrl-1 should work
+ assert_eq!(
+ km.resolve(&key("ctrl-1"), &ctx),
+ Some(Action::ReturnSelectionNth(1))
+ );
+ // alt-1 should NOT be bound
+ assert_eq!(km.resolve(&key("alt-1"), &ctx), None);
+ }
+
+ #[test]
+ fn default_alt_numeric_shortcuts() {
+ let settings = default_settings();
+ let km = default_emacs_keymap(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+
+ // alt-1 should work by default
+ assert_eq!(
+ km.resolve(&key("alt-1"), &ctx),
+ Some(Action::ReturnSelectionNth(1))
+ );
+ }
+
+ // -----------------------------------------------------------------------
+ // Config parsing and merging tests
+ // -----------------------------------------------------------------------
+
+ #[test]
+ fn parse_simple_binding_config() {
+ use crate::atuin_client::settings::KeyBindingConfig;
+ let cfg = KeyBindingConfig::Simple("accept".to_string());
+ let binding = super::parse_binding_config(&cfg).unwrap();
+ assert_eq!(binding.rules.len(), 1);
+ assert!(binding.rules[0].condition.is_none());
+ assert_eq!(binding.rules[0].action, Action::Accept);
+ }
+
+ #[test]
+ fn parse_conditional_binding_config() {
+ use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
+ let cfg = KeyBindingConfig::Rules(vec![
+ KeyRuleConfig {
+ when: Some("cursor-at-start".to_string()),
+ action: "exit".to_string(),
+ },
+ KeyRuleConfig {
+ when: None,
+ action: "cursor-left".to_string(),
+ },
+ ]);
+ let binding = super::parse_binding_config(&cfg).unwrap();
+ assert_eq!(binding.rules.len(), 2);
+ assert!(binding.rules[0].condition.is_some());
+ assert_eq!(binding.rules[0].action, Action::Exit);
+ assert!(binding.rules[1].condition.is_none());
+ assert_eq!(binding.rules[1].action, Action::CursorLeft);
+ }
+
+ #[test]
+ fn parse_binding_config_invalid_action() {
+ use crate::atuin_client::settings::KeyBindingConfig;
+ let cfg = KeyBindingConfig::Simple("not-a-real-action".to_string());
+ assert!(super::parse_binding_config(&cfg).is_err());
+ }
+
+ #[test]
+ fn parse_binding_config_invalid_condition() {
+ use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
+ let cfg = KeyBindingConfig::Rules(vec![KeyRuleConfig {
+ when: Some("not-a-real-condition".to_string()),
+ action: "exit".to_string(),
+ }]);
+ assert!(super::parse_binding_config(&cfg).is_err());
+ }
+
+ #[test]
+ fn config_override_replaces_key() {
+ use crate::atuin_client::settings::KeyBindingConfig;
+ use std::collections::HashMap;
+
+ let mut settings = default_settings();
+ let set = KeymapSet::defaults(&settings);
+
+ // Default: ctrl-c → ReturnOriginal
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ set.emacs.resolve(&key("ctrl-c"), &ctx),
+ Some(Action::ReturnOriginal)
+ );
+
+ // Override ctrl-c → Exit via config
+ settings.keymap.emacs = HashMap::from([(
+ "ctrl-c".to_string(),
+ KeyBindingConfig::Simple("exit".to_string()),
+ )]);
+
+ let set = KeymapSet::from_settings(&settings);
+ assert_eq!(set.emacs.resolve(&key("ctrl-c"), &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn config_override_preserves_unoverridden_keys() {
+ use crate::atuin_client::settings::KeyBindingConfig;
+ use std::collections::HashMap;
+
+ let mut settings = default_settings();
+ // Override only ctrl-c; enter should keep its default
+ settings.keymap.emacs = HashMap::from([(
+ "ctrl-c".to_string(),
+ KeyBindingConfig::Simple("exit".to_string()),
+ )]);
+
+ let set = KeymapSet::from_settings(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+
+ // ctrl-c overridden
+ assert_eq!(set.emacs.resolve(&key("ctrl-c"), &ctx), Some(Action::Exit));
+ // enter still has default (enter_accept=false → ReturnSelection)
+ assert_eq!(
+ set.emacs.resolve(&key("enter"), &ctx),
+ Some(Action::ReturnSelection)
+ );
+ }
+
+ #[test]
+ fn config_conditional_override() {
+ use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
+ use std::collections::HashMap;
+
+ let mut settings = default_settings();
+ // Override "up" with a custom conditional
+ settings.keymap.emacs = HashMap::from([(
+ "up".to_string(),
+ KeyBindingConfig::Rules(vec![
+ KeyRuleConfig {
+ when: Some("no-results".to_string()),
+ action: "exit".to_string(),
+ },
+ KeyRuleConfig {
+ when: None,
+ action: "select-previous".to_string(),
+ },
+ ]),
+ )]);
+
+ let set = KeymapSet::from_settings(&settings);
+
+ // With no results → exit
+ let ctx = make_ctx(0, 0, 0, 0);
+ assert_eq!(set.emacs.resolve(&key("up"), &ctx), Some(Action::Exit));
+
+ // With results → select-previous
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ set.emacs.resolve(&key("up"), &ctx),
+ Some(Action::SelectPrevious)
+ );
+ }
+
+ #[test]
+ fn from_settings_with_empty_config_equals_defaults() {
+ let settings = default_settings();
+ let defaults = KeymapSet::defaults(&settings);
+ let from_settings = KeymapSet::from_settings(&settings);
+
+ // Verify a sample of keys produce the same results
+ let ctx = make_ctx(0, 0, 0, 10);
+ let test_keys = [
+ "ctrl-c", "enter", "esc", "tab", "up", "down", "left", "right",
+ ];
+ for k in &test_keys {
+ assert_eq!(
+ defaults.emacs.resolve(&key(k), &ctx),
+ from_settings.emacs.resolve(&key(k), &ctx),
+ "mismatch for emacs key {k}"
+ );
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Phase 5: [keys] vs [keymap] backward compatibility
+ // -----------------------------------------------------------------------
+
+ #[test]
+ fn keymap_overrides_ignore_keys_section() {
+ use crate::atuin_client::settings::KeyBindingConfig;
+
+ // Set up: [keys] disables scroll_exits, but [keymap] is present
+ let mut settings = default_settings();
+ settings.keys.scroll_exits = false;
+
+ // Without [keymap], scroll_exits=false means no exit condition on down
+ let set_legacy = KeymapSet::defaults(&settings);
+ // At list-at-start (selected=0), down should still be SelectNext (no exit)
+ let ctx_at_boundary = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ set_legacy.emacs.resolve(&key("down"), &ctx_at_boundary),
+ Some(Action::SelectNext),
+ "legacy: down at boundary should be SelectNext with scroll_exits=false"
+ );
+
+ // With [keymap] present (even just one override), [keys] is ignored
+ // so the standard defaults (scroll_exits=true) apply
+ settings.keymap.emacs = HashMap::from([(
+ "ctrl-c".to_string(),
+ KeyBindingConfig::Simple("exit".to_string()),
+ )]);
+ let set_keymap = KeymapSet::from_settings(&settings);
+
+ // Not at boundary (selected=5): should SelectNext normally
+ let ctx_not_at_boundary = make_ctx(0, 0, 5, 10);
+ assert_eq!(
+ set_keymap.emacs.resolve(&key("down"), &ctx_not_at_boundary),
+ Some(Action::SelectNext),
+ "keymap: down not at boundary should SelectNext"
+ );
+ // At list-at-start (selected=0): should Exit (standard scroll_exits=true)
+ assert_eq!(
+ set_keymap.emacs.resolve(&key("down"), &ctx_at_boundary),
+ Some(Action::Exit),
+ "keymap: down at boundary should Exit (standard defaults restored)"
+ );
+ }
+
+ #[test]
+ fn keymap_present_resets_to_standard_keys_defaults() {
+ use crate::atuin_client::settings::KeyBindingConfig;
+
+ let mut settings = default_settings();
+ // Disable all [keys] behaviors
+ settings.keys.exit_past_line_start = false;
+ settings.keys.accept_past_line_end = false;
+
+ // Without [keymap], left should be plain CursorLeft
+ let set_legacy = KeymapSet::defaults(&settings);
+ let ctx_at_start = make_ctx(0, 5, 0, 10);
+ assert_eq!(
+ set_legacy.emacs.resolve(&key("left"), &ctx_at_start),
+ Some(Action::CursorLeft),
+ "legacy: left should be plain CursorLeft without exit_past_line_start"
+ );
+
+ // Add a [keymap] entry (for a different key)
+ settings.keymap.emacs = HashMap::from([(
+ "ctrl-c".to_string(),
+ KeyBindingConfig::Simple("exit".to_string()),
+ )]);
+ let set_keymap = KeymapSet::from_settings(&settings);
+
+ // Now left should use standard defaults (exit_past_line_start=true)
+ // At cursor start → Exit
+ assert_eq!(
+ set_keymap.emacs.resolve(&key("left"), &ctx_at_start),
+ Some(Action::Exit),
+ "keymap: left at cursor start should exit (standard defaults)"
+ );
+
+ // Right at cursor end should return selection (standard defaults: accept_past_line_end=true, enter_accept=false)
+ let ctx_at_end = make_ctx(5, 5, 0, 10);
+ assert_eq!(
+ set_keymap.emacs.resolve(&key("right"), &ctx_at_end),
+ Some(Action::ReturnSelection),
+ "keymap: right at cursor end should return selection (standard defaults)"
+ );
+ }
+
+ #[test]
+ fn keys_has_non_default_values_detection() {
+ use crate::atuin_client::settings::Keys;
+
+ let standard = Keys::standard_defaults();
+ assert!(!standard.has_non_default_values());
+
+ let mut modified = Keys::standard_defaults();
+ modified.scroll_exits = false;
+ assert!(modified.has_non_default_values());
+
+ let mut modified = Keys::standard_defaults();
+ modified.prefix = "x".to_string();
+ assert!(modified.has_non_default_values());
+ }
+
+ #[test]
+ fn original_input_empty_condition_in_config() {
+ use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
+ use std::collections::HashMap;
+
+ let mut settings = default_settings();
+ // Configure esc to: if original-input-empty -> return-query, else return-original
+ settings.keymap.emacs = HashMap::from([(
+ "esc".to_string(),
+ KeyBindingConfig::Rules(vec![
+ KeyRuleConfig {
+ when: Some("original-input-empty".to_string()),
+ action: "return-query".to_string(),
+ },
+ KeyRuleConfig {
+ when: None,
+ action: "return-original".to_string(),
+ },
+ ]),
+ )]);
+
+ let set = KeymapSet::from_settings(&settings);
+
+ // When original input was empty, should return-query
+ let ctx_original_empty = EvalContext {
+ cursor_position: 0,
+ input_width: 5,
+ input_byte_len: 5,
+ selected_index: 0,
+ results_len: 10,
+ original_input_empty: true,
+ has_context: false,
+ };
+ assert_eq!(
+ set.emacs.resolve(&key("esc"), &ctx_original_empty),
+ Some(Action::ReturnQuery),
+ "esc with original_input_empty=true should return-query"
+ );
+
+ // When original input was not empty, should return-original
+ let ctx_original_not_empty = EvalContext {
+ cursor_position: 0,
+ input_width: 5,
+ input_byte_len: 5,
+ selected_index: 0,
+ results_len: 10,
+ original_input_empty: false,
+ has_context: false,
+ };
+ assert_eq!(
+ set.emacs.resolve(&key("esc"), &ctx_original_not_empty),
+ Some(Action::ReturnOriginal),
+ "esc with original_input_empty=false should return-original"
+ );
+ }
+}
diff --git a/crates/turtle/src/command/client/search/keybindings/key.rs b/crates/turtle/src/command/client/search/keybindings/key.rs
new file mode 100644
index 00000000..c2eb31c6
--- /dev/null
+++ b/crates/turtle/src/command/client/search/keybindings/key.rs
@@ -0,0 +1,629 @@
+use std::fmt;
+
+use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MediaKeyCode};
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+/// A single key press with modifiers (e.g. `ctrl-c`, `alt-f`, `enter`).
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+#[expect(clippy::struct_excessive_bools)]
+pub struct SingleKey {
+ pub code: KeyCodeValue,
+ pub ctrl: bool,
+ pub alt: bool,
+ pub shift: bool,
+ pub super_key: bool,
+}
+
+/// The key code portion of a key press.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum KeyCodeValue {
+ Char(char),
+ Enter,
+ Esc,
+ Tab,
+ Backspace,
+ Delete,
+ Insert,
+ Up,
+ Down,
+ Left,
+ Right,
+ Home,
+ End,
+ PageUp,
+ PageDown,
+ Space,
+ F(u8),
+ Media(MediaKeyCode),
+}
+
+/// A key input that may be a single key or a multi-key sequence (e.g. `g g`).
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum KeyInput {
+ Single(SingleKey),
+ Sequence(Vec<SingleKey>),
+}
+
+impl SingleKey {
+ /// Convert a crossterm `KeyEvent` into a `SingleKey`.
+ pub fn from_event(event: &KeyEvent) -> Option<Self> {
+ let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
+ let alt = event.modifiers.contains(KeyModifiers::ALT);
+ let shift = event.modifiers.contains(KeyModifiers::SHIFT);
+ let super_key = event.modifiers.contains(KeyModifiers::SUPER);
+
+ let code = match event.code {
+ KeyCode::Char(' ') => KeyCodeValue::Space,
+ KeyCode::Char(c) => {
+ // If shift is the only modifier and it's an uppercase letter,
+ // we store the uppercase char directly and clear the shift flag
+ // since the case already encodes it.
+ if shift && !ctrl && !alt && !super_key && c.is_ascii_uppercase() {
+ return Some(SingleKey {
+ code: KeyCodeValue::Char(c),
+ ctrl: false,
+ alt: false,
+ shift: false,
+ super_key: false,
+ });
+ }
+ KeyCodeValue::Char(c)
+ }
+ KeyCode::Enter => KeyCodeValue::Enter,
+ KeyCode::Esc => KeyCodeValue::Esc,
+ KeyCode::Tab => KeyCodeValue::Tab,
+ // BackTab is sent by many terminals for Shift+Tab
+ KeyCode::BackTab => {
+ return Some(SingleKey {
+ code: KeyCodeValue::Tab,
+ ctrl,
+ alt,
+ shift: true,
+ super_key,
+ });
+ }
+ KeyCode::Backspace => KeyCodeValue::Backspace,
+ KeyCode::Delete => KeyCodeValue::Delete,
+ KeyCode::Insert => KeyCodeValue::Insert,
+ KeyCode::Up => KeyCodeValue::Up,
+ KeyCode::Down => KeyCodeValue::Down,
+ KeyCode::Left => KeyCodeValue::Left,
+ KeyCode::Right => KeyCodeValue::Right,
+ KeyCode::Home => KeyCodeValue::Home,
+ KeyCode::End => KeyCodeValue::End,
+ KeyCode::PageUp => KeyCodeValue::PageUp,
+ KeyCode::PageDown => KeyCodeValue::PageDown,
+ KeyCode::F(n) => KeyCodeValue::F(n),
+ KeyCode::Media(m) => KeyCodeValue::Media(m),
+ _ => return None,
+ };
+
+ Some(SingleKey {
+ code,
+ ctrl,
+ alt,
+ shift: if matches!(code, KeyCodeValue::Char(_)) {
+ false
+ } else {
+ shift
+ },
+ super_key,
+ })
+ }
+
+ /// Parse a key string like `"ctrl-c"`, `"alt-f"`, `"enter"`, `"G"`.
+ pub fn parse(s: &str) -> Result<Self, String> {
+ let s = s.trim();
+ let parts: Vec<&str> = s.split('-').collect();
+
+ let mut ctrl = false;
+ let mut alt = false;
+ let mut shift = false;
+ let mut super_key = false;
+
+ // All parts except the last are modifiers
+ for &part in &parts[..parts.len() - 1] {
+ match part.to_lowercase().as_str() {
+ "ctrl" => ctrl = true,
+ "alt" => alt = true,
+ "shift" => shift = true,
+ "super" | "cmd" | "win" => super_key = true,
+ _ => return Err(format!("unknown modifier: {part}")),
+ }
+ }
+
+ let key_part = parts[parts.len() - 1];
+ let code = match key_part.to_lowercase().as_str() {
+ "enter" | "return" => KeyCodeValue::Enter,
+ "esc" | "escape" => KeyCodeValue::Esc,
+ "tab" => KeyCodeValue::Tab,
+ "backspace" => KeyCodeValue::Backspace,
+ "delete" | "del" => KeyCodeValue::Delete,
+ "insert" | "ins" => KeyCodeValue::Insert,
+ "up" => KeyCodeValue::Up,
+ "down" => KeyCodeValue::Down,
+ "left" => KeyCodeValue::Left,
+ "right" => KeyCodeValue::Right,
+ "home" => KeyCodeValue::Home,
+ "end" => KeyCodeValue::End,
+ "pageup" => KeyCodeValue::PageUp,
+ "pagedown" => KeyCodeValue::PageDown,
+ "space" => KeyCodeValue::Space,
+ s if s.starts_with('f') && s.len() > 1 => {
+ // Parse function keys like "f1", "f12"
+ if let Ok(n) = s[1..].parse::<u8>() {
+ if (1..=24).contains(&n) {
+ KeyCodeValue::F(n)
+ } else {
+ return Err(format!("function key out of range: {key_part}"));
+ }
+ } else {
+ return Err(format!("unknown key: {key_part}"));
+ }
+ }
+ "[" => KeyCodeValue::Char('['),
+ "]" => KeyCodeValue::Char(']'),
+ "?" => KeyCodeValue::Char('?'),
+ "/" => KeyCodeValue::Char('/'),
+ "$" => KeyCodeValue::Char('$'),
+ // Media keys (no dashes - the parser splits on dash for modifiers)
+ "play" => KeyCodeValue::Media(MediaKeyCode::Play),
+ "pause" => KeyCodeValue::Media(MediaKeyCode::Pause),
+ "playpause" => KeyCodeValue::Media(MediaKeyCode::PlayPause),
+ "stop" => KeyCodeValue::Media(MediaKeyCode::Stop),
+ "fastforward" => KeyCodeValue::Media(MediaKeyCode::FastForward),
+ "rewind" => KeyCodeValue::Media(MediaKeyCode::Rewind),
+ "tracknext" => KeyCodeValue::Media(MediaKeyCode::TrackNext),
+ "trackprevious" => KeyCodeValue::Media(MediaKeyCode::TrackPrevious),
+ "record" => KeyCodeValue::Media(MediaKeyCode::Record),
+ "lowervolume" => KeyCodeValue::Media(MediaKeyCode::LowerVolume),
+ "raisevolume" => KeyCodeValue::Media(MediaKeyCode::RaiseVolume),
+ "mutevolume" | "mute" => KeyCodeValue::Media(MediaKeyCode::MuteVolume),
+ _ => {
+ let chars: Vec<char> = key_part.chars().collect();
+ if chars.len() == 1 {
+ let c = chars[0];
+ // An uppercase letter implies shift (unless shift already specified)
+ if c.is_ascii_uppercase() && !ctrl && !alt && !super_key {
+ return Ok(SingleKey {
+ code: KeyCodeValue::Char(c),
+ ctrl: false,
+ alt: false,
+ shift: false,
+ super_key: false,
+ });
+ }
+ KeyCodeValue::Char(c)
+ } else {
+ return Err(format!("unknown key: {key_part}"));
+ }
+ }
+ };
+
+ Ok(SingleKey {
+ code,
+ ctrl,
+ alt,
+ shift,
+ super_key,
+ })
+ }
+}
+
+impl fmt::Display for SingleKey {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ if self.super_key {
+ write!(f, "super-")?;
+ }
+ if self.ctrl {
+ write!(f, "ctrl-")?;
+ }
+ if self.alt {
+ write!(f, "alt-")?;
+ }
+ if self.shift {
+ write!(f, "shift-")?;
+ }
+ match &self.code {
+ KeyCodeValue::Char(c) => write!(f, "{c}"),
+ KeyCodeValue::Enter => write!(f, "enter"),
+ KeyCodeValue::Esc => write!(f, "esc"),
+ KeyCodeValue::Tab => write!(f, "tab"),
+ KeyCodeValue::Backspace => write!(f, "backspace"),
+ KeyCodeValue::Delete => write!(f, "delete"),
+ KeyCodeValue::Insert => write!(f, "insert"),
+ KeyCodeValue::Up => write!(f, "up"),
+ KeyCodeValue::Down => write!(f, "down"),
+ KeyCodeValue::Left => write!(f, "left"),
+ KeyCodeValue::Right => write!(f, "right"),
+ KeyCodeValue::Home => write!(f, "home"),
+ KeyCodeValue::End => write!(f, "end"),
+ KeyCodeValue::PageUp => write!(f, "pageup"),
+ KeyCodeValue::PageDown => write!(f, "pagedown"),
+ KeyCodeValue::Space => write!(f, "space"),
+ KeyCodeValue::F(n) => write!(f, "f{n}"),
+ KeyCodeValue::Media(m) => match m {
+ MediaKeyCode::Play => write!(f, "play"),
+ MediaKeyCode::Pause => write!(f, "media-pause"),
+ MediaKeyCode::PlayPause => write!(f, "playpause"),
+ MediaKeyCode::Stop => write!(f, "stop"),
+ MediaKeyCode::FastForward => write!(f, "fastforward"),
+ MediaKeyCode::Rewind => write!(f, "rewind"),
+ MediaKeyCode::TrackNext => write!(f, "tracknext"),
+ MediaKeyCode::TrackPrevious => write!(f, "trackprevious"),
+ MediaKeyCode::Record => write!(f, "record"),
+ MediaKeyCode::LowerVolume => write!(f, "lowervolume"),
+ MediaKeyCode::RaiseVolume => write!(f, "raisevolume"),
+ MediaKeyCode::MuteVolume => write!(f, "mutevolume"),
+ MediaKeyCode::Reverse => write!(f, "reverse"),
+ },
+ }
+ }
+}
+
+impl KeyInput {
+ /// Parse a key input string. Supports multi-key sequences separated by spaces
+ /// (e.g. `"g g"`).
+ pub fn parse(s: &str) -> Result<Self, String> {
+ let s = s.trim();
+ // Check for space-separated multi-key sequences
+ // But don't split "space" or modifier combos like "ctrl-a"
+ let parts: Vec<&str> = s.split_whitespace().collect();
+ if parts.len() > 1 {
+ let keys: Result<Vec<SingleKey>, String> =
+ parts.iter().map(|p| SingleKey::parse(p)).collect();
+ Ok(KeyInput::Sequence(keys?))
+ } else {
+ Ok(KeyInput::Single(SingleKey::parse(s)?))
+ }
+ }
+}
+
+impl fmt::Display for KeyInput {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ KeyInput::Single(k) => write!(f, "{k}"),
+ KeyInput::Sequence(keys) => {
+ for (i, k) in keys.iter().enumerate() {
+ if i > 0 {
+ write!(f, " ")?;
+ }
+ write!(f, "{k}")?;
+ }
+ Ok(())
+ }
+ }
+ }
+}
+
+impl Serialize for KeyInput {
+ fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+ serializer.serialize_str(&self.to_string())
+ }
+}
+
+impl<'de> Deserialize<'de> for KeyInput {
+ fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+ let s = String::deserialize(deserializer)?;
+ KeyInput::parse(&s).map_err(serde::de::Error::custom)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ #[test]
+ fn parse_simple_keys() {
+ let k = SingleKey::parse("a").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('a'));
+ assert!(!k.ctrl && !k.alt && !k.shift);
+
+ let k = SingleKey::parse("enter").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Enter);
+
+ let k = SingleKey::parse("esc").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Esc);
+
+ let k = SingleKey::parse("tab").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Tab);
+
+ let k = SingleKey::parse("space").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Space);
+ }
+
+ #[test]
+ fn parse_modifiers() {
+ let k = SingleKey::parse("ctrl-c").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('c'));
+ assert!(k.ctrl);
+ assert!(!k.alt);
+
+ let k = SingleKey::parse("alt-f").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('f'));
+ assert!(k.alt);
+ assert!(!k.ctrl);
+
+ let k = SingleKey::parse("ctrl-alt-x").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('x'));
+ assert!(k.ctrl && k.alt);
+ }
+
+ #[test]
+ fn parse_uppercase_implies_no_shift_flag() {
+ let k = SingleKey::parse("G").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('G'));
+ assert!(!k.shift);
+ assert!(!k.ctrl);
+ }
+
+ #[test]
+ fn parse_special_chars() {
+ let k = SingleKey::parse("ctrl-[").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('['));
+ assert!(k.ctrl);
+
+ let k = SingleKey::parse("?").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('?'));
+
+ let k = SingleKey::parse("/").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('/'));
+ }
+
+ #[test]
+ fn parse_multi_key_sequence() {
+ let ki = KeyInput::parse("g g").unwrap();
+ match ki {
+ KeyInput::Sequence(keys) => {
+ assert_eq!(keys.len(), 2);
+ assert_eq!(keys[0].code, KeyCodeValue::Char('g'));
+ assert_eq!(keys[1].code, KeyCodeValue::Char('g'));
+ }
+ _ => panic!("expected sequence"),
+ }
+ }
+
+ #[test]
+ fn display_round_trip() {
+ let cases = ["ctrl-c", "alt-f", "enter", "G", "tab", "pageup"];
+ for s in cases {
+ let k = KeyInput::parse(s).unwrap();
+ let display = k.to_string();
+ let k2 = KeyInput::parse(&display).unwrap();
+ assert_eq!(k, k2, "round-trip failed for {s}");
+ }
+
+ let ki = KeyInput::parse("g g").unwrap();
+ assert_eq!(ki.to_string(), "g g");
+ }
+
+ #[test]
+ fn from_event_basic() {
+ let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('c'));
+ assert!(k.ctrl);
+ assert!(!k.alt);
+
+ let event = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::Enter);
+ }
+
+ #[test]
+ fn from_event_uppercase() {
+ // Crossterm sends uppercase chars with SHIFT modifier
+ let event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('G'));
+ // shift flag should be cleared since the case encodes it
+ assert!(!k.shift);
+ }
+
+ #[test]
+ fn from_event_matches_parsed() {
+ // Verify that from_event and parse produce the same SingleKey
+ let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
+ let from_event = SingleKey::from_event(&event).unwrap();
+ let parsed = SingleKey::parse("ctrl-c").unwrap();
+ assert_eq!(from_event, parsed);
+
+ let event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT);
+ let from_event = SingleKey::from_event(&event).unwrap();
+ let parsed = SingleKey::parse("G").unwrap();
+ assert_eq!(from_event, parsed);
+ }
+
+ #[test]
+ fn parse_super_modifier() {
+ let k = SingleKey::parse("super-a").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('a'));
+ assert!(k.super_key);
+ assert!(!k.ctrl && !k.alt && !k.shift);
+
+ // "cmd" is an alias for "super"
+ let k2 = SingleKey::parse("cmd-a").unwrap();
+ assert_eq!(k, k2);
+
+ // "win" is an alias for "super"
+ let k3 = SingleKey::parse("win-a").unwrap();
+ assert_eq!(k, k3);
+ }
+
+ #[test]
+ fn parse_super_with_other_modifiers() {
+ let k = SingleKey::parse("super-ctrl-c").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('c'));
+ assert!(k.super_key && k.ctrl);
+ assert!(!k.alt && !k.shift);
+ }
+
+ #[test]
+ fn display_super_modifier() {
+ let k = SingleKey::parse("super-a").unwrap();
+ assert_eq!(k.to_string(), "super-a");
+
+ let k = SingleKey::parse("super-ctrl-x").unwrap();
+ assert_eq!(k.to_string(), "super-ctrl-x");
+ }
+
+ #[test]
+ fn display_round_trip_super() {
+ let k = KeyInput::parse("super-a").unwrap();
+ let display = k.to_string();
+ let k2 = KeyInput::parse(&display).unwrap();
+ assert_eq!(k, k2, "round-trip failed for super-a");
+ }
+
+ #[test]
+ fn from_event_super() {
+ let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('a'));
+ assert!(k.super_key);
+ assert!(!k.ctrl && !k.alt && !k.shift);
+ }
+
+ #[test]
+ fn from_event_super_matches_parsed() {
+ let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER);
+ let from_event = SingleKey::from_event(&event).unwrap();
+ let parsed = SingleKey::parse("super-a").unwrap();
+ assert_eq!(from_event, parsed);
+ }
+
+ #[test]
+ fn super_uppercase_preserves_super() {
+ // super-G should keep the super flag (unlike bare "G" which clears shift)
+ let k = SingleKey::parse("super-G").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('G'));
+ assert!(k.super_key);
+ }
+
+ #[test]
+ fn parse_errors() {
+ assert!(SingleKey::parse("ctrl-alt-shift-xxx").is_err());
+ assert!(SingleKey::parse("foobar-a").is_err());
+ }
+
+ #[test]
+ fn parse_function_keys() {
+ let k = SingleKey::parse("f1").unwrap();
+ assert_eq!(k.code, KeyCodeValue::F(1));
+ assert!(!k.ctrl && !k.alt && !k.shift);
+
+ let k = SingleKey::parse("F12").unwrap();
+ assert_eq!(k.code, KeyCodeValue::F(12));
+
+ let k = SingleKey::parse("ctrl-f5").unwrap();
+ assert_eq!(k.code, KeyCodeValue::F(5));
+ assert!(k.ctrl);
+
+ // F24 is valid (some keyboards have extended function keys)
+ let k = SingleKey::parse("f24").unwrap();
+ assert_eq!(k.code, KeyCodeValue::F(24));
+
+ // F0 and F25+ are invalid
+ assert!(SingleKey::parse("f0").is_err());
+ assert!(SingleKey::parse("f25").is_err());
+ }
+
+ #[test]
+ fn from_event_function_keys() {
+ let event = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::F(1));
+
+ let event = KeyEvent::new(KeyCode::F(12), KeyModifiers::CONTROL);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::F(12));
+ assert!(k.ctrl);
+ }
+
+ #[test]
+ fn display_function_keys() {
+ let k = SingleKey::parse("f1").unwrap();
+ assert_eq!(k.to_string(), "f1");
+
+ let k = SingleKey::parse("ctrl-f12").unwrap();
+ assert_eq!(k.to_string(), "ctrl-f12");
+ }
+
+ #[test]
+ fn function_key_round_trip() {
+ let cases = ["f1", "f12", "ctrl-f5", "alt-f10"];
+ for s in cases {
+ let k = KeyInput::parse(s).unwrap();
+ let display = k.to_string();
+ let k2 = KeyInput::parse(&display).unwrap();
+ assert_eq!(k, k2, "round-trip failed for {s}");
+ }
+ }
+
+ #[test]
+ fn from_event_function_key_matches_parsed() {
+ let event = KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE);
+ let from_event = SingleKey::from_event(&event).unwrap();
+ let parsed = SingleKey::parse("f12").unwrap();
+ assert_eq!(from_event, parsed);
+ }
+
+ #[test]
+ fn from_event_backtab_becomes_shift_tab() {
+ // Many terminals send BackTab for Shift+Tab
+ let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::Tab);
+ assert!(k.shift);
+ assert!(!k.ctrl && !k.alt);
+ }
+
+ #[test]
+ fn from_event_backtab_matches_parsed_shift_tab() {
+ let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE);
+ let from_event = SingleKey::from_event(&event).unwrap();
+ let parsed = SingleKey::parse("shift-tab").unwrap();
+ assert_eq!(from_event, parsed);
+ }
+
+ #[test]
+ fn from_event_backtab_with_ctrl() {
+ // BackTab with ctrl modifier
+ let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::CONTROL);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::Tab);
+ assert!(k.shift);
+ assert!(k.ctrl);
+ }
+
+ #[test]
+ fn parse_insert_key() {
+ let k = SingleKey::parse("insert").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Insert);
+ assert!(!k.ctrl && !k.alt && !k.shift);
+
+ let k = SingleKey::parse("ins").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Insert);
+
+ let k = SingleKey::parse("ctrl-insert").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Insert);
+ assert!(k.ctrl);
+ }
+
+ #[test]
+ fn from_event_insert_key() {
+ let event = KeyEvent::new(KeyCode::Insert, KeyModifiers::NONE);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::Insert);
+ }
+
+ #[test]
+ fn insert_key_round_trip() {
+ let k = KeyInput::parse("insert").unwrap();
+ let display = k.to_string();
+ assert_eq!(display, "insert");
+ let k2 = KeyInput::parse(&display).unwrap();
+ assert_eq!(k, k2);
+ }
+}
diff --git a/crates/turtle/src/command/client/search/keybindings/keymap.rs b/crates/turtle/src/command/client/search/keybindings/keymap.rs
new file mode 100644
index 00000000..0d362863
--- /dev/null
+++ b/crates/turtle/src/command/client/search/keybindings/keymap.rs
@@ -0,0 +1,233 @@
+use std::collections::HashMap;
+
+use super::actions::Action;
+use super::conditions::{ConditionExpr, EvalContext};
+use super::key::{KeyInput, SingleKey};
+
+/// A single rule within a keybinding: an optional condition and an action.
+/// If the condition is `None`, the rule always matches.
+#[derive(Debug, Clone)]
+pub struct KeyRule {
+ pub condition: Option<ConditionExpr>,
+ pub action: Action,
+}
+
+/// A keybinding is an ordered list of rules. The first rule whose condition
+/// matches (or has no condition) wins.
+#[derive(Debug, Clone)]
+pub struct KeyBinding {
+ pub rules: Vec<KeyRule>,
+}
+
+/// A keymap is a collection of keybindings indexed by key input.
+#[derive(Debug, Clone)]
+pub struct Keymap {
+ pub bindings: HashMap<KeyInput, KeyBinding>,
+}
+
+impl KeyRule {
+ /// Create an unconditional rule.
+ pub fn always(action: Action) -> Self {
+ KeyRule {
+ condition: None,
+ action,
+ }
+ }
+
+ /// Create a conditional rule. Accepts any type convertible to `ConditionExpr`,
+ /// including bare `ConditionAtom` values.
+ pub fn when(condition: impl Into<ConditionExpr>, action: Action) -> Self {
+ KeyRule {
+ condition: Some(condition.into()),
+ action,
+ }
+ }
+}
+
+impl KeyBinding {
+ /// Create a simple (unconditional) binding.
+ pub fn simple(action: Action) -> Self {
+ KeyBinding {
+ rules: vec![KeyRule::always(action)],
+ }
+ }
+
+ /// Create a conditional binding from a list of rules.
+ pub fn conditional(rules: Vec<KeyRule>) -> Self {
+ KeyBinding { rules }
+ }
+}
+
+impl Keymap {
+ /// Create an empty keymap.
+ pub fn new() -> Self {
+ Keymap {
+ bindings: HashMap::new(),
+ }
+ }
+
+ /// Bind a key input to a simple (unconditional) action.
+ pub fn bind(&mut self, key: KeyInput, action: Action) {
+ self.bindings.insert(key, KeyBinding::simple(action));
+ }
+
+ /// Bind a key input to a conditional set of rules.
+ pub fn bind_conditional(&mut self, key: KeyInput, rules: Vec<KeyRule>) {
+ self.bindings.insert(key, KeyBinding::conditional(rules));
+ }
+
+ /// Resolve a key input to an action given the current evaluation context.
+ /// Returns `None` if the key has no binding or no rule's condition matches.
+ pub fn resolve(&self, key: &KeyInput, ctx: &EvalContext) -> Option<Action> {
+ let binding = self.bindings.get(key)?;
+ for rule in &binding.rules {
+ match &rule.condition {
+ None => return Some(rule.action.clone()),
+ Some(cond) if cond.evaluate(ctx) => return Some(rule.action.clone()),
+ Some(_) => {}
+ }
+ }
+ None
+ }
+
+ /// Check if any binding starts with the given single key as the first key
+ /// of a multi-key sequence. Used to detect pending multi-key sequences.
+ pub fn has_sequence_starting_with(&self, prefix: &SingleKey) -> bool {
+ self.bindings.keys().any(|ki| match ki {
+ KeyInput::Sequence(keys) => keys.first() == Some(prefix),
+ KeyInput::Single(_) => false,
+ })
+ }
+
+ /// Merge another keymap into this one. Keys from `other` override keys in `self`.
+ #[expect(dead_code)]
+ pub fn merge(&mut self, other: &Keymap) {
+ for (key, binding) in &other.bindings {
+ self.bindings.insert(key.clone(), binding.clone());
+ }
+ }
+}
+
+impl Default for Keymap {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::super::conditions::ConditionAtom;
+ use super::*;
+
+ fn make_ctx(cursor: usize, width: usize, selected: usize, len: usize) -> EvalContext {
+ EvalContext {
+ cursor_position: cursor,
+ input_width: width,
+ input_byte_len: width,
+ selected_index: selected,
+ results_len: len,
+ original_input_empty: false,
+ has_context: false,
+ }
+ }
+
+ #[test]
+ fn simple_binding_resolves() {
+ let mut keymap = Keymap::new();
+ let key = KeyInput::parse("ctrl-c").unwrap();
+ keymap.bind(key.clone(), Action::ReturnOriginal);
+
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(keymap.resolve(&key, &ctx), Some(Action::ReturnOriginal));
+ }
+
+ #[test]
+ fn conditional_first_match_wins() {
+ let mut keymap = Keymap::new();
+ let key = KeyInput::parse("left").unwrap();
+ keymap.bind_conditional(
+ key.clone(),
+ vec![
+ KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit),
+ KeyRule::always(Action::CursorLeft),
+ ],
+ );
+
+ // Cursor at start → Exit
+ let ctx = make_ctx(0, 5, 0, 10);
+ assert_eq!(keymap.resolve(&key, &ctx), Some(Action::Exit));
+
+ // Cursor not at start → CursorLeft
+ let ctx = make_ctx(3, 5, 0, 10);
+ assert_eq!(keymap.resolve(&key, &ctx), Some(Action::CursorLeft));
+ }
+
+ #[test]
+ fn no_match_returns_none() {
+ let keymap = Keymap::new();
+ let key = KeyInput::parse("ctrl-c").unwrap();
+ let ctx = make_ctx(0, 0, 0, 0);
+ assert_eq!(keymap.resolve(&key, &ctx), None);
+ }
+
+ #[test]
+ fn conditional_no_condition_matches_returns_none() {
+ let mut keymap = Keymap::new();
+ let key = KeyInput::parse("left").unwrap();
+ // Only one rule with a condition that won't match
+ keymap.bind_conditional(
+ key.clone(),
+ vec![KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit)],
+ );
+
+ // Cursor not at start → no match
+ let ctx = make_ctx(3, 5, 0, 10);
+ assert_eq!(keymap.resolve(&key, &ctx), None);
+ }
+
+ #[test]
+ fn has_sequence_starting_with() {
+ let mut keymap = Keymap::new();
+ let seq = KeyInput::parse("g g").unwrap();
+ keymap.bind(seq, Action::ScrollToTop);
+
+ let g = SingleKey::parse("g").unwrap();
+ assert!(keymap.has_sequence_starting_with(&g));
+
+ let h = SingleKey::parse("h").unwrap();
+ assert!(!keymap.has_sequence_starting_with(&h));
+ }
+
+ #[test]
+ fn merge_overrides() {
+ let mut base = Keymap::new();
+ let key = KeyInput::parse("ctrl-c").unwrap();
+ base.bind(key.clone(), Action::ReturnOriginal);
+
+ let mut overlay = Keymap::new();
+ overlay.bind(key.clone(), Action::Exit);
+
+ base.merge(&overlay);
+
+ let ctx = make_ctx(0, 0, 0, 0);
+ assert_eq!(base.resolve(&key, &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn merge_preserves_unoverridden() {
+ let mut base = Keymap::new();
+ let key1 = KeyInput::parse("ctrl-c").unwrap();
+ let key2 = KeyInput::parse("ctrl-d").unwrap();
+ base.bind(key1.clone(), Action::ReturnOriginal);
+ base.bind(key2.clone(), Action::DeleteCharAfter);
+
+ let mut overlay = Keymap::new();
+ overlay.bind(key1.clone(), Action::Exit);
+
+ base.merge(&overlay);
+
+ let ctx = make_ctx(0, 0, 0, 0);
+ assert_eq!(base.resolve(&key1, &ctx), Some(Action::Exit));
+ assert_eq!(base.resolve(&key2, &ctx), Some(Action::DeleteCharAfter));
+ }
+}
diff --git a/crates/turtle/src/command/client/search/keybindings/mod.rs b/crates/turtle/src/command/client/search/keybindings/mod.rs
new file mode 100644
index 00000000..3b6eb2b2
--- /dev/null
+++ b/crates/turtle/src/command/client/search/keybindings/mod.rs
@@ -0,0 +1,14 @@
+pub mod actions;
+pub mod conditions;
+pub mod defaults;
+pub mod key;
+pub mod keymap;
+
+pub use actions::Action;
+#[expect(unused_imports)]
+pub use conditions::{ConditionAtom, ConditionExpr, EvalContext};
+pub use defaults::KeymapSet;
+#[expect(unused_imports)]
+pub use key::{KeyCodeValue, KeyInput, SingleKey};
+#[expect(unused_imports)]
+pub use keymap::{KeyBinding, KeyRule, Keymap};