ハイパーマッスルエンジニア

Vim、ShellScriptについてよく書く

Floating Windowの変態的な使い方

f:id:rasukarusan:20211212230022j:plain

これはVim Advent Calendar 2021の14日目の記事です。

NeovimにFloating Windowが実装されて以来、様々なプラグインが開発、リプレイスされてきました。 有名所でいうとgit-messenger.vimでしょうか。Floating Windowの良い使い方だなあと感動した覚えがあります。

今日は僕が今まで開発してきたプラグインの中で、Floating Windowを変態的に使ったものを紹介したいと思います。

はじめに

記事内に折りたたまれているコードはダブルクリックでコピーが可能で、
test.vimとして保存 ->:source % -> Shift+T
で実行できるようになっています。
ぜひ手元のNeovimでお試しください。Neovim >=0.4 であれば動きます。

矩形選択&ペーストを可視化して直感化

矩形選択したものをどこにでも貼り付けられる

github.com

矩形選択からのペーストをビジュアライズするプラグインです。 矩形選択した箇所をFloating Windowにしてブロック化、カーソル移動できるところが気持ちいいですね。
文字を持ち上げる感じがVimを3D化しているようでエモいですよね。 また、ペーストしたあとにgvで再選択できるのが地味に良いところです。

テトリスペースト

ペーストをテトリスっぽくする

コード▼ こちらはヤンクしてからShift+Tを実行してください。また、できるだけ画面下で実行すると上から降ってくるのがよく見えます。

function! s:move_floating_window(win_id, relative, row, col)
  let newConfig = {
    \ 'relative': a:relative,
    \ 'row': a:row,
    \ 'col': a:col,
    \}
  call nvim_win_set_config(a:win_id, newConfig)
  redraw
endfunction

function! s:create_window(config)
    let buf = nvim_create_buf(v:false, v:true)
    let win_id = nvim_open_win(buf, v:true, a:config)
    hi mycolor guifg=#ffffff guibg=#dd6900
    call nvim_win_set_option(win_id, 'winhighlight', 'Normal:mycolor')
    call nvim_win_set_option(win_id, 'winblend', 40)
    return win_id
endfunction

function! s:transparency_window(win_id)
    let i = 0
    while i <= 50
        call nvim_win_set_option(a:win_id, 'winblend', i*2)
        let i += 1
        " 毎回redrawするとカクつくため
        if i % 2 == 0
            redraw
        endif
    endwhile
endfunction

function! s:get_col() 
    " 行番号を非表示にしている場合は調整不要なので0を返す
    if &number == 0
        return 0
    endif
    " 行数を表示している場合、行数の桁数分調整する必要がある. e.g) max_line = 100の場合3(桁)
    " +1しているのは行番号表示用ウィンドウのスペース分
    let max_line = line("$")
    return strlen(max_line) + 1
endfunction

function! s:get_width() 
    return strlen(@*)
endfunction

function! s:get_height() 
    let contents = split(@*,'\n')
    return len(contents)
endfunction

function! s:paste_to_current_window(number_of_line)
    if a:number_of_line == 1
        let @* = substitute(@*,"\n","","g")
        let @* = @* . "\n"
    endif
    execute 'normal p'
endfunction

" ペーストする内容を入れる(表示する)ための空行を挿入
function! s:insert_empty_line(row)
    let i = 0
    while i < a:row
        call append(expand('.'), '')
        let i += 1
    endwhile
endfunction

function! s:delete_empty_line(row)
    execute 'normal ' . a:row . 'j'
    execute 'normal ' . a:row . '"_dd'
    execute 'normal ' . a:row . 'k'
endfunction

function! s:main()
    let start_row = 10
    let col = s:get_col()
    let width = s:get_width()
    let height = s:get_height()
    let config = { 'relative': 'editor', 'row': start_row, 'col': col, 'width': width, 'height': height, 'anchor': 'NW', 'style': 'minimal',}
    if width == 0 || height == 0
        return
    endif

    let win_id = s:create_window(config)

    " floating windowにクリップボードの内容をペースト
    execute 'normal P'
    " フォーカスをカレントウィンドウに戻す
    execute "0windo " . ":"

    " floating windowを上から降らす
    let move_y = line(".") - line("w0") - start_row
    let i = 0
    while i <= move_y
        call s:move_floating_window(win_id, config.relative, config.row + i + 1, config.col)
        sleep 10ms
        let i += 1
    endwhile

    " ペースト内容を表示するための空行を挿入
    call s:insert_empty_line(height)

    " floating windowを透明化
    call s:transparency_window(win_id)

    " カレントウィンドウにクリップボードの内容をペースト
    call s:paste_to_current_window(height)

    " 事前に挿入した空行を削除
    call s:delete_empty_line(height)

    " floating windowを削除
    call nvim_win_close(win_id, v:true)
endfunction

nnoremap <silent> T :call <SID>main()<CR>

ペーストを上から降らしてかっこよくします。
winblendで透明度を動的に変更することでスパーク感を演出しています。

また同じような理論でデリートもテトリスっぽくできます。こちらは選択した行の幅をstrdisplaywidthで取得し、Floating Windowでブロック化して分割することでテトリスらしさを醸し出すことに成功しています。

デリートのほうがテトリスっぽい

コード▼

function! s:create_window(config)
    let buf = nvim_create_buf(v:false, v:true)
    let win_id = nvim_open_win(buf, v:true, a:config)
    return win_id
endfunction

function! s:move_floating_window(win_id, relative, row, col)
  let newConfig = {'relative': a:relative, 'row': a:row, 'col': a:col,}
  call nvim_win_set_config(a:win_id, newConfig)
  redraw
endfunction

function! s:focus_to_main_window()
    execute "0windo :"
endfunction

function! s:get_col() 
    " when `set nonumber` not need adjustment
    if &number == 0
        return 0
    endif
    " not support over 1000 line file
    return 4
endfunction

function! s:split_words()
    let words = split(getline('.'), '\zs')
    let result = []
    let index = 0
    let i = 0
    let word = ''
    let split_num = 7
    while i < len(words)
        let word = word . words[i]
        if i % (len(words)/split_num)  == 0 && i != 0
            call insert(result, word, index)
            let word = ''
            let index += 1
        endif
        let i += 1
    endwhile
    call insert(result, word, index)
    return result
endfunction

function! s:fall_window(win_id)
    let move_y = line('w$') - line('.')
    let config = nvim_win_get_config(a:win_id)
    for y in range(0, move_y)
        call s:move_floating_window(a:win_id, config.relative, config.row + y + 1, config.col) 
        sleep 4ms
    endfor
endfunction

function! s:set_color_random(win_id)
    let color = '#' . printf("%x", Random(16)) . printf("%05x", Random(69905))
    let hl_name = 'ClipBG' . a:win_id
    execute 'hi' hl_name 'guifg=#ffffff' 'guibg=' . color
    call nvim_win_set_option(a:win_id, 'winhighlight', 'Normal:'.hl_name)
endfunction

function! s:create_words_window()
    let row = line('.') - line('w0')
    let col = s:get_col()
    let win_ids = []
    let words = s:split_words()

    for word in words
        let width = strdisplaywidth(word)
        if width == 0
            continue
        endif
        let config = {
            \'relative': 'editor',
            \ 'row': row,
            \ 'col': col,
            \ 'width': width,
            \ 'height': 1,
            \ 'anchor': 'NW',
            \ 'style': 'minimal',
            \}
        let win_id = s:create_window(config)
        call add(win_ids, win_id)

        call s:set_color_random(win_id)
        " call nvim_win_set_option(win_id, 'winblend', 100)

        call setline('.', word)
        call s:focus_to_main_window()
        let col += width
    endfor
    return win_ids
endfunction

function Random(max) abort
  return str2nr(matchstr(reltimestr(reltime()), '\v\.@<=\d+')[1:]) % a:max
endfunction

function! s:main() abort
    let win_ids = s:create_words_window()

    " fall current line
    call setline('.', '')
    for win_id in win_ids
        call s:fall_window(win_id)
    endfor
    execute 'normal dd'

    " close each floating window
    for win_id in win_ids
        call nvim_win_close(win_id, v:true)
    endfor
endfunction

call s:focus_to_main_window()

nnoremap <silent> T :call <SID>main()<CR>

ボタンを作って発射する

ENTERでボタンPUSH&弾発射

コード▼

let g:button_window = {}
hi FrontColor guibg=#F27200
hi BackColor guibg=#AC5D24

function! s:center(str)
  let width = nvim_win_get_width(0)
  let shift = floor(width/2) - floor(strdisplaywidth(a:str)/2)
  return repeat(' ', float2nr(shift)) . a:str
endfunction

function! s:move(direction, value)
  let front_win = nvim_get_current_win()
  let back_win = g:button_window[front_win]
  for id in [front_win, back_win]
    let config = nvim_win_get_config(id)
    if a:direction == 'x'
      let config.col += a:value
    else
      let config.row += a:value
    endif
    call nvim_win_set_config(id, config)
  endfor
endfunction

function! s:remove_button() abort
  let front_win = nvim_get_current_win()
  let back_win = g:button_window[front_win]
  call nvim_win_close(back_win, v:true)
  call nvim_win_close(front_win, v:true)
  call remove(g:button_window, front_win)
endfunction

function! s:create_window(config, ...) abort
  let hi_group = a:1
  let transparency = get(a:, '2', 0)
  let buf = nvim_create_buf(v:false, v:true)
  let win = nvim_open_win(buf, v:true, a:config)
  if hi_group != ''
    call nvim_win_set_option(win, 'winhighlight', hi_group)
    call nvim_win_set_option(win, 'winblend', transparency)
    call nvim_win_set_config(win, a:config)
  endif
  return win
endfunction

function! s:fire() abort
  let front_win = nvim_get_current_win()
  let conf = nvim_win_get_config(front_win)
  let row = conf.row + 1
  let col = conf.col + conf.width
  let width = 2
  let height = 1
  let config = { 'relative': 'editor', 'row': row, 'col': col, 'width': width, 'height': height, 'anchor': 'NW', 'style': 'minimal', }
  let ballet = s:create_window(config, 'Normal:FrontColor')

  for i in range(1, 30)
    let config.col += 1
    call nvim_win_set_config(ballet, config)
    redraw
    sleep 10ms
  endfor

  call nvim_win_close(ballet, v:true)
endfunction

function! s:push() abort
  let front_win = nvim_get_current_win()
  let back_win = g:button_window[front_win]
  let config = nvim_win_get_config(front_win)
  let config.row += 1
  call nvim_win_set_config(front_win, config)

  sleep 100ms
  redraw

  let config.row -= 1
  call nvim_win_set_config(front_win, config)

  call s:fire()
endfunction

function! s:main() abort
  let row = 20
  let col = 20
  let width = 20
  let height = 3
  let config = { 'relative': 'editor', 'row': row, 'col': col, 'width': width, 'height': height, 'anchor': 'NW', 'style': 'minimal' }

  " 影の部分
  let back_config = deepcopy(config)
  let back_config.row += 1
  let back_win = s:create_window(back_config, 'Normal:BackColor')

  " 前面の部分
  let front_win = s:create_window(config, 'Normal:FrontColor')
  call setline(2, s:center('Button'))
  call cursor(2, 0)

  nnoremap <buffer><nowait><silent> l :call <SID>move('x', 2)<CR>
  nnoremap <buffer><nowait><silent> h :call <SID>move('x', -2)<CR>
  nnoremap <buffer><nowait><silent> j :call <SID>move('y', 2)<CR>
  nnoremap <buffer><nowait><silent> k :call <SID>move('y', -2)<CR>
  nnoremap <buffer><nowait><silent> :q :call <SID>remove_button()
  nnoremap <CR> :call <SID>push()<CR>

  " 影と前面のウィンドウを紐付け
  let g:button_window[front_win] = back_win
endfunction

call s:main()

Floating Windowではボタンも作れます。さらには弾を発射することも可能です。
ボタンに見せるには全面と背面で2枚のFloating Windowを利用し、背面の色をちょっと暗くするとボタンに見えます。この辺はVimというよりデザインの話ですね。デザイナーもVimをやる時代です。

行飛ばしで選択してコピー

飛ばし飛ばしに行を選択してコピー

github.com

Vimでは飛ばし飛ばしの行を選択してコピーや削除するといったことが簡単にはできないので作りました。
ただ、実を言うとこれはFloating Windowを使っていません。「ExtMark」というNeovimの別の機能を使っています。 当初はFloating Windowで実装していたのですが、ExtMarkを使ったほうが動作が軽くなり、画面をまたぐ移動選択も可能になったためリプレイスしました。
ExtMarkもFloating Windowと同じぐらい変態的な使い方がまだまだ隠されていると思っているので、紹介させていただきました。

Vimでビートマニア(@skanehiraさん作)

Vimでビートマニア
引用元: Vimでビートマニアする

github.com

最後にこちらは他の方が作られたプラグインですが、「ああなかなかに変態だな...」と感じたため紹介させていただきます。作られたのはゴリラ界で最も有名なVimmerである@skanehiraさん、通称gorillaさんですね。 こちらはFloating WindowではなくVimのpopup windowを利用されているようです。timer_startを利用した非同期処理はとても参考になります。

最後に

Floating Windowのいろいろな使い方を紹介しました。まだまだこれら以外にもマニアックな使い方があると思います。
こんなプラグインもあるよという方、ぜひコメント欄にて教えてほしいです。また、これからプラグインを作る方にとって何かしらの刺激になっていたら幸いです。

これからもVimで最高に遊んでいきたいですね。ではまた。