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

Vim、ShellScriptについてよく書く

Neovim: Floating Windowに枠線をつける

f:id:rasukarusan:20210131132056p:plain

FloatingWindowには枠線をつけるオプションがない

border:1pxのようなオプションは用意されていない。ただ、枠線があるように見せることは可能。
下記はこちらの記事で作成したときのもの。カレンダーを表現するのに枠線付きのFloatingWindowが必要だった。ソース

カレンダーを枠線FloatingWindowで表現

枠線付きのFloatingWindowを作成する

枠線用にもう一枚FloatingWindowを作り、同じ位置にFloatingWindowを重ねることで枠線付きのFloatingWindowを表現する。

" コンテンツ用ウィンドウの作成
function! s:create_contents_window(config) abort
  let config = {'relative': 'editor', 'row': a:config.row + 1, 'col': a:config.col + 2, 'width': a:config.width - 4, 'height': a:config.height - 2, 'style': 'minimal'}
  let buffer = nvim_create_buf(v:false, v:true)
  return nvim_open_win(buffer, v:true, config)
endfunction

" 枠線用ウィンドウの作成
function! s:create_border_window(config) abort
  let width = a:config.width
  let height = a:config.height
  let top = "╭" . repeat("─", width - 2) . "╮"
  let mid = "│" . repeat(" ", width - 2) . "│"
  let bot = "╰" . repeat("─", width - 2) . "╯"
  let lines = [top] + repeat([mid], height - 2) + [bot]
  let buffer = nvim_create_buf(v:false, v:true)
  call nvim_buf_set_lines(buffer, 0, -1, v:true, lines)
  return nvim_open_win(buffer, v:true, a:config)
endfunction

" 2つで1つのウィンドウとしてみせる
function! s:new_window(config) abort
  call s:create_border_window(a:config)
  call s:create_contents_window(a:config)
endfunction

" ex.) 使い方
let config = { 'relative': 'editor', 'row': 30, 'col': 30, 'width': 50, 'height': 20, 'anchor': 'NW', 'style': 'minimal'}
call s:new_window(config)

f:id:rasukarusan:20210131125717p:plain:w600
枠線付きのFloatingWindow

参考: Feature: optional floating window borders

FloatingWindowにコンテンツを載せる

nvim_buf_set_lines()を使うと配列で1行ずつセットできるので直感的。下記ではfieldという配列を用意し、一行ずつコンテンツ内容を記載している。

let field =
  \ ['今日の日付']
  \ + ['']
  \ + ['2021/01/31']
call nvim_buf_set_lines(buffer, 0, -1, v:true, field)
" コンテンツ用ウィンドウの作成
 function! s:create_contents_window(config, field) abort
  let config = {'relative': 'editor', 'row': a:config.row + 1, 'col': a:config.col + 2, 'width': a:config.width - 4, 'height': a:config.height - 2, 'style': 'minimal'}
  let buffer = nvim_create_buf(v:false, v:true)
  call nvim_buf_set_lines(buffer, 0, -1, v:true, a:field)
  return nvim_open_win(buffer, v:true, config)
endfunction

" 2つで1つのウィンドウとしてみせる
 function! s:new_window(config, field) abort
  call s:create_border_window(a:config)
  call s:create_contents_window(a:config, a:field)
endfunction

let config = { 'relative': 'editor', 'row': 30, 'col': 30, 'width': 50, 'height': 20, 'anchor': 'NW', 'style': 'minimal'}
let field =
  \ ['今日の日付']
  \ + ['']
  \ + ['2021/01/31']

call s:new_window(config, field)

f:id:rasukarusan:20210131125729p:plain:w600
配列で直感的に内容を指定できる

:qで閉じるとき、2回実行する必要がある

追記:
:qを上書きしなくても、autocmd BufWinLeaveを利用すればシンプルに削除処理を登録できる。
下記をコンテンツウィンドウに設定すればOK。

" ウィンドウ削除処理を、コンテンツ用ウィンドウに登録
call nvim_command('autocmd BufWinLeave <buffer> call s:close_window()')

" 下記が:qを上書きするスタイル。BufWinLeaveを使ったほうが美しい。
" nnoremap <buffer><nowait><silent> :q<CR> :call <SID>close_window()<CR>

参考:https://www.2n.pl/blog/how-to-write-neovim-plugins-in-lua.md

追記ここまで

枠線用とコンテンツ用の2つのFloatingWindowがあるので、完全に閉じるには2回:qを実行しないといけない。
ただ、これはちょっと面倒くさいので1回の:qで両方とも閉じられるようにする。要はコンテンツ用ウィンドウのIDと枠線用ウィンドウのIDを紐付けしておけばいいので色々やり方はある。今回はディクショナリ型を使って実装してみる。

" 枠線用ウィンドウとコンテンツウィンドウを紐付ける連想配列
let s:window_ids = {}

" コンテンツ・枠線のウィンドウを削除
function! s:close_window(...)
  let contents_window_id = a:0 == 0 ? nvim_get_current_win() : a:1
  let border_window_id = get(s:window_ids, contents_window_id, -1)
  if border_window_id != -1
    call nvim_win_close(contents_window_id, v:true)
    call nvim_win_close(border_window_id, v:true)
    call remove(s:window_ids, contents_window_id)
  endif
endfunction

" 2つで1つのウィンドウとしてみせる
function! s:new_window(config, field) abort
  let border_window_id = s:create_border_window(a:config)
  let contents_window_id = s:create_contents_window(a:config, a:field)
  " コンテンツ用ウィンドウと枠線用ウィンドウを紐付ける
  let s:window_ids[contents_window_id] = border_window_id
  " ウィンドウ削除処理を、コンテンツ用ウィンドウに登録
  nnoremap <buffer><nowait><silent> :q<CR> :call <SID>close_window()<CR>
  return contents_window_id
endfunction

let config = { 'relative': 'editor', 'row': 30, 'col': 30, 'width': 50, 'height': 20, 'anchor': 'NW', 'style': 'minimal'}
let field =
  \ ['今日の日付']
  \ + ['']
  \ + ['2021/01/31']
call s:new_window(config, field)

:qnnoremapで上書きするスタイル。<buffer>オプションをつけているので、元のウィンドウにはキーバインドが登録されず、FloatingWindowにだけ登録される。s:close_window(...)では引数を取れるようにしてある。こうすることでコンテンツウィンドウにフォーカスが乗っていなくても、IDさえ知っておけばどこからでも削除できる。

" どこからでも削除できるようTに削除処理を登録、みたいなことができる
let contents_window_id = s:new_window(config, field)
nnoremap T :call <SID>close_window(contents_window_id)<CR>

移動できるようにする

hjklで作成したウィンドウを移動できるようにしてみる。

function! s:move(direction, value)
  let contents_window_id = nvim_get_current_win()
  let border_window_id = get(s:window_ids, contents_window_id, -1)
  for id in [contents_window_id, border_window_id]
    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

" 2つで1つのウィンドウとしてみせる
function! s:new_window(config, field) abort
  let border_window_id = s:create_border_window(a:config)
  let contents_window_id = s:create_contents_window(a:config, a:field)
  " コンテンツ用ウィンドウと枠線用ウィンドウを紐付ける
  let s:window_ids[contents_window_id] = border_window_id
  " ウィンドウ削除処理を、コンテンツ用ウィンドウに登録
  nnoremap <buffer><nowait><silent> :q<CR> :call <SID>close_window()<CR>
  " 移動操作を登録
  nnoremap <buffer><nowait><silent> l :call <SID>move('x', 1)<CR>
  nnoremap <buffer><nowait><silent> h :call <SID>move('x', -1)<CR>
  nnoremap <buffer><nowait><silent> j :call <SID>move('y', 1)<CR>
  nnoremap <buffer><nowait><silent> k :call <SID>move('y', -1)<CR>
  return contents_window_id
endfunction

hjlkでFloatingWindowを移動。ちょっとおもしろい。

これを応用したvim-block-pasteというプラグインを以前作った。

www.rasukarusan.com

最終的なソース

" 枠線用ウィンドウとコンテンツウィンドウを紐付ける連想配列
let s:window_ids = {}

" コンテンツ用ウィンドウの作成
function! s:create_contents_window(config, field) abort
  let config = {'relative': 'editor', 'row': a:config.row + 1, 'col': a:config.col + 2, 'width': a:config.width - 4, 'height': a:config.height - 2, 'style': 'minimal'}
  let buffer = nvim_create_buf(v:false, v:true)
  call nvim_buf_set_lines(buffer, 0, -1, v:true, a:field)
  return nvim_open_win(buffer, v:true, config)
endfunction

" 枠線用ウィンドウの作成
function! s:create_border_window(config) abort
  let width = a:config.width
  let height = a:config.height
  let top = "╭" . repeat("─", width - 2) . "╮"
  let mid = "│" . repeat(" ", width - 2) . "│"
  let bot = "╰" . repeat("─", width - 2) . "╯"
  let lines = [top] + repeat([mid], height - 2) + [bot]
  let buffer = nvim_create_buf(v:false, v:true)
  call nvim_buf_set_lines(buffer, 0, -1, v:true, lines)
  return nvim_open_win(buffer, v:true, a:config)
endfunction

function! s:move(direction, value)
  let contents_window_id = nvim_get_current_win()
  let border_window_id = get(s:window_ids, contents_window_id, -1)
  for id in [contents_window_id, border_window_id]
    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

" コンテンツ・枠線のウィンドウを削除
" コンテンツウィンドウIDを引数で指定できるようにしておくと便利
function! s:close_window(...)
  let contents_window_id = a:0 == 0 ? nvim_get_current_win() : a:1
  let border_window_id = get(s:window_ids, contents_window_id, -1)
  if border_window_id != -1
    call nvim_win_close(contents_window_id, v:true)
    call nvim_win_close(border_window_id, v:true)
    call remove(s:window_ids, contents_window_id)
  endif
endfunction

" 2つで1つのウィンドウとしてみせる
function! s:new_window(config, field) abort
  let border_window_id = s:create_border_window(a:config)
  let contents_window_id = s:create_contents_window(a:config, a:field)
  " コンテンツ用ウィンドウと枠線用ウィンドウを紐付ける
  let s:window_ids[contents_window_id] = border_window_id
  " ウィンドウ削除処理を、コンテンツ用ウィンドウに登録
  nnoremap <buffer><nowait><silent> :q<CR> :call <SID>close_window()<CR>
  nnoremap <buffer><nowait><silent> j :call <SID>move('y', 1)<CR>
  nnoremap <buffer><nowait><silent> k :call <SID>move('y', -1)<CR>
  nnoremap <buffer><nowait><silent> l :call <SID>move('x', 1)<CR>
  nnoremap <buffer><nowait><silent> h :call <SID>move('x', -1)<CR>
  return contents_window_id
endfunction


let config = { 'relative': 'editor', 'row': 30, 'col': 30, 'width': 50, 'height': 20, 'anchor': 'NW', 'style': 'minimal'}
let field =
  \ ['今日の日付']
  \ + ['']
  \ + ['2021/01/31']

let contents_window_id = s:new_window(config, field)
nnoremap T :call <SID>close_window(contents_window_id)<CR>

終わり

hjklで移動できると楽しいのでぜひやってみてほしい。