From d25965afbaa2ae3968f9a1ab006c19ad4ecb7bbd Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Fri, 6 Feb 2026 13:17:36 +1100 Subject: [PATCH] Vi mode: hack in support for df and friends Commit 38e633d49b1 (fish_vi_key_bindings: add support for count, 2025-12-16) introduced an operator mode which kind of makes a lot of sense for today's fish. If we end up needing more flexibility and tighter integration, we might want to move some of this into core. Unfortunately the change is at odds with our cursed forward-jump implementation. The forward-jump special input function works by magically reading the next key from stdin, which causes problems when we are executing a script: commandline -f begin-selection commandline -f forward-jump commandline -f end-selection here end-selection will be executed immediately and forward-jump fails to wait for a keystroke. We should get rid of forward-jump implementation. For now, replace only the broken thing with a dedicated bind mode for each of f/F/t/T. Fixes #12417 --- doc_src/cmds/bind.rst | 7 ++ doc_src/cmds/fish_mode_prompt.rst | 2 +- share/functions/fish_default_mode_prompt.fish | 2 +- share/functions/fish_vi_key_bindings.fish | 74 ++++++++++++------- share/prompts/nim.fish | 2 +- src/builtins/commandline.rs | 51 ++++++++++++- src/input.rs | 19 ++++- src/input_common.rs | 1 + src/reader/reader.rs | 16 +++- tests/pexpects/bind.py | 2 +- 10 files changed, 138 insertions(+), 38 deletions(-) diff --git a/doc_src/cmds/bind.rst b/doc_src/cmds/bind.rst index 8d945e44f..9632a789d 100644 --- a/doc_src/cmds/bind.rst +++ b/doc_src/cmds/bind.rst @@ -401,6 +401,13 @@ The following special input functions are available: ``self-insert-notfirst`` inserts the matching sequence into the command line, unless the cursor is at the beginning +``get-key`` + sets :envvar:`fish_key` to the key that was pressed to trigger this binding. Example use:: + + for i in (seq 0 9) + bind $i get-key 'commandline -i "#$fish_key"' 'set -eg fish_key' + end + ``suppress-autosuggestion`` remove the current autosuggestion. Returns true if there was a suggestion to remove. diff --git a/doc_src/cmds/fish_mode_prompt.rst b/doc_src/cmds/fish_mode_prompt.rst index 37dce5f00..89dc74a58 100644 --- a/doc_src/cmds/fish_mode_prompt.rst +++ b/doc_src/cmds/fish_mode_prompt.rst @@ -55,7 +55,7 @@ Example case visual set_color --bold brmagenta echo 'V' - case operator + case operator f F t T set_color --bold cyan echo 'N' case '*' diff --git a/share/functions/fish_default_mode_prompt.fish b/share/functions/fish_default_mode_prompt.fish index e607dc0c1..17656729a 100644 --- a/share/functions/fish_default_mode_prompt.fish +++ b/share/functions/fish_default_mode_prompt.fish @@ -18,7 +18,7 @@ function fish_default_mode_prompt --description "Display vi prompt mode" case visual set_color --bold magenta echo '[V]' - case operator + case operator f F t T set_color --bold cyan echo '[N]' end diff --git a/share/functions/fish_vi_key_bindings.fish b/share/functions/fish_vi_key_bindings.fish index 3181638b4..a56436424 100644 --- a/share/functions/fish_vi_key_bindings.fish +++ b/share/functions/fish_vi_key_bindings.fish @@ -53,7 +53,7 @@ function fish_vi_yank_selection end function fish_vi_exec_motion - argparse linewise -- $argv + argparse --stop-nonopt linewise -- $argv or return set -l motion $argv @@ -83,7 +83,7 @@ function fish_vi_exec_motion else set -l use_selection true set -l swap_case_hack - switch $motion + switch $motion[1] case forward-word-vi forward-bigword-vi if test $__fish_vi_operator = swap-case set swap_case_hack (string replace -r -- '^forward-((?:big)?word)-vi$' '$1' $motion) @@ -93,50 +93,62 @@ function fish_vi_exec_motion set motion (string replace -- forward kill $motion) end end + switch $motion[1] + case commandline + case '*' + set motion commandline -f $motion + end if $use_selection commandline -f begin-selection else commandline -f begin-undo-group end + set -l ok true switch $__fish_vi_operator case delete for i in (seq $total) - commandline -f $motion + $motion || { set ok false; break } end - if $use_selection + if $ok && $use_selection commandline -f kill-selection end case change for i in (seq $total) - commandline -f $motion + $motion || { set ok false; break } end - if $use_selection - commandline -f kill-selection + if $ok + if $use_selection + commandline -f kill-selection + end + set fish_bind_mode insert end - set fish_bind_mode insert case yank for i in (seq $total) - commandline -f $motion + $motion || { set ok false; break } end - if $use_selection - fish_vi_yank_selection - else - commandline -f yank + if $ok + if $use_selection + fish_vi_yank_selection + else + commandline -f yank + end end case swap-case for i in (seq $total) - commandline -f $motion + $motion || { set ok false; break } end - if set -q swap_case_hack[1] - set -l word $swap_case_hack - commandline -f \ - backward-$word \ - forward-$word-end \ - togglecase-selection \ - backward-$word \ - forward-$word-vi - else - commandline -f togglecase-selection + if $ok + if set -q swap_case_hack[1] + set -l word $swap_case_hack + commandline -f \ + backward-$word \ + forward-$word-end \ + togglecase-selection \ + backward-$word \ + forward-$word-vi + else + commandline -f togglecase-selection + end end end if $use_selection @@ -400,10 +412,16 @@ function fish_vi_key_bindings --description 'vi-like key bindings for fish' bind --preset -M operator \^ 'fish_vi_exec_motion beginning-of-line' bind --preset -M operator \$ 'fish_vi_exec_motion end-of-line' - bind --preset -M operator f 'fish_vi_exec_motion forward-jump' - bind --preset -M operator F 'fish_vi_exec_motion backward-jump' - bind --preset -M operator t 'fish_vi_exec_motion forward-jump-till' - bind --preset -M operator T 'fish_vi_exec_motion backward-jump-till' + bind --preset -M operator f --sets-mode f '' + bind --preset -M operator F --sets-mode F '' + bind --preset -M operator t --sets-mode t '' + bind --preset -M operator T --sets-mode T '' + + bind --preset -M f '' get-key 'fish_vi_exec_motion commandline --forward-jump=$fish_key' 'set -eg fish_key' + bind --preset -M F '' get-key 'fish_vi_exec_motion commandline --backward-jump=$fish_key' 'set -eg fish_key' + bind --preset -M t '' get-key 'fish_vi_exec_motion commandline --forward-jump-till=$fish_key' 'set -eg fish_key' + bind --preset -M T '' get-key 'fish_vi_exec_motion commandline --backward-jump-till=$fish_key' 'set -eg fish_key' + bind --preset -M operator ';' 'fish_vi_exec_motion repeat-jump' bind --preset -M operator , 'fish_vi_exec_motion repeat-jump-reverse' diff --git a/share/prompts/nim.fish b/share/prompts/nim.fish index b4d1ffec0..c193fa96f 100644 --- a/share/prompts/nim.fish +++ b/share/prompts/nim.fish @@ -86,7 +86,7 @@ function fish_prompt switch $fish_bind_mode case default set mode (set_color --bold red)N - case operator + case operator f F t T set mode (set_color --bold cyan)N case insert set mode (set_color --bold green)I diff --git a/src/builtins/commandline.rs b/src/builtins/commandline.rs index 19c00f6d3..bb41f0a43 100644 --- a/src/builtins/commandline.rs +++ b/src/builtins/commandline.rs @@ -15,8 +15,9 @@ use crate::prelude::*; use crate::proc::is_interactive_session; use crate::reader::{ - commandline_get_state, commandline_set_buffer, commandline_set_search_field, - reader_execute_readline_cmd, reader_showing_suggestion, + JumpDirection, JumpPrecision, commandline_get_state, commandline_set_buffer, + commandline_set_search_field, reader_execute_readline_cmd, reader_jump, + reader_showing_suggestion, }; use crate::tokenizer::{TOK_ACCEPT_UNFINISHED, TokenType, Tokenizer}; use fish_wcstringutil::join_strings; @@ -262,6 +263,11 @@ pub fn commandline(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr]) let mut showing_suggestion = false; let mut override_buffer = None; + let mut forward_jump = false; + let mut backward_jump = false; + let mut forward_jump_till = false; + let mut backward_jump_till = false; + let mut jump_target = None; let short_options = L!("abijpctfxorhI:CBELSsP"); let long_options: &[WOption] = &[ @@ -292,6 +298,10 @@ pub fn commandline(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr]) wopt(L!("search-field"), ArgType::NoArgument, '\x03'), wopt(L!("is-valid"), ArgType::NoArgument, '\x01'), wopt(L!("showing-suggestion"), ArgType::NoArgument, '\x04'), + wopt(L!("forward-jump"), ArgType::RequiredArgument, '\x07'), + wopt(L!("backward-jump"), ArgType::RequiredArgument, '\x08'), + wopt(L!("forward-jump-till"), ArgType::RequiredArgument, '\x09'), + wopt(L!("backward-jump-till"), ArgType::RequiredArgument, '\x0a'), ]; let mut w = WGetopter::new(short_options, long_options, args); @@ -341,6 +351,22 @@ pub fn commandline(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr]) '\x03' => search_field_mode = true, '\x01' => is_valid = true, '\x04' => showing_suggestion = true, + '\x07' => { + forward_jump = true; + jump_target = Some(w.woptarg.unwrap().to_owned()); + } + '\x08' => { + backward_jump = true; + jump_target = Some(w.woptarg.unwrap().to_owned()); + } + '\x09' => { + forward_jump_till = true; + jump_target = Some(w.woptarg.unwrap().to_owned()); + } + '\x0a' => { + backward_jump_till = true; + jump_target = Some(w.woptarg.unwrap().to_owned()); + } 'h' => { builtin_print_help(parser, streams, cmd); return Ok(SUCCESS); @@ -360,6 +386,27 @@ pub fn commandline(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr]) _ => panic!(), } } + if forward_jump || forward_jump_till || backward_jump || backward_jump_till { + let direction = if forward_jump || forward_jump_till { + JumpDirection::Forward + } else { + JumpDirection::Backward + }; + let precision = if forward_jump || backward_jump { + JumpPrecision::To + } else { + JumpPrecision::Till + }; + let target = jump_target.unwrap(); + let Some(target) = target.chars().next() else { + return Err(STATUS_INVALID_ARGS); + }; + return if reader_jump(direction, precision, target) { + Ok(SUCCESS) + } else { + Err(STATUS_CMD_ERROR) + }; + } let positional_args = w.argv.len() - w.wopt_index; diff --git a/src/input.rs b/src/input.rs index 961f512d6..0d0cc6357 100644 --- a/src/input.rs +++ b/src/input.rs @@ -162,6 +162,7 @@ const fn make_md(name: &'static wstr, code: ReadlineCmd) -> InputFunctionMetadat make_md(L!("forward-word"), ReadlineCmd::ForwardWordEmacs), make_md(L!("forward-word-end"), ReadlineCmd::ForwardWordEnd), make_md(L!("forward-word-vi"), ReadlineCmd::ForwardWordVi), + make_md(L!("get-key"), ReadlineCmd::GetKey), make_md(L!("history-delete"), ReadlineCmd::HistoryDelete), make_md(L!("history-last-token-search-backward"), ReadlineCmd::HistoryLastTokenSearchBackward), make_md(L!("history-last-token-search-forward"), ReadlineCmd::HistoryLastTokenSearchForward), @@ -698,7 +699,9 @@ pub fn read_char(&mut self) -> CharEvent { let evt = self.readch(); match evt { CharEvent::Readline(ref readline_event) => match readline_event.cmd { - ReadlineCmd::SelfInsert | ReadlineCmd::SelfInsertNotFirst => { + ReadlineCmd::SelfInsert + | ReadlineCmd::SelfInsertNotFirst + | ReadlineCmd::GetKey => { // Typically self-insert is generated by the generic (empty) binding. // However if it is generated by a real sequence, then insert that sequence. let seq = readline_event.seq.chars().map(CharEvent::from_char); @@ -721,6 +724,20 @@ pub fn read_char(&mut self) -> CharEvent { kevt.input_style = CharInputStyle::NotFirst; } } + if readline_event.cmd == ReadlineCmd::GetKey { + if let CharEvent::Key(kevt) = res { + return CharEvent::Command(sprintf!( + "set -g fish_key %s", + escape( + &kevt + .key + .codepoint_text() + .map(|c| WString::from_chars(vec![c])) + .unwrap_or_default() + ) + )); + } + } return res; } ReadlineCmd::FuncAnd | ReadlineCmd::FuncOr => { diff --git a/src/input_common.rs b/src/input_common.rs index 28b8f0466..043ba3daf 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -107,6 +107,7 @@ pub enum ReadlineCmd { HistoryLastTokenSearchForward, SelfInsert, SelfInsertNotFirst, + GetKey, TransposeChars, TransposeWords, UpcaseWord, diff --git a/src/reader/reader.rs b/src/reader/reader.rs index d26e6ae5d..b25c79ed1 100644 --- a/src/reader/reader.rs +++ b/src/reader/reader.rs @@ -546,13 +546,13 @@ enum Kill { } #[derive(Clone, Copy, Eq, PartialEq)] -enum JumpDirection { +pub enum JumpDirection { Forward, Backward, } #[derive(Clone, Copy, Eq, PartialEq)] -enum JumpPrecision { +pub enum JumpPrecision { Till, To, } @@ -1175,6 +1175,15 @@ pub fn reader_execute_readline_cmd(parser: &Parser, ch: CharEvent) { let _ = data.handle_char_event(Some(ch)); } +pub fn reader_jump(direction: JumpDirection, precision: JumpPrecision, target: char) -> bool { + let Some(data) = current_data() else { + return false; + }; + data.save_screen_state(); + let elt = data.active_edit_line_tag(); + data.jump_and_remember_last_jump(direction, precision, elt, target, false) +} + pub fn reader_showing_suggestion(parser: &Parser) -> bool { if !is_interactive_session() { return false; @@ -4346,7 +4355,7 @@ fn handle_readline_command(&mut self, c: ReadlineCmd) { rl::ScrollbackPush => { self.screen.push_to_scrollback(); } - rl::SelfInsert | rl::SelfInsertNotFirst | rl::FuncAnd | rl::FuncOr => { + rl::SelfInsert | rl::SelfInsertNotFirst | rl::GetKey | rl::FuncAnd | rl::FuncOr => { // This can be reached via `commandline -f and` etc // panic!("should have been handled by inputter_t::readch"); } @@ -6220,6 +6229,7 @@ fn command_ends_paging(c: ReadlineCmd, focused_on_search_field: bool) -> bool { | rl::BackwardKillBigword | rl::BackwardKillToken | rl::SelfInsert + | rl::GetKey | rl::SelfInsertNotFirst | rl::TransposeChars | rl::TransposeWords diff --git a/tests/pexpects/bind.py b/tests/pexpects/bind.py index 1e5838e87..f986a58a0 100644 --- a/tests/pexpects/bind.py +++ b/tests/pexpects/bind.py @@ -321,7 +321,7 @@ expect_prompt("echo two three") # Now test that exactly the expected bind modes are defined sendline("bind --list-modes") expect_prompt( - "default\r\ninsert\r\noperator\r\nreplace\r\nreplace_one\r\nvisual\r\n", + "F\r\nT\r\ndefault\r\nf\r\ninsert\r\noperator\r\nreplace\r\nreplace_one\r\nt\r\nvisual\r\n", unmatched="Unexpected vi bind modes", )