100行でeasymotion

# [easymotion](https://github.com/easymotion/vim-easymotion)を再現した

[fzf](https://github.com/junegunn/fzf.vim)を100行で再現した[こちら](https://qiita.com/neras_1215/items/5f58e10d3ba3b846b51b)も書いたのでぜひ。
[それ全部vimrcでよくね](https://qiita.com/neras_1215/items/b4090deb7c07f52c6ec7)という記事もよろしくお願いします。

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453310/09a5e851-0824-ef1e-b1f0-e38649aaf68e.gif)

レポはこちら

https://github.com/serna37/vim


# 概要を説明
本家様のソースは実はあまりみていなく、こうすればいけそう、って感じで作りました。
100行といいつつ139行でしたが、コメント抜いたり圧縮したらだいたい100行なのでご容赦。。
仕組みは
1. ジャンプ先を取得するため、ページ内で正規表現`¥<.`に一致する( =単語の先頭文字 )列数を取得し
`行番号: {列数, ...}`を作成します。
2. キーを作ります。ここがメインロジック。
例えばジャンプ先が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で作成したものの行数と現在行の絶対値でソートします。
3. 描画します。今の画面の全行を灰色にし、ジャンプ先の文字を2で作成したオブジェクト通りに置き換えます。
このとき、`sss`と描画する際は、`単語の先頭`に飛ぶ以上、3文字全部置き換えてしまうと
`This is a test.`こんな感じの場合、`a test.`のaを3文字で置き換えると次の単語にまで影響するので
キーが3文字以上だとしても、最大2文字までを描画するようにしました。
`sswwaa`と押す必要がある場合、`s`を押したら次に`sw`と表示される具合です。
また、残り1キーの場合と2キー以上必要な場合で色を分けつつ、キー文字にもハイライトをつけます。
4. キー入力を受け付けます。
本家ではコマンドラインをいい感じにしているようで、一度入力したらバックスペースで戻れますが
まぁいいでしょ、ということで、入力したら元に戻れない+対象キー以外を押したら終了、などのようにしました。
というのも、キー入力を受け付けるには既存キーマップを無視する必要等があるので、今回はfzfの時のようにポップアップを作成し、（gifのデモでは画面左下のコマンドラインのとこに置いてます）、入力されたキーが想定のものでない場合は全部無視するような仕組みを採用しています。ポップアップ最強。
必要なキーが`ssw`で`s`が入力された場合に、2で更新しておいたオブジェクトのうち、キーがsから始まる要素のみにフィルタし、書き換えた文字を全て戻してから、３の描画を再度行うことでアニメーションします。
最後のキーになった場合はポップアップを消し、最後の行/列にジャンプします。

ただのカーソル移動にオーバーキルすぎますが、easymotionは本当に素晴らしい機能だと思いますし、個人的にも大好きなので、上手く再現できてよかったです。
本家様には他にも色々なバラエティがありますので、easymotionに興味のある方はぜひお試しあれ。

# ソース
こちらをvimrcに書いて、`nnoremap s :call Emotion()<CR>`とか設定すれば呼び出せます。
ポップアップを使用しているのでvim8.2以上とかが必要なはずです。
`brew install vim`で9.0が入ります。
```vim
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
```