1

I would like to add, say, the next three characters to the current visual selection. For example:

Do not select. Select from here.
Select this too. Select until there. Do not select.

If bold represents the selection, I would like to expand it by three characters at the end. If I know that the cursor is at the end of the selection, I can do:

<Space><Space><Space>

If I know that the cursor is at the beginning of the selection, I can do:

o<Space><Space><Space>o

However, I want to write a visual-mode command that works in both situations. How would I do that? Restoring the cursor position afterwards would be a plus.

1

Here's another solution, which solves the problems with multibyte characters (and a couple of issues with visual block mode). It works by using motions to expand the visual selection by moving the cursor, instead of setting the ends of the selection directly in Vimscript.

It also differs from your solution in that if the visual selection is linewise, the commands expand the selection vertically instead of displaying an error. If you really don't want that, it should be fairly easy to adapt the code to revert to your version's behaviour.

function! ExpandVisualWrapper(count, expand_after) abort
  " Save cursor position, count and expansion type
  let s:cursor_pos = getpos('.')
  let s:count = a:count
  let s:expand_after = a:expand_after

  " Return a string that will save the current visual selection (by yanking it
  " into the black hole register) and then call the real function.
  return "\"_y:call ExpandVisual()\<CR>"
endfunction

function! ExpandVisual() abort
    " Check which end we want to expand
    if s:expand_after
        let visual_pos = getpos("'>")
    else
        let visual_pos = getpos("'<")
    endif

    " Check which end the cursor is at
    if
    \     visualmode() == 'v' " Characterwise
    \     &&
    \     s:cursor_pos == visual_pos
    \ ||
    \     visualmode() == 'V' " Linewise
    \     &&
    \     s:cursor_pos[1] == visual_pos[1]
    \ ||
    \     visualmode() == "\<C-V>" Blockwise
    \     &&
    \     xor(getpos("'<")[2] > getpos("'>")[2], s:cursor_pos[2] == visual_pos[2])

        " Cursor is at the correct end
        let change_end = ''
    else
        " We'll need to hop to the other end of the selection
        let change_end = 'o'
    endif

    " Which direction do we want to move the cursor?
    if (s:expand_after && s:count > 0) || (!s:expand_after && s:count < 0)
        let motion = visualmode() == "V" ? "\<Down>" : "\<Space>"
    else
        let motion = visualmode() == "V" ? "\<Up>" : "\<BS>"
    endif

    " Build the commands
    let commands = 'gv'
    let commands .= change_end
    let commands .= abs(s:count) . motion
    let commands .= change_end

    " Run the commands
    execute 'normal!' commands
endfunction

vnoremap <silent> <expr> \- ExpandVisualWrapper(-v:count1, 0)
vnoremap <silent> <expr> \+ ExpandVisualWrapper(v:count1, 0)
vnoremap <silent> <expr> - ExpandVisualWrapper(-v:count1, 1)
vnoremap <silent> <expr> + ExpandVisualWrapper(v:count1, 1)

When I saw your solution, I read the explanation at the top and instead of actually reading the code, just looked at the length of it before jumping to the conclusion, “That looks overly complicated. Surely this would be easier to implement just by using normal motion commands.”

Arguably, my solution is even more complex. 🤦

This is for three reasons:

  1. I didn't really think about how many different behaviours you'd implemented,
  2. I failed to realise that most of your code is actually error handling,
  3. It's not possible to implement this with a simple expression mapping because when such a mapping is invoked, the '< and '> marks are set to the previous visual selection — not the current one. However, it's also not possible to implement it with a regular mapping, because as soon as you enter command-line mode to invoke a function via :call, the cursor moves to the start of the visual selection, so it's not possible to discern which end you were at.

    It's therefore necessary to implement two functions. The first, invoked by an expression mapping, saves the state of the visual selection by means of yanking it into the black hole register. The second, invoked by the return value of the first, actually does the work.

  • 1
    Great! I am not sure that it is really conceptually more complex. The key idea I did not think of, was to compare the position of . with that of the end you want to expand. After that, this is relatively straightforward… except for that black hole trick — thanks for this helpful insight! That’s a small price for handling multibyte characters. Plus, you get rid of all the strange corner cases I ran into while doing vimscript arithmetic on positions. – Maëlan Sep 5 '18 at 20:25
1

I finally figured it out. The keys are:

  • the marks for the starting ('<) and ending ('>) positions of the visual selection;¹
  • functions getpos() and setpos() to manipulate marks from vimscript.
  • (the normal-mode command gv to reselect the active visual selection.)

Mixing these ingredients is less straightforward than one might expect, because of oddities of Vim. Also, column numbers are bytewise, so this will break with multibyte characters.

The (much expanded) code looks like this.

" These functions will not work as intended in the presence of multibyte
" characters.

fun! ExpandVisualBefore(by_lines, by_cols)
  let [start_buf, start_line, start_col, start_off] = getpos("'<")
  let end_pos                                       = getpos("'>")
  if start_off != 0
    throw 'function ExpandVisualBefore(): ''virtualedit'' is not supported'
  endif
  " Note: “:help getpos()” says that the column of '< should be 0 when in
  " visual-line mode, but my tests indicate that it is set to 1 instead.
  " the column of '> should be set to a “large number”, which my tests confirm.
  if end_pos[2] >= 2147483647 && a:by_cols != 0
    echohl WarningMsg
    echo 'function ExpandVisualBefore(): cannot add or remove columns in visual-line mode'
    echohl None
  endif
  if setpos("'<", [start_buf, start_line-a:by_lines, start_col-a:by_cols, 0]) == -1
    throw 'function ExpandVisualBefore(): setpos() failed'
  endif
  " Note: For some obscure reason, when the cursor is at the start of the
  " selection, changing '< apparently sets '> to the former value of '<. Below,
  " we fix that by restoring '> to its initial value.
  " Under the same circumstances, the cursor is moved to the end of the
  " selection, we should fix that as well…
  if setpos("'>", end_pos) == -1
    throw 'function ExpandVisualAfter(): setpos() failed'
  endif
endfun

fun! ExpandVisualAfter(by_lines, by_cols)
  let start_pos                             = getpos("'<")
  let [end_buf, end_line, end_col, end_off] = getpos("'>")
  if end_off != 0
    throw 'function ExpandVisualAfter(): ''virtualedit'' is not supported'
  endif
  if end_col >= 2147483647 && a:by_cols != 0
    echohl WarningMsg
    echo 'function ExpandVisualAfter(): cannot add or remove columns in visual-line mode'
    echohl None
  endif
  if setpos("'>", [end_buf, end_line+a:by_lines, end_col+a:by_cols, 0]) == -1
    throw 'function ExpandVisualAfter(): setpos() failed'
  endif
  " Note: For some obscure reason, when the cursor is at the start of the
  " selection, changing '> apparently sets '< to the former value of '>. Below,
  " we fix that by restoring '< to its initial value.
  " Under the same circumstances, the cursor is moved to the end of the
  " selection, we should fix that as well…
  if setpos("'<", start_pos) == -1
    throw 'function ExpandVisualAfter(): setpos() failed'
  endif
endfun

vnoremap <silent> \- :call ExpandVisualBefore(0,-v:count1)<CR>gv
vnoremap <silent> \+ :call ExpandVisualBefore(0,v:count1)<CR>gv
vnoremap <silent> - :call ExpandVisualAfter(0,-v:count1)<CR>gv
vnoremap <silent> + :call ExpandVisualAfter(0,v:count1)<CR>gv

Then, in visual mode, typing 3+ (resp. 3\+) extends the selection by three characters to the right (resp. left); typing 3- (resp. 3\-) shrinks it by three characters to the right (resp. left).


¹ Notation are inconsistent here: in range and movements, `< and `> are the starting and ending positions of the selection, whereas '< and '> are linewise (they only mean the line where the selection begins or ends); but as far as getpos() / setpos() is concerned, '< and '> must be used to manipulate the full positions.

  • In my copy of Vim (v8.0.1420) this has a couple of problems in blockwise mode: 1. It extends the selection by the count multiplied by the number of lines selected, 2. If you create the block by moving down-and-left or up-and-right, the commands to extend/shrink are reversed (they go in the wrong direction). – Rich Sep 5 '18 at 15:55
  • You are right, blockwise selection is probably harder to handle and I did not feel like thinking about it, considering that I hardly use it at all, and considering headache my solution above already gave me. I should have mentioned it. – Maëlan Sep 5 '18 at 20:16

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.