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 61bc213..8f5f4ca 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` (e.g. `"ctrl+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 @@ -161,8 +164,35 @@ default_limit = 50 [theme] name = "default" + +[shell] +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 "ctrl+t"` +(run `tth init` again afterwards so the shell hook picks up the new key). + +#### Keybinding notation + +`shell.keybinding` takes a kitty-style combo: modifiers joined to a key with `+`. + +| 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` | + +- **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 `[history] filter` is a list of regular expressions; any command matching one is **never 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..617ac0d 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", "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. @@ -40,9 +41,20 @@ 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. 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 = "ctrl+r" "#; #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)] @@ -153,6 +165,10 @@ fn default_theme_name() -> String { "default".into() } +fn default_keybinding() -> String { + "ctrl+r".into() +} + #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] #[serde(default)] pub struct ThemeSection { @@ -168,6 +184,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 +207,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 { @@ -352,6 +384,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, @@ -406,7 +440,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)))?, _ => { @@ -512,7 +546,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(), @@ -521,16 +555,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, ) } } @@ -611,6 +651,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 = ctrl+r")); assert!(out.contains("false")); } @@ -703,6 +745,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(), "ctrl+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(); @@ -777,6 +832,23 @@ mod tests { assert_eq!(ThemeSection::default().name, "default"); } + #[test] + fn shell_section_default_keybinding_is_ctrl_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, "ctrl+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..1b0db6f 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,154 @@ fn hook_body(shell: &Shell) -> &'static str { } } -pub fn render_init(shell: &Shell) -> &'static str { - hook_body(shell) +/// Translate a human keybinding like `ctrl+shift+left` into caret notation. +/// +/// 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. +/// +/// 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. +/// +/// 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 => caret, + Shell::Bash => caret_to_bash(&caret), + } +} + +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); + } + None => out.push('^'), + } + } + out +} + +/// 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 +601,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 +609,150 @@ 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 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"); + 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"); + 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);