Vi mode: hack in support for df and friends

Commit 38e633d49b (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
This commit is contained in:
Johannes Altmanninger
2026-02-06 13:17:36 +11:00
parent 6f895935a9
commit d25965afba
10 changed files with 138 additions and 38 deletions

View File

@@ -401,6 +401,13 @@ The following special input functions are available:
``self-insert-notfirst`` ``self-insert-notfirst``
inserts the matching sequence into the command line, unless the cursor is at the beginning 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`` ``suppress-autosuggestion``
remove the current autosuggestion. Returns true if there was a suggestion to remove. remove the current autosuggestion. Returns true if there was a suggestion to remove.

View File

@@ -55,7 +55,7 @@ Example
case visual case visual
set_color --bold brmagenta set_color --bold brmagenta
echo 'V' echo 'V'
case operator case operator f F t T
set_color --bold cyan set_color --bold cyan
echo 'N' echo 'N'
case '*' case '*'

View File

@@ -18,7 +18,7 @@ function fish_default_mode_prompt --description "Display vi prompt mode"
case visual case visual
set_color --bold magenta set_color --bold magenta
echo '[V]' echo '[V]'
case operator case operator f F t T
set_color --bold cyan set_color --bold cyan
echo '[N]' echo '[N]'
end end

View File

@@ -53,7 +53,7 @@ function fish_vi_yank_selection
end end
function fish_vi_exec_motion function fish_vi_exec_motion
argparse linewise -- $argv argparse --stop-nonopt linewise -- $argv
or return or return
set -l motion $argv set -l motion $argv
@@ -83,7 +83,7 @@ function fish_vi_exec_motion
else else
set -l use_selection true set -l use_selection true
set -l swap_case_hack set -l swap_case_hack
switch $motion switch $motion[1]
case forward-word-vi forward-bigword-vi case forward-word-vi forward-bigword-vi
if test $__fish_vi_operator = swap-case if test $__fish_vi_operator = swap-case
set swap_case_hack (string replace -r -- '^forward-((?:big)?word)-vi$' '$1' $motion) 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) set motion (string replace -- forward kill $motion)
end end
end end
switch $motion[1]
case commandline
case '*'
set motion commandline -f $motion
end
if $use_selection if $use_selection
commandline -f begin-selection commandline -f begin-selection
else else
commandline -f begin-undo-group commandline -f begin-undo-group
end end
set -l ok true
switch $__fish_vi_operator switch $__fish_vi_operator
case delete case delete
for i in (seq $total) for i in (seq $total)
commandline -f $motion $motion || { set ok false; break }
end end
if $use_selection if $ok && $use_selection
commandline -f kill-selection commandline -f kill-selection
end end
case change case change
for i in (seq $total) for i in (seq $total)
commandline -f $motion $motion || { set ok false; break }
end end
if $use_selection if $ok
commandline -f kill-selection if $use_selection
commandline -f kill-selection
end
set fish_bind_mode insert
end end
set fish_bind_mode insert
case yank case yank
for i in (seq $total) for i in (seq $total)
commandline -f $motion $motion || { set ok false; break }
end end
if $use_selection if $ok
fish_vi_yank_selection if $use_selection
else fish_vi_yank_selection
commandline -f yank else
commandline -f yank
end
end end
case swap-case case swap-case
for i in (seq $total) for i in (seq $total)
commandline -f $motion $motion || { set ok false; break }
end end
if set -q swap_case_hack[1] if $ok
set -l word $swap_case_hack if set -q swap_case_hack[1]
commandline -f \ set -l word $swap_case_hack
backward-$word \ commandline -f \
forward-$word-end \ backward-$word \
togglecase-selection \ forward-$word-end \
backward-$word \ togglecase-selection \
forward-$word-vi backward-$word \
else forward-$word-vi
commandline -f togglecase-selection else
commandline -f togglecase-selection
end
end end
end end
if $use_selection 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 beginning-of-line'
bind --preset -M operator \$ 'fish_vi_exec_motion end-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 --sets-mode f ''
bind --preset -M operator F 'fish_vi_exec_motion backward-jump' bind --preset -M operator F --sets-mode F ''
bind --preset -M operator t 'fish_vi_exec_motion forward-jump-till' bind --preset -M operator t --sets-mode t ''
bind --preset -M operator T 'fish_vi_exec_motion backward-jump-till' 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'
bind --preset -M operator , 'fish_vi_exec_motion repeat-jump-reverse' bind --preset -M operator , 'fish_vi_exec_motion repeat-jump-reverse'

View File

@@ -86,7 +86,7 @@ function fish_prompt
switch $fish_bind_mode switch $fish_bind_mode
case default case default
set mode (set_color --bold red)N set mode (set_color --bold red)N
case operator case operator f F t T
set mode (set_color --bold cyan)N set mode (set_color --bold cyan)N
case insert case insert
set mode (set_color --bold green)I set mode (set_color --bold green)I

View File

@@ -15,8 +15,9 @@
use crate::prelude::*; use crate::prelude::*;
use crate::proc::is_interactive_session; use crate::proc::is_interactive_session;
use crate::reader::{ use crate::reader::{
commandline_get_state, commandline_set_buffer, commandline_set_search_field, JumpDirection, JumpPrecision, commandline_get_state, commandline_set_buffer,
reader_execute_readline_cmd, reader_showing_suggestion, commandline_set_search_field, reader_execute_readline_cmd, reader_jump,
reader_showing_suggestion,
}; };
use crate::tokenizer::{TOK_ACCEPT_UNFINISHED, TokenType, Tokenizer}; use crate::tokenizer::{TOK_ACCEPT_UNFINISHED, TokenType, Tokenizer};
use fish_wcstringutil::join_strings; 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 showing_suggestion = false;
let mut override_buffer = None; 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 short_options = L!("abijpctfxorhI:CBELSsP");
let long_options: &[WOption] = &[ 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!("search-field"), ArgType::NoArgument, '\x03'),
wopt(L!("is-valid"), ArgType::NoArgument, '\x01'), wopt(L!("is-valid"), ArgType::NoArgument, '\x01'),
wopt(L!("showing-suggestion"), ArgType::NoArgument, '\x04'), 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); 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, '\x03' => search_field_mode = true,
'\x01' => is_valid = true, '\x01' => is_valid = true,
'\x04' => showing_suggestion = 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' => { 'h' => {
builtin_print_help(parser, streams, cmd); builtin_print_help(parser, streams, cmd);
return Ok(SUCCESS); return Ok(SUCCESS);
@@ -360,6 +386,27 @@ pub fn commandline(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr])
_ => panic!(), _ => 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; let positional_args = w.argv.len() - w.wopt_index;

View File

@@ -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"), ReadlineCmd::ForwardWordEmacs),
make_md(L!("forward-word-end"), ReadlineCmd::ForwardWordEnd), make_md(L!("forward-word-end"), ReadlineCmd::ForwardWordEnd),
make_md(L!("forward-word-vi"), ReadlineCmd::ForwardWordVi), 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-delete"), ReadlineCmd::HistoryDelete),
make_md(L!("history-last-token-search-backward"), ReadlineCmd::HistoryLastTokenSearchBackward), make_md(L!("history-last-token-search-backward"), ReadlineCmd::HistoryLastTokenSearchBackward),
make_md(L!("history-last-token-search-forward"), ReadlineCmd::HistoryLastTokenSearchForward), 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(); let evt = self.readch();
match evt { match evt {
CharEvent::Readline(ref readline_event) => match readline_event.cmd { 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. // Typically self-insert is generated by the generic (empty) binding.
// However if it is generated by a real sequence, then insert that sequence. // However if it is generated by a real sequence, then insert that sequence.
let seq = readline_event.seq.chars().map(CharEvent::from_char); 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; 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; return res;
} }
ReadlineCmd::FuncAnd | ReadlineCmd::FuncOr => { ReadlineCmd::FuncAnd | ReadlineCmd::FuncOr => {

View File

@@ -107,6 +107,7 @@ pub enum ReadlineCmd {
HistoryLastTokenSearchForward, HistoryLastTokenSearchForward,
SelfInsert, SelfInsert,
SelfInsertNotFirst, SelfInsertNotFirst,
GetKey,
TransposeChars, TransposeChars,
TransposeWords, TransposeWords,
UpcaseWord, UpcaseWord,

View File

@@ -546,13 +546,13 @@ enum Kill {
} }
#[derive(Clone, Copy, Eq, PartialEq)] #[derive(Clone, Copy, Eq, PartialEq)]
enum JumpDirection { pub enum JumpDirection {
Forward, Forward,
Backward, Backward,
} }
#[derive(Clone, Copy, Eq, PartialEq)] #[derive(Clone, Copy, Eq, PartialEq)]
enum JumpPrecision { pub enum JumpPrecision {
Till, Till,
To, To,
} }
@@ -1175,6 +1175,15 @@ pub fn reader_execute_readline_cmd(parser: &Parser, ch: CharEvent) {
let _ = data.handle_char_event(Some(ch)); 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 { pub fn reader_showing_suggestion(parser: &Parser) -> bool {
if !is_interactive_session() { if !is_interactive_session() {
return false; return false;
@@ -4346,7 +4355,7 @@ fn handle_readline_command(&mut self, c: ReadlineCmd) {
rl::ScrollbackPush => { rl::ScrollbackPush => {
self.screen.push_to_scrollback(); 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 // This can be reached via `commandline -f and` etc
// panic!("should have been handled by inputter_t::readch"); // 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::BackwardKillBigword
| rl::BackwardKillToken | rl::BackwardKillToken
| rl::SelfInsert | rl::SelfInsert
| rl::GetKey
| rl::SelfInsertNotFirst | rl::SelfInsertNotFirst
| rl::TransposeChars | rl::TransposeChars
| rl::TransposeWords | rl::TransposeWords

View File

@@ -321,7 +321,7 @@ expect_prompt("echo two three")
# Now test that exactly the expected bind modes are defined # Now test that exactly the expected bind modes are defined
sendline("bind --list-modes") sendline("bind --list-modes")
expect_prompt( 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", unmatched="Unexpected vi bind modes",
) )