From 28a1bcffd24c2a34b5ebe73cc09b25cd7cadc431 Mon Sep 17 00:00:00 2001 From: JoseVelazcoH Date: Sun, 5 Jul 2026 15:40:12 -0600 Subject: [PATCH 1/5] feat(hooks): make the finder keybinding configurable Add [shell] keybinding config (default ^R) that templatizes the shell integration hooks. zsh uses caret notation directly; bash values are converted to \C-x form. "none"/empty drops the binding entirely. Closes #76 --- docs/features.md | 3 ++ shells/thoth.bash | 2 +- shells/thoth.zsh | 2 +- src/cli.rs | 6 +++- src/config.rs | 46 ++++++++++++++++++++++++- src/hooks.rs | 85 ++++++++++++++++++++++++++++++++++++++++++++--- 6 files changed, 136 insertions(+), 8 deletions(-) diff --git a/docs/features.md b/docs/features.md index 61bc213..64debf9 100644 --- a/docs/features.md +++ b/docs/features.md @@ -44,6 +44,9 @@ Press **`Ctrl-R`** (or run `tth`) to open the finder. As you type, the list filt with fuzzy matching across the command, project, directory, and tags. A preview pane on the right shows the full details of the highlighted entry. +The trigger key is configurable via `[shell] keybinding` (caret notation, e.g. `"^T"`); +set it to `"none"` to bind the widget yourself. Re-run `tth init` after changing it. + #### Modes (vim-style) The finder is modal. **Insert** is the default (type to filter); `Esc` drops you into diff --git a/shells/thoth.bash b/shells/thoth.bash index 313a75d..d5afba9 100644 --- a/shells/thoth.bash +++ b/shells/thoth.bash @@ -71,7 +71,7 @@ _tth_widget() { fi } -bind -x '"\C-r": _tth_widget' +bind -x '"__THOTH_KEYBIND__": _tth_widget' tth-tag() { eval "$(command tth tag "$1")" diff --git a/shells/thoth.zsh b/shells/thoth.zsh index ad6bd38..e395fae 100644 --- a/shells/thoth.zsh +++ b/shells/thoth.zsh @@ -77,4 +77,4 @@ _tth_widget() { } zle -N _tth_widget -bindkey '^R' _tth_widget +bindkey '__THOTH_KEYBIND__' _tth_widget diff --git a/src/cli.rs b/src/cli.rs index 597b665..c5eaa09 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -494,7 +494,11 @@ pub fn run() -> Result<(), crate::error::ThothError> { Some(Cmd::Init(args)) => { let shell_env = std::env::var("SHELL").ok(); let shell = crate::hooks::detect_shell(args.shell.as_deref(), shell_env.as_deref())?; - print!("{}", crate::hooks::render_init(&shell)); + let cfg = crate::config::load(); + print!( + "{}", + crate::hooks::render_init(&shell, &cfg.shell.keybinding) + ); } Some(Cmd::Tag(args)) => { if args.name.is_empty() { diff --git a/src/config.rs b/src/config.rs index 056bce4..56495f4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -40,9 +40,16 @@ default_limit = 50 # filter = ["^\\s*tth\\b", "--password", "export .*TOKEN"] # [theme] -# Built-in themes: default, ember, frost, latte, frappe, macchiato, mocha +# Built-in themes: default, ember, frost, latte, frappe, macchiato, mocha, +# dracula, tokyonight, rosepine, solarized, kanagawa # You can also drop a .toml file in ~/.config/thoth/themes/ for a custom theme. # name = "default" + +# [shell] +# Key that opens the interactive finder, in caret notation (e.g. "^R", "^T"). +# Set to "none" to skip binding a key and bind the widget yourself. +# Takes effect after re-running `tth init` (regenerate your shell hook). +# keybinding = "^R" "#; #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)] @@ -153,6 +160,10 @@ fn default_theme_name() -> String { "default".into() } +fn default_keybinding() -> String { + "^R".into() +} + #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] #[serde(default)] pub struct ThemeSection { @@ -168,6 +179,21 @@ impl Default for ThemeSection { } } +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(default)] +pub struct ShellSection { + #[serde(default = "default_keybinding")] + pub keybinding: String, +} + +impl Default for ShellSection { + fn default() -> Self { + Self { + keybinding: default_keybinding(), + } + } +} + #[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq)] #[serde(default)] pub struct Config { @@ -176,6 +202,7 @@ pub struct Config { pub search: Search, pub history: History, pub theme: ThemeSection, + pub shell: ShellSection, } fn config_path_from(thoth_config: Option<&str>, xdg_config: Option<&str>, home: &Path) -> PathBuf { @@ -777,6 +804,23 @@ mod tests { assert_eq!(ThemeSection::default().name, "default"); } + #[test] + fn shell_section_default_keybinding_is_ctrl_r() { + assert_eq!(ShellSection::default().keybinding, "^R"); + } + + #[test] + fn parse_empty_string_keybinding_is_default() { + let cfg = parse("").unwrap(); + assert_eq!(cfg.shell.keybinding, "^R"); + } + + #[test] + fn parse_custom_keybinding() { + let cfg = parse("[shell]\nkeybinding = \"^T\"\n").unwrap(); + assert_eq!(cfg.shell.keybinding, "^T"); + } + #[test] fn parse_empty_string_theme_name_is_default() { let cfg = parse("").unwrap(); diff --git a/src/hooks.rs b/src/hooks.rs index 29288ec..504885c 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -47,6 +47,8 @@ pub fn default_rc_path(shell: &Shell, home: &Path) -> PathBuf { } } +const KEYBIND_PLACEHOLDER: &str = "__THOTH_KEYBIND__"; + fn hook_body(shell: &Shell) -> &'static str { match shell { Shell::Bash => BASH_HOOK, @@ -54,8 +56,41 @@ fn hook_body(shell: &Shell) -> &'static str { } } -pub fn render_init(shell: &Shell) -> &'static str { - hook_body(shell) +/// Convert a caret-notation keybinding (e.g. `^R`) to the shell's native form. +/// +/// zsh's `bindkey` accepts caret notation directly, while bash's `bind -x` +/// expects `\C-x`. Non caret-style values pass through unchanged. +fn shell_keybinding(shell: &Shell, keybinding: &str) -> String { + match shell { + Shell::Zsh => keybinding.to_string(), + Shell::Bash => { + if let Some(rest) = keybinding.strip_prefix('^') { + if rest.len() == 1 && rest.chars().all(|c| c.is_ascii_alphabetic()) { + return format!("\\C-{}", rest.to_ascii_lowercase()); + } + } + keybinding.to_string() + } + } +} + +/// Render the shell integration script, binding the finder to `keybinding`. +/// +/// An empty or `none` keybinding drops the binding line entirely, letting the +/// user bind the widget themselves. +pub fn render_init(shell: &Shell, keybinding: &str) -> String { + let body = hook_body(shell); + let key = keybinding.trim(); + if key.is_empty() || key.eq_ignore_ascii_case("none") { + return body + .lines() + .filter(|line| !line.contains(KEYBIND_PLACEHOLDER)) + .collect::>() + .join("\n") + + "\n"; + } + let native = shell_keybinding(shell, key); + body.replace(KEYBIND_PLACEHOLDER, &native) } fn eval_line(shell: &Shell) -> String { @@ -453,7 +488,7 @@ mod tests { #[test] fn render_init_zsh_returns_zsh_hook() { - let script = render_init(&Shell::Zsh); + let script = render_init(&Shell::Zsh, "^R"); assert!(script.contains("_tth_preexec")); assert!(script.contains("bindkey '^R'")); assert!(!script.contains(SENTINEL_BEGIN)); @@ -461,12 +496,54 @@ mod tests { #[test] fn render_init_bash_returns_bash_hook() { - let script = render_init(&Shell::Bash); + let script = render_init(&Shell::Bash, "^R"); assert!(script.contains("_thoth_preexec")); assert!(script.contains("bind -x")); assert!(!script.contains(SENTINEL_BEGIN)); } + #[test] + fn render_init_default_leaves_no_placeholder() { + let zsh = render_init(&Shell::Zsh, "^R"); + let bash = render_init(&Shell::Bash, "^R"); + assert!(!zsh.contains(KEYBIND_PLACEHOLDER)); + assert!(!bash.contains(KEYBIND_PLACEHOLDER)); + } + + #[test] + fn render_init_bash_default_uses_control_r() { + let script = render_init(&Shell::Bash, "^R"); + assert!(script.contains(r#"bind -x '"\C-r": _tth_widget'"#)); + } + + #[test] + fn render_init_zsh_custom_keybinding() { + let script = render_init(&Shell::Zsh, "^T"); + assert!(script.contains("bindkey '^T'")); + assert!(!script.contains("bindkey '^R'")); + } + + #[test] + fn render_init_bash_custom_keybinding_converted() { + let script = render_init(&Shell::Bash, "^T"); + assert!(script.contains(r#"bind -x '"\C-t": _tth_widget'"#)); + } + + #[test] + fn render_init_none_drops_binding_zsh() { + let script = render_init(&Shell::Zsh, "none"); + assert!(!script.contains("bindkey")); + assert!(script.contains("_tth_widget")); + assert!(!script.contains(KEYBIND_PLACEHOLDER)); + } + + #[test] + fn render_init_empty_drops_binding_bash() { + let script = render_init(&Shell::Bash, " "); + assert!(!script.contains("bind -x")); + assert!(!script.contains(KEYBIND_PLACEHOLDER)); + } + #[test] fn eval_line_zsh_format() { let line = eval_line(&Shell::Zsh); From 63931411807291d6c305ff1d253909c8e5585c21 Mon Sep 17 00:00:00 2001 From: JoseVelazcoH Date: Sun, 5 Jul 2026 15:51:53 -0600 Subject: [PATCH 2/5] feat(config): expose shell.keybinding in config get/set/show Wire the new [shell] keybinding key into 'tth config' so it can be read and written like other keys, and show it (plus theme.name) in the config listing. --- src/config.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index 56495f4..bf6c920 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,7 @@ const VALID_KEYS: &[(&str, &str)] = &[ ("tui.orientation", r#""bottom" or "top""#), ("search.default_limit", "positive integer"), ("theme.name", "theme name string"), + ("shell.keybinding", "caret-notation key (e.g. \"^R\") or \"none\""), ]; pub const DEFAULT_CONFIG_TOML: &str = r#"# Thoth configuration. All settings are optional; values shown are the defaults. @@ -379,6 +380,8 @@ pub fn get_value(cfg: &Config, key: &str) -> Result { Ok(v.to_string()) } "search.default_limit" => Ok(cfg.search.default_limit.to_string()), + "theme.name" => Ok(cfg.theme.name.clone()), + "shell.keybinding" => Ok(cfg.shell.keybinding.clone()), _ => Err(ThothError::Config(format!( "unknown key '{}'; valid keys: {}", key, @@ -433,7 +436,7 @@ pub fn apply_set(existing_toml: &str, key: &str, value: &str) -> Result toml_edit::value(value) + "theme.name" | "shell.keybinding" => toml_edit::value(value) .into_value() .map_err(|e| ThothError::Config(format!("toml_edit error: {}", e)))?, _ => { @@ -539,7 +542,7 @@ pub fn render_config(cfg: &Config, path: &Path, exists: bool, color: bool) -> St "false".red().to_string() }; format!( - "Config path: {}\nExists: {}\n{} gap_minutes = {}\n{} orientation = {}\n{} default_limit = {}\n", + "Config path: {}\nExists: {}\n{} gap_minutes = {}\n{} orientation = {}\n{} default_limit = {}\n{} name = {}\n{} keybinding = {}\n", path.display().to_string().yellow(), exists_str, "[session]".cyan(), @@ -548,16 +551,22 @@ pub fn render_config(cfg: &Config, path: &Path, exists: bool, color: bool) -> St orientation.green(), "[search]".cyan(), cfg.search.default_limit.to_string().green(), + "[theme]".cyan(), + cfg.theme.name.clone().green(), + "[shell]".cyan(), + cfg.shell.keybinding.clone().green(), ) } else { let exists_str = if exists { "true" } else { "false" }; format!( - "Config path: {}\nExists: {}\n[session] gap_minutes = {}\n[tui] orientation = {}\n[search] default_limit = {}\n", + "Config path: {}\nExists: {}\n[session] gap_minutes = {}\n[tui] orientation = {}\n[search] default_limit = {}\n[theme] name = {}\n[shell] keybinding = {}\n", path.display(), exists_str, cfg.session.gap_minutes, orientation, cfg.search.default_limit, + cfg.theme.name, + cfg.shell.keybinding, ) } } @@ -638,6 +647,8 @@ mod tests { assert!(out.contains("gap_minutes = 30")); assert!(out.contains("orientation = bottom")); assert!(out.contains("default_limit = 50")); + assert!(out.contains("[theme] name = default")); + assert!(out.contains("[shell] keybinding = ^R")); assert!(out.contains("false")); } @@ -730,6 +741,19 @@ mod tests { assert!(get_value(&cfg, "foo.bar").is_err()); } + #[test] + fn get_value_shell_keybinding() { + let cfg = Config::default(); + assert_eq!(get_value(&cfg, "shell.keybinding").unwrap(), "^R"); + } + + #[test] + fn apply_set_shell_keybinding() { + let result = apply_set("", "shell.keybinding", "^T").unwrap(); + assert!(result.contains("[shell]")); + assert!(result.contains("keybinding = \"^T\"")); + } + #[test] fn apply_set_gap_minutes_creates_section() { let result = apply_set("", "session.gap_minutes", "15").unwrap(); From 03cee4c99d82e88ad4c52ea2a5a9a29c001086a2 Mon Sep 17 00:00:00 2001 From: JoseVelazcoH Date: Sun, 5 Jul 2026 15:59:51 -0600 Subject: [PATCH 3/5] docs: document shell.keybinding config option --- README.md | 2 +- docs/features.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fe66aa8..5fdda7f 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ exec $SHELL # reload your shell, or open a new terminal ## Quick start -- Press **`Ctrl-R`** to open the interactive search. +- Press **`Ctrl-R`** to open the interactive search (rebindable via `[shell] keybinding`). - **Type** to fuzzy-filter across the command, project, directory, and tags. - Press **`Esc`** for normal mode, then **`:`** to open the filter cmdline (e.g. `project:thoth exit:fail`), or **`?`** for help. diff --git a/docs/features.md b/docs/features.md index 64debf9..be6ad0b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -164,8 +164,14 @@ default_limit = 50 [theme] name = "default" + +[shell] +keybinding = "^R" # key that opens the finder; caret notation, or "none" ``` +Read or change any setting from the CLI, e.g. `tth config set shell.keybinding "^T"` +(run `tth init` again afterwards so the shell hook picks up the new key). + ### History filter `[history] filter` is a list of regular expressions; any command matching one is **never From ce04769f9f5da15e58494f650a49b218c8e4c62d Mon Sep 17 00:00:00 2001 From: JoseVelazcoH Date: Sun, 5 Jul 2026 16:05:45 -0600 Subject: [PATCH 4/5] feat(hooks): support any key combination in shell.keybinding Rewrite the caret-notation conversion so Alt/Meta (^[) and full escape sequences (e.g. ^[[1;6D for Ctrl-Shift-Left) render correctly in both zsh and bash, not just single Ctrl- chords. --- docs/features.md | 17 +++++++++++ src/config.rs | 4 ++- src/hooks.rs | 75 ++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/docs/features.md b/docs/features.md index be6ad0b..fa3bbcb 100644 --- a/docs/features.md +++ b/docs/features.md @@ -172,6 +172,23 @@ keybinding = "^R" # key that opens the finder; caret notation, or "none Read or change any setting from the CLI, e.g. `tth config set shell.keybinding "^T"` (run `tth init` again afterwards so the shell hook picks up the new key). +#### Keybinding notation + +`shell.keybinding` uses caret notation, so any sequence the terminal emits can be bound: + +| Combination | Value | Notes | +| ------------------ | ---------- | -------------------------------------- | +| Ctrl-R (default) | `^R` | | +| Ctrl-T | `^T` | | +| Alt-Ctrl-R | `^[^R` | `^[` is Escape, i.e. the Alt/Meta prefix | +| Alt-X | `^[x` | | +| Ctrl-Shift-Left | `^[[1;6D` | a raw escape sequence | +| (disabled) | `none` | bind the `_tth_widget` yourself | + +There are no symbolic names (`ctrl+shift+left`): shells bind to the bytes a key sends, +not to key labels. To find the sequence for a key, run `cat -v` (or `sed -n l`) and press +it: e.g. Ctrl-Shift-Left prints `^[[1;6D`, which is exactly the value to use. + ### History filter `[history] filter` is a list of regular expressions; any command matching one is **never diff --git a/src/config.rs b/src/config.rs index bf6c920..863e496 100644 --- a/src/config.rs +++ b/src/config.rs @@ -47,7 +47,9 @@ default_limit = 50 # name = "default" # [shell] -# Key that opens the interactive finder, in caret notation (e.g. "^R", "^T"). +# Key that opens the interactive finder, in caret notation. Examples: +# "^R" Ctrl-R "^T" Ctrl-T "^[^R" Alt-Ctrl-R "^[[1;6D" Ctrl-Shift-Left +# "^[" is Escape (the Alt/Meta prefix). Find a key's sequence with `cat -v`. # Set to "none" to skip binding a key and bind the widget yourself. # Takes effect after re-running `tth init` (regenerate your shell hook). # keybinding = "^R" diff --git a/src/hooks.rs b/src/hooks.rs index 504885c..b6de563 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -56,22 +56,47 @@ fn hook_body(shell: &Shell) -> &'static str { } } -/// Convert a caret-notation keybinding (e.g. `^R`) to the shell's native form. +/// Convert a caret-notation keybinding to the shell's native form. /// -/// zsh's `bindkey` accepts caret notation directly, while bash's `bind -x` -/// expects `\C-x`. Non caret-style values pass through unchanged. +/// The keybinding is written in caret notation, the same form zsh's `bindkey` +/// accepts, so any sequence the terminal emits can be bound: +/// +/// - `^R` is Ctrl-R +/// - `^[` is Escape, which is also the Alt/Meta prefix, so `^[^R` is Alt-Ctrl-R +/// - a raw escape sequence like `^[[1;6D` (Ctrl-Shift-Left) works verbatim +/// +/// zsh understands caret notation directly, so it passes through unchanged. +/// bash's `bind -x` expects backslash escapes, so caret tokens are rewritten: +/// `^[` becomes `\e` and `^X` becomes `\C-x`; everything else is literal. fn shell_keybinding(shell: &Shell, keybinding: &str) -> String { match shell { Shell::Zsh => keybinding.to_string(), - Shell::Bash => { - if let Some(rest) = keybinding.strip_prefix('^') { - if rest.len() == 1 && rest.chars().all(|c| c.is_ascii_alphabetic()) { - return format!("\\C-{}", rest.to_ascii_lowercase()); - } + Shell::Bash => caret_to_bash(keybinding), + } +} + +fn caret_to_bash(s: &str) -> String { + let mut out = String::new(); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c != '^' { + out.push(c); + continue; + } + match chars.next() { + Some('[') => out.push_str("\\e"), + Some(ctrl) if ctrl.is_ascii_alphabetic() => { + out.push_str("\\C-"); + out.push(ctrl.to_ascii_lowercase()); + } + Some(other) => { + out.push('^'); + out.push(other); } - keybinding.to_string() + None => out.push('^'), } } + out } /// Render the shell integration script, binding the finder to `keybinding`. @@ -529,6 +554,38 @@ mod tests { assert!(script.contains(r#"bind -x '"\C-t": _tth_widget'"#)); } + #[test] + fn render_init_alt_ctrl_zsh_passthrough() { + let script = render_init(&Shell::Zsh, "^[^R"); + assert!(script.contains("bindkey '^[^R'")); + } + + #[test] + fn render_init_alt_ctrl_bash_converted() { + let script = render_init(&Shell::Bash, "^[^R"); + assert!(script.contains(r#"bind -x '"\e\C-r": _tth_widget'"#)); + } + + #[test] + fn render_init_escape_sequence_bash_converted() { + // Ctrl-Shift-Left emits ESC [ 1 ; 6 D + let script = render_init(&Shell::Bash, "^[[1;6D"); + assert!(script.contains(r#"bind -x '"\e[1;6D": _tth_widget'"#)); + } + + #[test] + fn render_init_escape_sequence_zsh_passthrough() { + let script = render_init(&Shell::Zsh, "^[[1;6D"); + assert!(script.contains("bindkey '^[[1;6D'")); + } + + #[test] + fn render_init_alt_letter_bash() { + // Alt-X is ESC then the literal letter + let script = render_init(&Shell::Bash, "^[x"); + assert!(script.contains(r#"bind -x '"\ex": _tth_widget'"#)); + } + #[test] fn render_init_none_drops_binding_zsh() { let script = render_init(&Shell::Zsh, "none"); From d1f4434d42eeccf2cbe5eced628aaa3a358c1583 Mon Sep 17 00:00:00 2001 From: JoseVelazcoH Date: Sun, 5 Jul 2026 16:13:00 -0600 Subject: [PATCH 5/5] feat(config): accept kitty-style key combos for shell.keybinding Parse human combinations like ctrl+r, alt+x, and ctrl+shift+left into the right terminal sequence for zsh and bash, and default to ctrl+r. Raw caret notation still works for anything the parser doesn't cover. --- docs/features.md | 36 +++++----- src/config.rs | 22 +++--- src/hooks.rs | 174 ++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 195 insertions(+), 37 deletions(-) diff --git a/docs/features.md b/docs/features.md index fa3bbcb..8f5f4ca 100644 --- a/docs/features.md +++ b/docs/features.md @@ -44,8 +44,8 @@ Press **`Ctrl-R`** (or run `tth`) to open the finder. As you type, the list filt with fuzzy matching across the command, project, directory, and tags. A preview pane on the right shows the full details of the highlighted entry. -The trigger key is configurable via `[shell] keybinding` (caret notation, e.g. `"^T"`); -set it to `"none"` to bind the widget yourself. Re-run `tth init` after changing it. +The trigger key is configurable via `[shell] keybinding` (e.g. `"ctrl+t"`); set it to +`"none"` to bind the widget yourself. Re-run `tth init` after changing it. #### Modes (vim-style) @@ -166,28 +166,32 @@ default_limit = 50 name = "default" [shell] -keybinding = "^R" # key that opens the finder; caret notation, or "none" +keybinding = "ctrl+r" # key that opens the finder; combo, or "none" ``` -Read or change any setting from the CLI, e.g. `tth config set shell.keybinding "^T"` +Read or change any setting from the CLI, e.g. `tth config set shell.keybinding "ctrl+t"` (run `tth init` again afterwards so the shell hook picks up the new key). #### Keybinding notation -`shell.keybinding` uses caret notation, so any sequence the terminal emits can be bound: +`shell.keybinding` takes a kitty-style combo: modifiers joined to a key with `+`. -| Combination | Value | Notes | -| ------------------ | ---------- | -------------------------------------- | -| Ctrl-R (default) | `^R` | | -| Ctrl-T | `^T` | | -| Alt-Ctrl-R | `^[^R` | `^[` is Escape, i.e. the Alt/Meta prefix | -| Alt-X | `^[x` | | -| Ctrl-Shift-Left | `^[[1;6D` | a raw escape sequence | -| (disabled) | `none` | bind the `_tth_widget` yourself | +| Combination | Value | +| --------------------- | ----------------- | +| Ctrl-R (default) | `ctrl+r` | +| Ctrl-T | `ctrl+t` | +| Alt-X | `alt+x` | +| Ctrl-Alt-R | `ctrl+alt+r` | +| Ctrl-Shift-Left | `ctrl+shift+left` | +| (disabled) | `none` | -There are no symbolic names (`ctrl+shift+left`): shells bind to the bytes a key sends, -not to key labels. To find the sequence for a key, run `cat -v` (or `sed -n l`) and press -it: e.g. Ctrl-Shift-Left prints `^[[1;6D`, which is exactly the value to use. +- **Modifiers**: `ctrl`, `alt` (aka `meta`/`option`), `shift`. +- **Keys**: a single letter, or a named navigation key: `left`, `right`, `up`, `down`, + `home`, `end`, `pageup`, `pagedown`, `insert`, `delete`. + +Modified navigation keys are encoded as xterm CSI sequences (`ctrl+shift+left` becomes +`\e[1;6D`), which modern terminals emit. For anything not covered you can still pass a +raw escape sequence in caret notation (e.g. `^[[1;6D`); find a key's bytes with `cat -v`. ### History filter diff --git a/src/config.rs b/src/config.rs index 863e496..617ac0d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,7 +10,7 @@ const VALID_KEYS: &[(&str, &str)] = &[ ("tui.orientation", r#""bottom" or "top""#), ("search.default_limit", "positive integer"), ("theme.name", "theme name string"), - ("shell.keybinding", "caret-notation key (e.g. \"^R\") or \"none\""), + ("shell.keybinding", "key combo (e.g. \"ctrl+r\") or \"none\""), ]; pub const DEFAULT_CONFIG_TOML: &str = r#"# Thoth configuration. All settings are optional; values shown are the defaults. @@ -47,12 +47,14 @@ default_limit = 50 # name = "default" # [shell] -# Key that opens the interactive finder, in caret notation. Examples: -# "^R" Ctrl-R "^T" Ctrl-T "^[^R" Alt-Ctrl-R "^[[1;6D" Ctrl-Shift-Left -# "^[" is Escape (the Alt/Meta prefix). Find a key's sequence with `cat -v`. +# Key that opens the interactive finder. Combine modifiers with "+", e.g. +# "ctrl+r" "ctrl+t" "alt+x" "ctrl+alt+r" "ctrl+shift+left" +# Modifiers: ctrl, alt, shift. Keys: a letter or a named nav key +# (left/right/up/down, home, end, pageup, pagedown, insert, delete). +# Raw caret notation (e.g. "^[[1;6D") also works for anything not covered. # Set to "none" to skip binding a key and bind the widget yourself. # Takes effect after re-running `tth init` (regenerate your shell hook). -# keybinding = "^R" +# keybinding = "ctrl+r" "#; #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)] @@ -164,7 +166,7 @@ fn default_theme_name() -> String { } fn default_keybinding() -> String { - "^R".into() + "ctrl+r".into() } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] @@ -650,7 +652,7 @@ mod tests { assert!(out.contains("orientation = bottom")); assert!(out.contains("default_limit = 50")); assert!(out.contains("[theme] name = default")); - assert!(out.contains("[shell] keybinding = ^R")); + assert!(out.contains("[shell] keybinding = ctrl+r")); assert!(out.contains("false")); } @@ -746,7 +748,7 @@ mod tests { #[test] fn get_value_shell_keybinding() { let cfg = Config::default(); - assert_eq!(get_value(&cfg, "shell.keybinding").unwrap(), "^R"); + assert_eq!(get_value(&cfg, "shell.keybinding").unwrap(), "ctrl+r"); } #[test] @@ -832,13 +834,13 @@ mod tests { #[test] fn shell_section_default_keybinding_is_ctrl_r() { - assert_eq!(ShellSection::default().keybinding, "^R"); + assert_eq!(ShellSection::default().keybinding, "ctrl+r"); } #[test] fn parse_empty_string_keybinding_is_default() { let cfg = parse("").unwrap(); - assert_eq!(cfg.shell.keybinding, "^R"); + assert_eq!(cfg.shell.keybinding, "ctrl+r"); } #[test] diff --git a/src/hooks.rs b/src/hooks.rs index b6de563..1b0db6f 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -56,22 +56,110 @@ fn hook_body(shell: &Shell) -> &'static str { } } -/// Convert a caret-notation keybinding to the shell's native form. +/// Translate a human keybinding like `ctrl+shift+left` into caret notation. /// -/// The keybinding is written in caret notation, the same form zsh's `bindkey` -/// accepts, so any sequence the terminal emits can be bound: +/// Modifiers (`ctrl`, `alt`/`meta`/`option`, `shift`) join the key with `+`, +/// kitty-style. The key is a single character (`ctrl+r`) or a named navigation +/// key (`left`, `right`, `up`, `down`, `home`, `end`, `pageup`, `pagedown`, +/// `insert`, `delete`). Modified navigation keys use the xterm CSI encoding +/// (e.g. `ctrl+shift+left` -> `^[[1;6D`), which most modern terminals emit. /// -/// - `^R` is Ctrl-R -/// - `^[` is Escape, which is also the Alt/Meta prefix, so `^[^R` is Alt-Ctrl-R -/// - a raw escape sequence like `^[[1;6D` (Ctrl-Shift-Left) works verbatim +/// Returns `None` when the value is not a `+`-combo we recognize, so raw caret +/// notation (`^R`, `^[[1;6D`) still passes through untouched. +fn human_to_caret(input: &str) -> Option { + if !input.contains('+') { + return None; + } + let lower = input.to_ascii_lowercase(); + let parts: Vec<&str> = lower.split('+').map(str::trim).collect(); + let (key, mods) = parts.split_last()?; + let (mut ctrl, mut alt, mut shift) = (false, false, false); + for m in mods { + match *m { + "ctrl" | "control" => ctrl = true, + "alt" | "meta" | "option" | "opt" => alt = true, + "shift" => shift = true, + _ => return None, + } + } + + if let Some(seq) = named_key_caret(key, ctrl, alt, shift) { + return Some(seq); + } + + let mut chars = key.chars(); + let c = chars.next()?; + if chars.next().is_some() || !c.is_ascii() { + return None; + } + let mut out = String::new(); + if alt { + out.push_str("^["); + } + if ctrl { + if !c.is_ascii_alphabetic() { + return None; + } + out.push('^'); + out.push(c.to_ascii_uppercase()); + } else if shift { + out.push(c.to_ascii_uppercase()); + } else { + out.push(c); + } + Some(out) +} + +/// Caret notation for a named navigation key with modifiers, using the xterm +/// modifier code `1 + shift + 2*alt + 4*ctrl`. +fn named_key_caret(key: &str, ctrl: bool, alt: bool, shift: bool) -> Option { + let letter_final = match key { + "up" => Some('A'), + "down" => Some('B'), + "right" => Some('C'), + "left" => Some('D'), + "home" => Some('H'), + "end" => Some('F'), + _ => None, + }; + let tilde_num = match key { + "insert" | "ins" => Some(2), + "delete" | "del" => Some(3), + "pageup" | "pgup" => Some(5), + "pagedown" | "pgdn" | "pgdown" => Some(6), + _ => None, + }; + if letter_final.is_none() && tilde_num.is_none() { + return None; + } + let modcode = 1 + u8::from(shift) + 2 * u8::from(alt) + 4 * u8::from(ctrl); + if let Some(fin) = letter_final { + return Some(if modcode == 1 { + format!("^[[{fin}") + } else { + format!("^[[1;{modcode}{fin}") + }); + } + let n = tilde_num.unwrap(); + Some(if modcode == 1 { + format!("^[[{n}~") + } else { + format!("^[[{n};{modcode}~") + }) +} + +/// Convert a keybinding to the shell's native form. /// -/// zsh understands caret notation directly, so it passes through unchanged. -/// bash's `bind -x` expects backslash escapes, so caret tokens are rewritten: -/// `^[` becomes `\e` and `^X` becomes `\C-x`; everything else is literal. +/// The value may be a human combo (`ctrl+shift+left`) or raw caret notation +/// (`^[[1;6D`); the former is normalized to caret first. zsh understands caret +/// notation directly, so it passes through. bash's `bind -x` expects backslash +/// escapes, so caret tokens are rewritten: `^[` becomes `\e` and `^X` becomes +/// `\C-x`; everything else is literal. fn shell_keybinding(shell: &Shell, keybinding: &str) -> String { + let caret = human_to_caret(keybinding).unwrap_or_else(|| keybinding.to_string()); match shell { - Shell::Zsh => keybinding.to_string(), - Shell::Bash => caret_to_bash(keybinding), + Shell::Zsh => caret, + Shell::Bash => caret_to_bash(&caret), } } @@ -554,6 +642,70 @@ mod tests { assert!(script.contains(r#"bind -x '"\C-t": _tth_widget'"#)); } + #[test] + fn human_to_caret_ctrl_letter() { + assert_eq!(human_to_caret("ctrl+r").unwrap(), "^R"); + } + + #[test] + fn human_to_caret_alt_letter() { + assert_eq!(human_to_caret("alt+x").unwrap(), "^[x"); + } + + #[test] + fn human_to_caret_ctrl_alt_letter() { + assert_eq!(human_to_caret("ctrl+alt+r").unwrap(), "^[^R"); + } + + #[test] + fn human_to_caret_ctrl_shift_left() { + assert_eq!(human_to_caret("ctrl+shift+left").unwrap(), "^[[1;6D"); + } + + #[test] + fn human_to_caret_alt_up() { + assert_eq!(human_to_caret("alt+up").unwrap(), "^[[1;3A"); + } + + #[test] + fn human_to_caret_ctrl_pageup() { + assert_eq!(human_to_caret("ctrl+pageup").unwrap(), "^[[5;5~"); + } + + #[test] + fn human_to_caret_case_insensitive() { + assert_eq!(human_to_caret("Ctrl+R").unwrap(), "^R"); + } + + #[test] + fn human_to_caret_raw_caret_passthrough_is_none() { + assert!(human_to_caret("^R").is_none()); + assert!(human_to_caret("^[[1;6D").is_none()); + } + + #[test] + fn human_to_caret_unknown_modifier_is_none() { + assert!(human_to_caret("hyper+r").is_none()); + } + + #[test] + fn render_init_human_ctrl_r_zsh() { + let script = render_init(&Shell::Zsh, "ctrl+r"); + assert!(script.contains("bindkey '^R'")); + } + + #[test] + fn render_init_human_ctrl_shift_left_bash() { + let script = render_init(&Shell::Bash, "ctrl+shift+left"); + assert!(script.contains(r#"bind -x '"\e[1;6D": _tth_widget'"#)); + } + + #[test] + fn render_init_human_alt_x_bash() { + let script = render_init(&Shell::Bash, "alt+x"); + assert!(script.contains(r#"bind -x '"\ex": _tth_widget'"#)); + } + #[test] fn render_init_alt_ctrl_zsh_passthrough() { let script = render_init(&Shell::Zsh, "^[^R");