aboutsummaryrefslogtreecommitdiffstats
path: root/crates/turtle/src/command/client/search/keybindings
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-09 21:43:23 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-09 21:43:23 +0200
commit3223d93cb3c77ab02aa0a35f2a8314e447cee9a4 (patch)
tree3971a1f37f5fe3cadb747c5229a0d54e868e45e3 /crates/turtle/src/command/client/search/keybindings
parentfix(client/sync): Pass through precise error on `SyncError::WrongKey` (diff)
downloadatuin-3223d93cb3c77ab02aa0a35f2a8314e447cee9a4.zip
chore: Separate daemon, client, server, and lib into crates
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.rs1274
-rw-r--r--crates/turtle/src/command/client/search/keybindings/key.rs633
-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, 0 insertions, 3277 deletions
diff --git a/crates/turtle/src/command/client/search/keybindings/actions.rs b/crates/turtle/src/command/client/search/keybindings/actions.rs
deleted file mode 100644
index 2842d618..00000000
--- a/crates/turtle/src/command/client/search/keybindings/actions.rs
+++ /dev/null
@@ -1,322 +0,0 @@
-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(crate) 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(crate) 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(Self::AcceptNth(n));
- }
- if let Some(rest) = s.strip_prefix("return-selection-")
- && let Ok(n) = rest.parse::<u8>()
- && (1..=9).contains(&n)
- {
- return Ok(Self::ReturnSelectionNth(n));
- }
-
- match s {
- "cursor-left" => Ok(Self::CursorLeft),
- "cursor-right" => Ok(Self::CursorRight),
- "cursor-word-left" => Ok(Self::CursorWordLeft),
- "cursor-word-right" => Ok(Self::CursorWordRight),
- "cursor-word-end" => Ok(Self::CursorWordEnd),
- "cursor-start" => Ok(Self::CursorStart),
- "cursor-end" => Ok(Self::CursorEnd),
-
- "delete-char-before" => Ok(Self::DeleteCharBefore),
- "delete-char-after" => Ok(Self::DeleteCharAfter),
- "delete-word-before" => Ok(Self::DeleteWordBefore),
- "delete-word-after" => Ok(Self::DeleteWordAfter),
- "delete-to-word-boundary" => Ok(Self::DeleteToWordBoundary),
- "clear-line" => Ok(Self::ClearLine),
- "clear-to-start" => Ok(Self::ClearToStart),
- "clear-to-end" => Ok(Self::ClearToEnd),
-
- "select-next" => Ok(Self::SelectNext),
- "select-previous" => Ok(Self::SelectPrevious),
- "scroll-half-page-up" => Ok(Self::ScrollHalfPageUp),
- "scroll-half-page-down" => Ok(Self::ScrollHalfPageDown),
- "scroll-page-up" => Ok(Self::ScrollPageUp),
- "scroll-page-down" => Ok(Self::ScrollPageDown),
- "scroll-to-top" => Ok(Self::ScrollToTop),
- "scroll-to-bottom" => Ok(Self::ScrollToBottom),
- "scroll-to-screen-top" => Ok(Self::ScrollToScreenTop),
- "scroll-to-screen-middle" => Ok(Self::ScrollToScreenMiddle),
- "scroll-to-screen-bottom" => Ok(Self::ScrollToScreenBottom),
-
- "accept" => Ok(Self::Accept),
- "return-selection" => Ok(Self::ReturnSelection),
- "copy" => Ok(Self::Copy),
- "delete" => Ok(Self::Delete),
- "delete-all" => Ok(Self::DeleteAll),
- "return-original" => Ok(Self::ReturnOriginal),
- "return-query" => Ok(Self::ReturnQuery),
- "exit" => Ok(Self::Exit),
- "redraw" => Ok(Self::Redraw),
- "cycle-filter-mode" => Ok(Self::CycleFilterMode),
- "cycle-search-mode" => Ok(Self::CycleSearchMode),
- "switch-context" => Ok(Self::SwitchContext),
- "clear-context" => Ok(Self::ClearContext),
- "toggle-tab" => Ok(Self::ToggleTab),
-
- "vim-enter-normal" => Ok(Self::VimEnterNormal),
- "vim-enter-insert" => Ok(Self::VimEnterInsert),
- "vim-enter-insert-after" => Ok(Self::VimEnterInsertAfter),
- "vim-enter-insert-at-start" => Ok(Self::VimEnterInsertAtStart),
- "vim-enter-insert-at-end" => Ok(Self::VimEnterInsertAtEnd),
- "vim-search-insert" => Ok(Self::VimSearchInsert),
- "vim-change-to-end" => Ok(Self::VimChangeToEnd),
- "enter-prefix-mode" => Ok(Self::EnterPrefixMode),
-
- "inspect-previous" => Ok(Self::InspectPrevious),
- "inspect-next" => Ok(Self::InspectNext),
-
- "noop" => Ok(Self::Noop),
-
- _ => Err(format!("unknown action: {s}")),
- }
- }
-
- /// Convert to a kebab-case string.
- pub(crate) fn as_str(&self) -> String {
- match self {
- Self::CursorLeft => "cursor-left".to_string(),
- Self::CursorRight => "cursor-right".to_string(),
- Self::CursorWordLeft => "cursor-word-left".to_string(),
- Self::CursorWordRight => "cursor-word-right".to_string(),
- Self::CursorWordEnd => "cursor-word-end".to_string(),
- Self::CursorStart => "cursor-start".to_string(),
- Self::CursorEnd => "cursor-end".to_string(),
-
- Self::DeleteCharBefore => "delete-char-before".to_string(),
- Self::DeleteCharAfter => "delete-char-after".to_string(),
- Self::DeleteWordBefore => "delete-word-before".to_string(),
- Self::DeleteWordAfter => "delete-word-after".to_string(),
- Self::DeleteToWordBoundary => "delete-to-word-boundary".to_string(),
- Self::ClearLine => "clear-line".to_string(),
- Self::ClearToStart => "clear-to-start".to_string(),
- Self::ClearToEnd => "clear-to-end".to_string(),
-
- Self::SelectNext => "select-next".to_string(),
- Self::SelectPrevious => "select-previous".to_string(),
- Self::ScrollHalfPageUp => "scroll-half-page-up".to_string(),
- Self::ScrollHalfPageDown => "scroll-half-page-down".to_string(),
- Self::ScrollPageUp => "scroll-page-up".to_string(),
- Self::ScrollPageDown => "scroll-page-down".to_string(),
- Self::ScrollToTop => "scroll-to-top".to_string(),
- Self::ScrollToBottom => "scroll-to-bottom".to_string(),
- Self::ScrollToScreenTop => "scroll-to-screen-top".to_string(),
- Self::ScrollToScreenMiddle => "scroll-to-screen-middle".to_string(),
- Self::ScrollToScreenBottom => "scroll-to-screen-bottom".to_string(),
-
- Self::Accept => "accept".to_string(),
- Self::AcceptNth(n) => format!("accept-{n}"),
- Self::ReturnSelection => "return-selection".to_string(),
- Self::ReturnSelectionNth(n) => format!("return-selection-{n}"),
- Self::Copy => "copy".to_string(),
- Self::Delete => "delete".to_string(),
- Self::DeleteAll => "delete-all".to_string(),
- Self::ReturnOriginal => "return-original".to_string(),
- Self::ReturnQuery => "return-query".to_string(),
- Self::Exit => "exit".to_string(),
- Self::Redraw => "redraw".to_string(),
- Self::CycleFilterMode => "cycle-filter-mode".to_string(),
- Self::CycleSearchMode => "cycle-search-mode".to_string(),
- Self::SwitchContext => "switch-context".to_string(),
- Self::ClearContext => "clear-context".to_string(),
- Self::ToggleTab => "toggle-tab".to_string(),
-
- Self::VimEnterNormal => "vim-enter-normal".to_string(),
- Self::VimEnterInsert => "vim-enter-insert".to_string(),
- Self::VimEnterInsertAfter => "vim-enter-insert-after".to_string(),
- Self::VimEnterInsertAtStart => "vim-enter-insert-at-start".to_string(),
- Self::VimEnterInsertAtEnd => "vim-enter-insert-at-end".to_string(),
- Self::VimSearchInsert => "vim-search-insert".to_string(),
- Self::VimChangeToEnd => "vim-change-to-end".to_string(),
- Self::EnterPrefixMode => "enter-prefix-mode".to_string(),
-
- Self::InspectPrevious => "inspect-previous".to_string(),
- Self::InspectNext => "inspect-next".to_string(),
-
- Self::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)?;
- Self::from_str(&s).map_err(serde::de::Error::custom)
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::Action;
-
- #[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
deleted file mode 100644
index fd993f2b..00000000
--- a/crates/turtle/src/command/client/search/keybindings/conditions.rs
+++ /dev/null
@@ -1,801 +0,0 @@
-use std::fmt;
-
-use serde::{Deserialize, Deserializer, Serialize, Serializer};
-
-/// Atomic (leaf) conditions that can be evaluated against state.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) 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(crate) enum ConditionExpr {
- Atom(ConditionAtom),
- Not(Box<Self>),
- And(Box<Self>, Box<Self>),
- Or(Box<Self>, Box<Self>),
-}
-
-/// Context needed to evaluate conditions. This is a pure snapshot of state —
-/// no references to mutable data.
-pub(crate) struct EvalContext {
- /// Current cursor position (unicode width units).
- pub(crate) cursor_position: usize,
- /// Width of the input string in unicode width units.
- pub(crate) input_width: usize,
- /// Byte length of the input string.
- pub(crate) input_byte_len: usize,
- /// Currently selected index in the results list.
- pub(crate) selected_index: usize,
- /// Total number of results.
- pub(crate) results_len: usize,
- /// Whether the original input (query passed to the TUI) was empty.
- pub(crate) original_input_empty: bool,
- /// Whether we use a search context of a command from the history.
- pub(crate) has_context: bool,
-}
-
-// ---------------------------------------------------------------------------
-// ConditionAtom
-// ---------------------------------------------------------------------------
-
-impl ConditionAtom {
- /// Evaluate this atom against the given context.
- pub(crate) fn evaluate(&self, ctx: &EvalContext) -> bool {
- match self {
- Self::CursorAtStart => ctx.cursor_position == 0,
- Self::CursorAtEnd => ctx.cursor_position == ctx.input_width,
- Self::InputEmpty => ctx.input_byte_len == 0,
- Self::OriginalInputEmpty => ctx.original_input_empty,
- Self::ListAtEnd => {
- ctx.results_len == 0 || ctx.selected_index >= ctx.results_len.saturating_sub(1)
- }
- Self::ListAtStart => ctx.results_len == 0 || ctx.selected_index == 0,
- Self::NoResults => ctx.results_len == 0,
- Self::HasResults => ctx.results_len > 0,
- Self::HasContext => ctx.has_context,
- }
- }
-
- /// Parse from a kebab-case string.
- pub(crate) fn from_str(s: &str) -> Result<Self, String> {
- match s {
- "cursor-at-start" => Ok(Self::CursorAtStart),
- "cursor-at-end" => Ok(Self::CursorAtEnd),
- "input-empty" => Ok(Self::InputEmpty),
- "original-input-empty" => Ok(Self::OriginalInputEmpty),
- "list-at-end" => Ok(Self::ListAtEnd),
- "list-at-start" => Ok(Self::ListAtStart),
- "no-results" => Ok(Self::NoResults),
- "has-results" => Ok(Self::HasResults),
- "has-context" => Ok(Self::HasContext),
- _ => Err(format!("unknown condition: {s}")),
- }
- }
-
- /// Convert to a kebab-case string.
- pub(crate) fn as_str(&self) -> &'static str {
- match self {
- Self::CursorAtStart => "cursor-at-start",
- Self::CursorAtEnd => "cursor-at-end",
- Self::InputEmpty => "input-empty",
- Self::OriginalInputEmpty => "original-input-empty",
- Self::ListAtEnd => "list-at-end",
- Self::ListAtStart => "list-at-start",
- Self::NoResults => "no-results",
- Self::HasResults => "has-results",
- Self::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(crate) fn evaluate(&self, ctx: &EvalContext) -> bool {
- match self {
- Self::Atom(atom) => atom.evaluate(ctx),
- Self::Not(inner) => !inner.evaluate(ctx),
- Self::And(lhs, rhs) => lhs.evaluate(ctx) && rhs.evaluate(ctx),
- Self::Or(lhs, rhs) => lhs.evaluate(ctx) || rhs.evaluate(ctx),
- }
- }
-}
-
-// ---------------------------------------------------------------------------
-// ConditionExpr — ergonomic builders
-// ---------------------------------------------------------------------------
-
-impl From<ConditionAtom> for ConditionExpr {
- fn from(atom: ConditionAtom) -> Self {
- Self::Atom(atom)
- }
-}
-
-#[expect(dead_code)]
-impl ConditionExpr {
- /// Negate this expression: `!self`.
- pub(crate) fn not(self) -> Self {
- Self::Not(Box::new(self))
- }
-
- /// Conjoin with another expression: `self && other`.
- pub(crate) fn and(self, other: Self) -> Self {
- Self::And(Box::new(self), Box::new(other))
- }
-
- /// Disjoin with another expression: `self || other`.
- pub(crate) fn or(self, other: Self) -> Self {
- Self::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(crate) 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 {
- Self::Or(..) => Prec::Or,
- Self::And(..) => Prec::And,
- Self::Not(..) => Prec::Not,
- Self::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 {
- Self::Atom(atom) => write!(f, "{atom}")?,
- Self::Not(inner) => {
- write!(f, "!")?;
- inner.fmt_with_prec(f, Prec::Not)?;
- }
- Self::And(lhs, rhs) => {
- lhs.fmt_with_prec(f, Prec::And)?;
- write!(f, " && ")?;
- rhs.fmt_with_prec(f, Prec::And)?;
- }
- Self::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)?;
- Self::parse(&s).map_err(serde::de::Error::custom)
- }
-}
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-#[cfg(test)]
-mod tests {
- use super::{ConditionAtom, ConditionExpr, EvalContext};
-
- 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
deleted file mode 100644
index 4d00b475..00000000
--- a/crates/turtle/src/command/client/search/keybindings/defaults.rs
+++ /dev/null
@@ -1,1274 +0,0 @@
-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(crate) struct KeymapSet {
- pub(crate) emacs: Keymap,
- pub(crate) vim_normal: Keymap,
- pub(crate) vim_insert: Keymap,
- pub(crate) inspector: Keymap,
- pub(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) fn defaults(settings: &Settings) -> Self {
- Self {
- 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(crate) 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::{
- Action, HashMap, KeymapSet, Settings, default_emacs_keymap, default_inspector_keymap,
- default_prefix_keymap, default_vim_insert_keymap, default_vim_normal_keymap, key,
- parse_binding_config,
- };
- 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::new().unwrap()
- }
-
- // -- 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 = 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 = 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!(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!(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 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
deleted file mode 100644
index 5e772238..00000000
--- a/crates/turtle/src/command/client/search/keybindings/key.rs
+++ /dev/null
@@ -1,633 +0,0 @@
-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(crate) struct SingleKey {
- pub(crate) code: KeyCodeValue,
- pub(crate) ctrl: bool,
- pub(crate) alt: bool,
- pub(crate) shift: bool,
- pub(crate) super_key: bool,
-}
-
-/// The key code portion of a key press.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
-#[expect(
- variant_size_differences,
- reason = "It's not that much. So should be ok?"
-)]
-pub(crate) 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(crate) enum KeyInput {
- Single(SingleKey),
- Sequence(Vec<SingleKey>),
-}
-
-impl SingleKey {
- /// Convert a crossterm `KeyEvent` into a `SingleKey`.
- pub(crate) 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(Self {
- 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(Self {
- 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(Self {
- 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(crate) 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(Self {
- 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(Self {
- 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(crate) 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(Self::Sequence(keys?))
- } else {
- Ok(Self::Single(SingleKey::parse(s)?))
- }
- }
-}
-
-impl fmt::Display for KeyInput {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::Single(k) => write!(f, "{k}"),
- Self::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)?;
- Self::parse(&s).map_err(serde::de::Error::custom)
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::{KeyCodeValue, KeyInput, SingleKey};
- 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
deleted file mode 100644
index 067d9403..00000000
--- a/crates/turtle/src/command/client/search/keybindings/keymap.rs
+++ /dev/null
@@ -1,233 +0,0 @@
-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(crate) struct KeyRule {
- pub(crate) condition: Option<ConditionExpr>,
- pub(crate) 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(crate) struct KeyBinding {
- pub(crate) rules: Vec<KeyRule>,
-}
-
-/// A keymap is a collection of keybindings indexed by key input.
-#[derive(Debug, Clone)]
-pub(crate) struct Keymap {
- pub(crate) bindings: HashMap<KeyInput, KeyBinding>,
-}
-
-impl KeyRule {
- /// Create an unconditional rule.
- pub(crate) fn always(action: Action) -> Self {
- Self {
- condition: None,
- action,
- }
- }
-
- /// Create a conditional rule. Accepts any type convertible to `ConditionExpr`,
- /// including bare `ConditionAtom` values.
- pub(crate) fn when(condition: impl Into<ConditionExpr>, action: Action) -> Self {
- Self {
- condition: Some(condition.into()),
- action,
- }
- }
-}
-
-impl KeyBinding {
- /// Create a simple (unconditional) binding.
- pub(crate) fn simple(action: Action) -> Self {
- Self {
- rules: vec![KeyRule::always(action)],
- }
- }
-
- /// Create a conditional binding from a list of rules.
- pub(crate) fn conditional(rules: Vec<KeyRule>) -> Self {
- Self { rules }
- }
-}
-
-impl Keymap {
- /// Create an empty keymap.
- pub(crate) fn new() -> Self {
- Self {
- bindings: HashMap::new(),
- }
- }
-
- /// Bind a key input to a simple (unconditional) action.
- pub(crate) 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(crate) 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(crate) 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(crate) 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(crate) fn merge(&mut self, other: &Self) {
- 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::{Action, EvalContext, KeyInput, KeyRule, Keymap, SingleKey};
-
- 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
deleted file mode 100644
index cdca0406..00000000
--- a/crates/turtle/src/command/client/search/keybindings/mod.rs
+++ /dev/null
@@ -1,14 +0,0 @@
-pub(crate) mod actions;
-pub(crate) mod conditions;
-pub(crate) mod defaults;
-pub(crate) mod key;
-pub(crate) mod keymap;
-
-pub(crate) use actions::Action;
-#[expect(unused_imports)]
-pub(crate) use conditions::{ConditionAtom, ConditionExpr, EvalContext};
-pub(crate) use defaults::KeymapSet;
-#[expect(unused_imports)]
-pub(crate) use key::{KeyCodeValue, KeyInput, SingleKey};
-#[expect(unused_imports)]
-pub(crate) use keymap::{KeyBinding, KeyRule, Keymap};