visual in vi #2

まずモード名だが、vim のように visual モードではなく、bound モードと呼ぶことにしたい。もしかしたら region モードにするかもしれない。

これを実装するにあたって、内部的には、本質的には単に選択領域の 2 つの端点だけを管理するだけの話だ。bound モードではカーソルが行末の改行にも乗れるようにする、とかカーソル(選択の終了点)が選択の開始点の前へ行った時のつじつま合わせとか、細々としたものはあるけれど、内部的には結局は 2 つの端点だ。

一方で、vim で visual モードというだけあって、見た目の部分が重要だ。つまり選択中の領域を反転表示させないといけない。DOM でこれをどう実現するか?

まずブラウザが持つ標準の範囲選択の仕組みをブラウザから制御するために selection がある。これだとまさに選択領域の両端点を指定するだけでいい。

var r = window.getSelection().getRangeAt(0);
r.setStart(text_node_of_lower_side, column_of_its_index);
r.setEnd(text_node_of_higher_side, column_of_its_index);

ただ、それがどう表示されるかは全くブラウザ任せになる。これは実際楽なのだけど、wasavi 側のテーマによっては判別しづらいものになるかもしれない。

次に DOM レベルで選択範囲を span でくくりだす方法もある。こちらは選択領域の更新を自前でやらないければいけないのがネックになる。一番簡単なのは、更新前に一旦すべての選択領域をクリアし(つまり span でくくりだされた部分を脱皮させる)、再度選択範囲をくくりだすのである。これだと特に選択範囲が何十行にも及ぶ場合に明らかに遅くなりそうではあるので、実際はそのへんは差分で管理しないといけないはずだ。

そういうわけでこんなかんじになる。いたってふつー。

bounding

undo boundary #2

単純に undoboundlen によった文字数で判断すると、ラテン文字な文章では問題ないのだが東アジアなそれでなんか微妙だ。

つまり、最終的には表音文字と表語文字の性格の違いということになると思うのだけど、同じ 20 字分でも密度が違うのである。例えば日本語文とかのほうが、英文に比べて同じ文字数での情報量がずっと多い、気がする。というのも、英文が 20 文字ごとに undo されていく様子は「あー、そうそうだいたいそのくらいだよね!」と感じるのだが漢字かな交じりの文だと「えっそんなに undo しちゃうの? 多くね?」となるのだ。

というわけで、undoboundlen をもとにした表示上の幅で判断するようにした。つまりフォントに影響を受けるのだけど、日本語だとだいたい 12 文字前後で区切られるようになる。

こんなもんかなー!

undo boundary

wasavi へ新しい機能を追加するにあたって、これまではほぼ 100%、vim の機能を持ってくるというものだった。しかし今回追加する機能は、なんと emacs 由来のものである。

input モードに入ってだーっとテキストを入力し、esc を押す。すると、undo の単位は入力したテキスト全体になる。従って、たとえば入力した後「やっぱりテキストの半分は書き直したい」というケースでは、undo は役に立たない。

emacs は、連続して文字を入力している間であっても、(確か)20 文字ごとに関数 undo-boundary を呼び出す。この関数は undo リストに「区切り」を挿入する。すると、undo は連続して入力した文字列全体ではなく、その区切りで区切られた断片的なテキストの単位で行われる。賢い。実は vim にも似たものはあるのだが、手作業で [cci]^G u[/cci] を押させるのである。自動ではない。きっと vimscript でどうにでもなるのだとは思うけど、デフォルトの仕様はあまり賢いとは言えない。

そんなこんなで、wasavi に実装する機能は以下のとおりである:

  • オプション undoboundlen を導入する。これは整数を保持する
  • オプション undobound を導入する。これはundo の区切りとみなすことのできる文字列群を保持する。例えば [cci] ,.!?、。,.!?[/cci]
  • input モードで 1 文字入力するごとに次の処理を行う: 入力中の文字列が undoboundlen を超えていて、かつ最後に入力した文字が undobound に含まれる時、undo リストに区切りを挿入する

* * *

undobound として区切り文字を指定するのではなく、unicode の一般カテゴリが Zs であるか、プロパティが STerm であるか、または Terminal_Punctuation である文字とするようにした。

visual in vi

ついに恐れていた要望が訪れた。

issue #19。つまり、vim の visual モードがほしいというものだ。

visual モードというのは、一般的な GUI が持つテキスト選択の仕組みに似ていて、編集中のテキストの任意の選択範囲を指定するのに用いられるのだが、最大の特徴はその範囲が視覚的に明確に示されるという点だ。visual モードを開始した位置と、現在のカーソルとの間が選択範囲となり、それが例えば反転表示とかされるのである。カーソル移動のために visual モードでは command モードと共通のモーションを使用できるが、オペレーションに関しては visual モード専用の処理が実行される。

何を恐れているかといえば、まず visual モードというネーミングだ。そもそも、vi 自体が ed とか ex とかいったエディタの「visual モード」なのである。完全に被っている。次にそれが必要な決定的な理由がない(と思われる)のも微妙だ。visual モードがなかったとしても編集には基本的には全く問題ないのだ。

しかしまあこれがほしいという要望は多分来るだろうと思っていたし、実際来てしまったので作るわけなのだ。たぶんモード名は変えると思う。

expandtab #5

input モード中に backspace や ^H を押した際、カーソル位置より左にある直近のタブ位置からカーソル位置までの文字列がすべて U+0020 で構成されている場合、それらをまとめて削除するようにした。

これは expandtab の状態でタブの代わりに空白の塊が挿入された場合に、それをやはり塊として削除できるようになるので、expandtab によるタブ変換と対になっている機能といえる。この機能は expandtab の状態にかかわりなく、常に有効である(vim がそのようなので)。

ただ塊ではなく 1 つの空白だけを削除したいケースもあるはずで、そういうときは恐ろしくおせっかいな機能と化してしまう。なにか代替となる操作も用意した方がいいかもしれない。まあその辺を実際にどうするかはユーザのレスポンス次第である。

なにはともあれ、とりあえず expandtab 絡みの実装はこれで一段落ということにする。

expandtab #4

言うまでもなく vi はモード志向の強いテキストエディタだ。たとえば vi で文字を入力するにはまず input モードに移行する必要があるのだけど、これは総称であって、実際には i、a、o とかを押して入る insert モードと、それから R を押して入る overwrite モードがある。

この overwrite モード、かなり奇妙である。overwrite モードのカーソルが何かの文字の上にあるときに文字を入力すると、それはカーソルの下の文字を上書きする。ただし、改行の上にあるときは改行の前に挿入される。一方 enter を押した場合は常に改行が挿入される。その他にもちょっとした制限があったりなかったりする。

はっきり言ってこのモード、普通にテキストを編集するのにあたっては使うことはまったくないと言っていい。しかし作ってしまったので、expandtab 絡みの動きも overwrite を考慮しないといけないのだった。これが実に面倒くさい。また、あろうことかどうも vim では expandtab+overwrite でタブを入力した時のタブ展開の動きが、expandtab+insert の場合のそれとなんか微妙に違うのである。これ、考えぬいた仕様というよりは、overwrite なんて誰も使ってないのでテストも比較もいまいちされていないのではないかという思いが脳裏をかすめてならない。

それにしても今考えると overwrite モードは作らなくても良かったなーと思うことしきりである。もし wasavi がそれを備えてなかったとしても「なんで overwrite モードねーんだよこのハゲ!」と怒る vi guy は地球上にいないだろう。たぶん Bill Joy さんも許してくれると思う。

expandtab #3

vim が持つ expandtab 絡みのさまざまなオプションであるが、とりあえず wasavi には expandtab オプション以外は実装しない。つまり smarttab と softtabstop は実装しない(tabstop、shiftwidth はすでに実装してある)。

で、実際に input モードで tab キーを押した際になにが起こるのかをまとめると:

  1. タブ(U+0009)をカーソル位置に挿入する
  2. 行頭のインデント中のタブを一旦すべて空白に置き換える
  3. 行頭のインデント中のマークをすべて退避する
  4. expandtab がオンなら、何もしない。オフなら、行頭のインデントを先頭から走査して、タブに置き換えられる箇所を置き換える。つまり tabstop の字数ぶん連続した U+0020 を U+0009 に置き換える。その過程で、退避したマークのオフセットのつじつま合わせも行う
  5. 退避したマークを再設定する

という感じになる。実はこの処理の大部分は、シフトコマンドのそれである。なのでまずその辺を詰めることになる。例えば tab キーを押した際の処理は左や右へシフトを行うわけではない。いわば 0 カウントだけシフトすることになる。という訳でそんな感じのいろいろな修正を施した。

expandtab #2

もう少し整理してみる。

vim で input モードで tab キーを押した時に、まず以下のオプションが参照される:

  • tabstop
  • expandtab

tab とスペースの変換という点で見ると、この 2 つがコアになる。tabstop は tab が最大何文字幅分に伸びるかを指定する。expandtab は、tab キーを押した際に対応する文字数分の空白に置き換えるか、および行頭インデントの最小化(つまり、空白を可能ならタブに置き換える処理)をスキップするかを指定する。

一方で、本質的ではない以下のオプションも参照される:

  • softtabstop
  • shiftwidth
  • smarttab

softtabstop は、「タブ幅は標準の 8 桁から変えたくないけど、インデントの単位は 8 じゃない数値にしたい!!」という向きに応えるためにある。タブ幅自体を変えても気にしないなら、このオプションを弄る必要はない。

shiftwidth は、本来は、< と > コマンドによってシフトする際のシフト量を指定するためのものだが、smarttab の状態によっては tab キー押下時に参照されるときがある。

smarttab は、インデント量をカーソルの位置に応じた文脈で選択させるようにする。smarttab がオンの状態で、カーソルが行頭のインデント領域の中にある時、shiftwidth の分だけインデントされる。smtarttab がオフであるか、あるいはオンであってもカーソルが行頭インデントではない箇所にある場合は、softtabstop、tabstop の順に評価して 0 ではない最初の値を取り出し、それをインデント量とする。

とこのように、expandtab 自体は単純明快なのだけど。字下げスタイルという個々人の好みにものすごく左右されるものにできるだけ対応するための、付加的なオプションのおかげで全体的になんだかわよくわからないものになってしまっている。

さらに cpoptions オプションの設定によって微妙に部分的な動作が変わったり、あるいは前の記事の通り backspace オプションが絡んできたり、:retab コマンドの動作にも影響したりする。カオスすぎる。

expandtab

issue #16 の、vim の expandtab を実装してよというリクエスト。

expandtab がオンの状態だと、input モードで tab を押した際、タブそのものではなくタブ幅に応じた適当な個数の空白が挿入される。また、> や < でシフトした際、通常はタブで置き換えられるなら置き換える(つまり ts=8 で sw=4 の状態で 2 回シフトすると、インデントは 1 個のタブになる)のだが、その処理を迂回して常に空白でインデントするようになる。

他にも影響があるところがあるかも知れない。実は個人的にこのオプション使ったことがないので、よくわかっていないのだった。インデントを常に空白で行うというのはなんというか富豪的である。貧乏人は普通にタブを使うのだ。

また、間接的に backspace オプション、retab コマンドとも関連する。これらとセットじゃないとやはり実用上は辛いかもしれない。というわけでフルに実装するとけっこうかなり面倒な代物なのだった。

ということを issues やりとりしたら、とりあえず単にインデントを空白で行う処理だけでもいいんじゃね? みたいな話でまとまったので、そういう方向でやってみよう。