気を散らすノート

色々と散った気をまとめておくところ.論文読んだり自分で遊んだりする.たぶん.

Vim 8.2, そして Killersheep

じぶん advent calendar 2019, 13日目の記事です.

Vim 8.2 がリリースされました.

  $ git log v8.2.0000
commit 98056533b96b6b5d8849641de93185dd7bcadc44 (tag: v8.2.0000)
Author: Bram Moolenaar <Bram@vim.org>
Date:   Thu Dec 12 14:18:35 2019 +0100

    Vim 8.2 release

8.1 も結構最近だったような気がしたけど,振り返ると長かった.

  $ git log v8.1.0000
commit b1c9198afb7ff902588b45fbe44f0760a9f48375 (tag: v8.1.0000)
Author: Bram Moolenaar <Bram@vim.org>
Date:   Thu May 17 17:04:55 2018 +0200

    Vim 8.1 release
    
    Update version number and information.  Fix a couple of tests.

この間 (v8 に入る頃から), vim は激動の時代を歩んだと言ってもいいかもしれません. 5年前には全く想定されなかった様々な機能が入りました.

  • async / +job, +channel (これはもうちょっと前)
  • :Terminal
  • popup
  • audio (!!?)
  • ...

いわゆるモダンさは一歩譲るが卓越した根本的な設計思想からくる操作性を 誇る,という立ち位置だった Vim も,その姿を徐々に変えつつあります.

折しも世間でエディタごとの言語-specific なツール一辺倒の時代が黄昏を迎え, Language Server Protocol が広がり始めたころでもあります.

そんな中,Bramが突如公開した謎のゲーム…

https://github.com/vim/killersheep

Silly game to show off the new features of Vim 8.2: * Popup windows with colors and mask * Text properties to highlight text * Sound

f:id:lesguillemets:20191213233335p:plain
見た目よりも音がいかにも間が抜けてて崩れ落ちそうになる

マジで音の間抜けさがやばいのでみんなやってみましょう.

ともかく実装が気になるので,新機能のチェックがてら覗いてみましょう.

plugin/killersheep.vim

普通に起動するだけ. call killersheep#Start(s:dir) ですね.:echohl の存在を初めて知った.

マジで音の間抜けさがやばいので plugin/ 下のファイル再生だけでいいからしてみてほしい.

autoload/killersheep.vim

killersheep#Start(sounddir):

func killersheep#Start(sounddir)
  let s:dir = a:sounddir

  if !has('sound')
    if executable('afplay')
      let s:sound_cmd = 'afplay'
      let g:killersheep_sound_ext = '.mp3'
    " 中略: 再生に使えるコマンドと対応する拡張子の設定
    endif
  endif

  if !s:did_init
    let s:did_init = 1
    call s:Init()
  endif

  call s:Clear()
  call s:Intro()
endfunc

順に見ていきましょう

func s:Init()
  hi def KillerCannon ctermbg=blue guibg=blue
  " 中略: 必要な highlight を定義

  " 略: 環境に合わせて g:killersheep_sound_ext を設定
  let g:killersheep_sound_ext = ".ogg"
endfunc

ではあとは s:Clears:Intro を呼べばよろしい.

先に s:Intro を見てみましょう.予備知識: #{} (:h literal-Dict) は key を クオートしなくてよくて便利な辞書リテラルです (#{zero:0} == {'zero': 0}). これを作っているところだ:

f:id:lesguillemets:20191214001157p:plain
色ついてるのが動く

func s:Intro()
  hi SheepTitle cterm=bold gui=bold
  hi introHL ctermbg=cyan guibg=cyan
  call prop_type_delete('sheepTitle')
  call prop_type_add('sheepTitle', #{highlight: 'SheepTitle'})
  call prop_type_delete('introHL')
  call prop_type_add('introHL', #{highlight: 'introHL'})
  let s:intro = popup_create([
   \   #{text: '   The sheep are out to get you!',
   \     props: [#{col: 4, length: 29, type: 'sheepTitle'}]},
   \   s:NoProp('In the game:'),
   \   #{text: '     h       move cannon left',
   \     props: [#{col: 6, length: 1, type: 'sheepTitle'}]},
    \ " 中略
   \ ], #{
   \   filter: function('s:IntroFilter'),
   \   callback: function('s:IntroClose'),
   \   border: [],
   \   padding: [],
   \   mapping: 0,
   \   drag: 1,
   \   close: 'button',
   \ })
  if has('sound') || len(s:sound_cmd)
    let s:keep_playing = 1
    call s:PlayMusic()
  endif
  call s:IntroHighlight(0)
endfunc
func s:NoProp(text)
  return #{text: a:text, props: []}
endfunc

さてさて.まずは prop_type_add( {name} , {props}):

  • {name} という text property type を作る.
  • {props}
    • bufnr (バッファローカルに作りたいとき)
    • highlight,
    • priority: その文字に複数の text property が当てられたときにどれが優先されるか,
    • combine: highlight の設定とシンタックスハイライトを共存させるかどうか
    • start_incl: 開始位置が含まれるかどうか
    • end_incl

というわけで,ハイライトだけ定義した(SheepTitle: 太字,introHL: 水色) textprop が作られた.

つぎ popup_create( {what}, {options}).

  • what を表示するポップアップをつくる. what は次のどれか:
    • buffer number
    • 文字列
    • 文字列のリスト
    • a list of text lines with text properties ←コレ!
  • 引数の詳細は込み入っていて別のヘルプにかかれているが,この例では #{text: "text", props: [list] の形式で1行を表現する.各行について,props ではその行のテキストに適応すべきプロパティを以下の要素で指定している(1行に複数プロパティを指定することもできる.
    • col: byte で数えた starting column
    • length: バイトで数えたテキストの長さ
    • type: text property type お名前.
  • さらに第2引数 options は dictionary で,様々なオプションがあるがここでは
    • filter: 打鍵されたキーについて呼ばれる関数
    • callback: ポップアップが閉じるときに呼ばれる関数
    • drag: マウスでドラッグできる(!?)
    • border/padding: css と同じ雰囲気の数値指定.
    • close: "button" では右上のXクリックでポップアップが度汁.ほか click ではどこでもクリックで, none はとくにそういうのなし.
  • buftype="popup" なバッファを一つ作って,window-ID を返す(失敗時は0).

IntroFilter はゲームを始めるところなので後で読みます.IntroCloses:Clear() なので多分読み飛ばします.

func s:PlayMusic()
  if s:keep_playing
    let fname = s:dir .. '/music' .. g:killersheep_sound_ext
    if has('sound')
      let s:music_id = sound_playfile(fname, {x -> s:PlayMusic()})
    elseif len(s:sound_cmd)
      let s:music_job = job_start(s:sound_cmd .. ' ' .. fname)
      " Detecting job exit is a bit slow, use a timer to loop.
      let s:music_timer = timer_start(14100, {x -> s:PlayMusic()})
    endif
  endif
endfunc

素直に音声を再生している(+sound がないときにタイマーでループしてるの悔しい). sound_playfile の第2引数はコールバックで,sound ID と status (0:成功,1:中断,2:再生後にエラー)を受け取る. この場合は単にループ再生なので自身をもう一回呼び出しています.

も一つ

const s:introHL = [[4, 3], [8, 5], [14, 3], [18, 3], [22, 2], [25, 3], [29, 4]]
let s:intro_timer = 0
func s:IntroHighlight(idx)
  let idx = a:idx
  if idx >= len(s:introHL)
    let idx = 0
  endif
  let buf = winbufnr(s:intro)
  call prop_remove(#{type: 'introHL', bufnr: buf}, 1)
  call prop_add(1, s:introHL[idx][0],
   \ #{length: s:introHL[idx][1], bufnr: buf, type: 'introHL'})
  let s:intro_timer = timer_start(300, { -> s:IntroHighlight(idx + 1)})
endfunc

ここで The sheep are out to get you! の cyan を動かしているわけだ.

  • s:introHL にこの文面の単語ごとの開始地点と長さを記録しておいいて,
  • 今の text property での着色を消して,
  • 次の単語を着色して,
  • timer_start で制御し,idx を単語数で wrap しながらループ

結構かんたんじゃん.(もりもり整備されたAPIの音がする…)

続きは明日

advent calendar, なんというか夜ふかしして長い記事を書く趣旨ではないと思うので,起動の準備ができたところで続きは 明日(かもう一寸先か)に読むことにします.