easymotionを再現した
fzfを100行で再現したこちらも書いたのでぜひ。
それ全部vimrcでよくねという記事もよろしくお願いします。
レポはこちら
概要を説明
本家様のソースは実はあまりみていなく、こうすればいけそう、って感じで作りました。
100行といいつつ139行でしたが、コメント抜いたり圧縮したらだいたい100行なのでご容赦。。
仕組みは
- ジャンプ先を取得するため、ページ内で正規表現
¥<.
に一致する( =単語の先頭文字 )列数を取得し
行番号: {列数, ...}
を作成します。 - キーを作ります。ここがメインロジック。
例えばジャンプ先が100箇所だったら、キーをホームポジション的に
['s', 'w', 'a', 'd', 'j', 'k', 'h', 'l']
と8キーにしていた場合
キーの組み合わせは、8個の組み合わせの場合の数が100を越えれば良いので
8^nが100を超えるまで指数を増やし、今回だと8^3で100を超えるので
3個のキーが必要となり、sss
ssw
といったようにキーを作成します。
このとき、行きたい場所に置くキーは、使えるキー配列のインデックスを用いて
sss
なら[0,0,0]
、wad
なら[1,2,3]
と表せるので
8進法で1ずつ増えていくように捉えられますので、ジャンプ先候補が100個なら100回、増やしながらキーを作成しつつ、1で作成したオブジェクトを行番号: {列数とキー, ...}
と更新します。
また、なるべく現在行から近い順にキーを割り振りたかったので、1で作成したものの行数と現在行の絶対値でソートします。 - 描画します。今の画面の全行を灰色にし、ジャンプ先の文字を2で作成したオブジェクト通りに置き換えます。
このとき、sss
と描画する際は、単語の先頭
に飛ぶ以上、3文字全部置き換えてしまうと
This is a test.
こんな感じの場合、a test.
のaを3文字で置き換えると次の単語にまで影響するので
キーが3文字以上だとしても、最大2文字までを描画するようにしました。
sswwaa
と押す必要がある場合、s
を押したら次にsw
と表示される具合です。
また、残り1キーの場合と2キー以上必要な場合で色を分けつつ、キー文字にもハイライトをつけます。 - キー入力を受け付けます。
本家ではコマンドラインをいい感じにしているようで、一度入力したらバックスペースで戻れますが
まぁいいでしょ、ということで、入力したら元に戻れない+対象キー以外を押したら終了、などのようにしました。
というのも、キー入力を受け付けるには既存キーマップを無視する必要等があるので、今回はfzfの時のようにポップアップを作成し、(gifのデモでは画面左下のコマンドラインのとこに置いてます)、入力されたキーが想定のものでない場合は全部無視するような仕組みを採用しています。ポップアップ最強。
必要なキーがssw
でs
が入力された場合に、2で更新しておいたオブジェクトのうち、キーがsから始まる要素のみにフィルタし、書き換えた文字を全て戻してから、3の描画を再度行うことでアニメーションします。
最後のキーになった場合はポップアップを消し、最後の行/列にジャンプします。
ただのカーソル移動にオーバーキルすぎますが、easymotionは本当に素晴らしい機能だと思いますし、個人的にも大好きなので、上手く再現できてよかったです。
本家様には他にも色々なバラエティがありますので、easymotionに興味のある方はぜひお試しあれ。
ソース
こちらをvimrcに書いて、nnoremap s :call Emotion()<CR>
とか設定すれば呼び出せます。
ポップアップを使用しているのでvim8.2以上とかが必要なはずです。
brew install vim
で9.0が入ります。
let s:emotion_keypos = []
let s:emotion_klen = 1
" m, g read some function? doesn't work just as I want
let g:emotion_keys = ['s', 'w', 'a', 'd', 'j', 'k', 'h', 'l']
fu! Emotion()
" fold all open
normal zR
" get target chars in current window without empty line
" [{'row': row number, 'col': [ col number, ... ]}...]
let s:emotion_keypos = [] | let wininfo = [] | let tarcnt = 0 | let rn = line('w0') | let crn = line('.') | let s:emotion_klen = 1
for l in getline('w0', 'w$')
" loop row without 'including MultiByte' and 'empty', get head chars
" 日本語は1文字でマルチバイト3文字分だが、カーソル幅は2なのでめんどい、日本語を含む行は弾く
if l !~ '^[ -~]\+$' | let rn+=1 | continue | endif
let chars = [] | let ofst = 0
while ofst != -1
let st = matchstrpos(l, '\<.', ofst) | let ofst = matchstrpos(l, '.\>', ofst)[2]
if st[0] != '' | cal add(chars, st[2]) | endif
endwhile
if !empty(chars) | cal add(wininfo, #{row: rn, col: chars}) | endif
let tarcnt = tarcnt+len(chars) | let rn+=1
endfor
if tarcnt==0 | retu | endif
" calc key stroke length, keyOrder is 'ssw' = [0,0,1]
while tarcnt > pow(len(g:emotion_keys), s:emotion_klen) | let s:emotion_klen+=1 | endwhile
let keyOrder = range(1, s:emotion_klen)->map({->0})
" sort near current line, create 's:emotion_keypos' map like this
" [{'row': 1000, 'col': [{'key': 'ssw', 'pos': 7}, ... ]}, ... ]
for r in sort(deepcopy(wininfo), { x,y -> abs(x.row-crn) - abs(y.row-crn) })
let tmp = []
for col in r.col
cal add(tmp, #{key: copy(keyOrder)->map({i,v->g:emotion_keys[v]})->join(''), pos: col})
let keyOrder = s:incrementNOrder(len(g:emotion_keys)-1, keyOrder)
endfor
cal add(s:emotion_keypos, #{row: r.row, col: tmp})
endfor
" draw , disable f-scope(with clear matches)
cal FModeDeactivate()
for rn in range(line('w0'), line('w$')) | cal matchaddpos('EmotionBase', [rn], 98) | endfor
cal s:emotion_draw(s:emotion_keypos) | cal popup_close(s:emotion_popid)
let s:emotion_popid = popup_create('e-motion', #{line: &lines, col: &columns*-1, mapping: 0, filter: function('s:emotion_char_enter')})
cal win_execute(s:emotion_popid, "mapclear <buffer>") | echo ''
endf
" function: increment N order
" 配列をN進法とみなし、1増やす. 使うキーがssf → sws と繰り上がる仕組み
fu! s:incrementNOrder(nOrder, keyOrder)
if len(a:keyOrder) == 1 | retu [a:keyOrder[0]+1] | endif
let tmp = [] | let overflow = 0
for idx in reverse(range(0, len(a:keyOrder)-1))
" 1. increment last digit
if idx == len(a:keyOrder)-1
cal insert(tmp, a:keyOrder[idx] == a:nOrder ? 0 : a:keyOrder[idx]+1)
if tmp[0] == 0 | let overflow = 1 | endif | continue
endif
" 2. check next digit
if overflow
cal insert(tmp, a:keyOrder[idx] == a:nOrder ? 0 : a:keyOrder[idx]+1)
let overflow = a:keyOrder[idx] == a:nOrder ? 1 : 0
else
cal insert(tmp, a:keyOrder[idx])
endif
endfor
return tmp
endf
" about highlight setting
augroup emotion_hl
autocmd!
autocmd ColorScheme * highlight EmotionBase ctermfg=59
autocmd ColorScheme * highlight EmotionWip ctermfg=166 cterm=bold
autocmd ColorScheme * highlight EmotionFin ctermfg=196 cterm=bold
augroup END
highlight EmotionBase ctermfg=59
highlight EmotionWip ctermfg=166 cterm=bold
highlight EmotionFin ctermfg=196 cterm=bold
fu! HiResetAll(group_name)
cal getmatches()->filter({ _,v -> v.group == a:group_name })->map('execute("cal matchdelete(v:val.id)")')
endf
" draw keystroke
" 日本語は1文字でマルチバイト3文字分だが、カーソル幅は2なのでめんどいから弾いてある
" posの次文字がマルチバイトだと、strokeが2回以上残ってる時、変に文字を書き換えてカラム数変わる
fu! s:emotion_draw(keypos)
cal HiResetAll('EmotionFin') | cal HiResetAll('EmotionWip')
let hlpos_wip = [] | let hlpos_fin = []
for r in a:keypos | let line = getline(r.row)
for c in r.col
let colidx = c.pos-1 | let view_keystroke = c.key[:0] | let offset = colidx-1
cal add(hlpos_fin, [r.row, c.pos])
if len(c.key)>=2
let view_keystroke = c.key[:1]
cal add(hlpos_wip, [r.row, c.pos, 2])
endif
let line = colidx == 0
\ ? view_keystroke.line[len(view_keystroke):]
\ : line[0:offset].view_keystroke.line[colidx+len(view_keystroke):]
endfor
cal setline(r.row, line)
endfor
for t in hlpos_fin | cal matchaddpos('EmotionFin', [t], 99) | endfor
for t in hlpos_wip | cal matchaddpos('EmotionWip', [t], 100) | endfor
endf
let s:emotion_popid = 0
fu! s:emotion_char_enter(winid, key)
" noop (for polyglot bug adhoc)
if strtrans(a:key) == "<80><fd>`" | retu 1 | endif
" only accept defined emotion key
if g:emotion_keys->index(a:key) == -1
" go out e-motion
cal popup_close(s:emotion_popid)
let p = getpos('.') | u | cal cursor(p[1],p[2])
cal HiResetAll('EmotionFin') | cal HiResetAll('EmotionWip') | cal HiResetAll('EmotionBase')
" restore f-scope
if g:fmode_flg == 1 | cal FModeActivate() | else | cal FModeDeactivate() | endif
echohl Special | echo 'e-motion: go out' | echohl None | retu 1
endif
" upd emotion_keypos
let tmp = s:emotion_keypos->deepcopy()->map({ _,r -> #{row: r.row,
\col: r.col->filter({_,v->v.key[0]==a:key})->map({_,v->#{key: v.key[1:], pos: v.pos}})} })
\->filter({_,v->!empty(v.col)})
" nomatch -> noop
if empty(tmp) | retu 1 | else | let s:emotion_keypos = tmp | endif
" if last match -> end e-motion
if len(s:emotion_keypos) == 1 && len(s:emotion_keypos[0].col) == 1
cal popup_close(s:emotion_popid)
u | cal cursor(s:emotion_keypos[0].row, s:emotion_keypos[0].col[0].pos)
cal HiResetAll('EmotionFin') | cal HiResetAll('EmotionWip') | cal HiResetAll('EmotionBase')
" restore f-scope
if g:fmode_flg == 1 | cal FModeActivate() | else | cal FModeDeactivate() | endif
echohl Special | echo 'e-motion: finish' | echohl None | retu 1
endif
" redraw
let p = getpos('.') | u | cal cursor(p[1],p[2]) | echo '' | cal s:emotion_draw(s:emotion_keypos)
retu 1
endf