tommcdo / vim-exchange

Easy text exchange operator for Vim
MIT License
756 stars 23 forks source link

Transpose the last 2 words #58

Closed huyz closed 2 years ago

huyz commented 2 years ago

What would be a reliable mapping to transpose the last 2 words, something that works consistently even if words are only one character long?

tommcdo commented 2 years ago

Hello, thanks for reaching out!

Can you provide some examples of what you're trying to achieve? Sample text and expected result would be very helpful.

huyz commented 2 years ago

Sure.

If I have first second third and the mode is normal and the cursor is on any character of second (or, preferably but not required, on the whitespace right after second), then if I hit a key sequence, it would be swap first and second and the cursor would end on the last character of first.

In insert mode, the cursor could be anywhere before, in between, or after the characters of second and the key sequence would trigger a swap of first and second and the cursor would end up after last character of first and I would still be in insert mode.

This is what I have so far:

" Swap words using vim-exchange
nmap gX cxiWBcxiWEE
" <A-x> on macOS keyboard
imap ≈ <Esc>gXa 

but the cursor ends up in the wrong place if a word is only one character long.

tommcdo commented 2 years ago

You could set a mark (e.g. ma to set to "a" mark) and return to it after (e.g. `a)

nmap gX macxiWBcxiW`a

Also, a little tip: When you're exchanging with the same region, you can use . (repeat) the second time:

nmap gX macxiWB.`a

Caveat

The biggest caveat to this is that you might be clobbering a mark that you've previously set. Avoiding that is a bit more involved - you can use the setpos() and getpos() functions, but to do so you'd need to put your mapping into a function.

function! s:exchange_two_words()
    let l:pos = getpos('.')
    normal cxiWB.
    call setpos('.', l:pos)
endfunction

nmap gX :<C-u>call <SID>exchange_two_words()<CR>

I tested this a bit, but you might want to do some more thorough testing.

huyz commented 2 years ago

Thanks, that was very helpful.

I had to tweak that to make sure that it worked if the second word was one character long. I don't know the idiomatic way of doing "press E unless already at the last character of a word", but I realized that visual mode does move the cursor that way.

function! s:swap_last_two_words()
  " First, go to the last character of last word, even if the Word is one character long
  normal viW
  let l:pos = getpos('.')
  normal XBcxiW
  call setpos('.', l:pos)
endfunction
nnoremap gs :<C-u>call <SID>swap_last_two_words()<CR>

if has("gui_running") && has("mac")
  " <M-s> on macOS
  nmap ß gs
  imap ß <Esc>gsa
else
  nmap <M-s> gs
  imap <M-s> <Esc>gsa
endif

There is at least one edge case if the words straddle two lines, but this is largely enough.

Also, a little tip: When you're exchanging with the same region, you can use . (repeat) the second time:

nice!

huyz commented 2 years ago

I wanted to make this work in insert mode even after whitespace (and even if at the end of the line, which is hard to deal with). Finally have the following (which is a lot more complex than I expected):

" Usage: cursor must be on any character of the second word
function! s:swap_last_two_words()
  " First, go to last character  of last word, even if the Word is one long character
  normal viW
  let l:pos = getpos('.')
  normal XBcxiW
  call setpos('.', l:pos)
endfunction

" Usage: cursor must be right before second word, inside the word, or after
"   any whitespace after the word
function! s:swap_last_two_words_in_insert_mode()
  let l:addone = 0
  let l:beyondeol = 0
  let l:pos = getpos('.')
  if col(".") == col("$")
    let l:beyondeol = 1
  endif
  normal \<Esc>
  if getline('.')[col('.')-1] =~ "\\s"
    " If on whitespace, go back one word
    normal gE
  else
    " If inside word, then let's make sure we move the cursor to end of word
    " because the left word could be shorter than the second word
    exe "normal! viW\<Esc>"
    let l:pos = getpos('.')
    let l:addone = 1
  endif
  normal cxiWB.
  call setpos('.', l:pos)
  if l:beyondeol
    normal $
  elseif l:addone
    normal l
  endif
endfunction

nnoremap <silent> <M-s> :<C-u>call <SID>swap_last_two_words()<CR>
imap <silent> <M-s> <C-\><C-o>:<C-u>call <SID>swap_last_two_words_in_insert_mode()<CR>
nmap ß <M-s>
imap ß <M-s>

In the process, learned about <C-\> from Bram and may have found a bug: https://github.com/vim/vim/issues/11411