Separation #5

引き続きビューとバッファ、およびコントローラのそれぞれのやりとりについて考える。ちなみにここで言うコントローラとは教科書通りに、ユーザーからの入力を適切にバッファに送信する担当のことだ。ただし今回 wasavi に対して施そうとしている修正の場合バッファはエクステンションのバックエンド側に位置することになるので、それに合わせてコントローラも分割し、コントローラ(フロントエンド)およびコントローラ(バックエンド)を分けて考える必要がある。

まずキー入力からそれが再表示されるまでのサイクルを考えると、

  1. キー入力が行われ、コントローラ(フロントエンド)がそれを受ける
  2. キー情報と、ビューの情報をバックエンドへ送信する
  3. コントローラ(バックエンド)ではキー入力からエディタのどの機能をディスパッチするか判断し、バッファの機能を適宜呼び出す
  4. 再表示用の情報を生成し、フロントエンドへ送信する
  5. ビューがそれを受け取り、再表示する
  6. 再表示が完了したことをイベントとしてバックエンドへ送信する

ということになると思う。それぞれ掘り下げてみたい。

キー入力
キー入力はもちろん keypress や keydown イベントで得たものなのだがここでは qeema によって統合する。すなわち qeema はフロントエンド側に置く必要がある。一方でキー入力が実際の vi コマンドにディスパッチされるまでにはもう一つマップマネージャも経由する必要があるが、こちらはバックエンド側に置く(じゃないとマップ情報をその都度フロントエンドに転送しないといけない)。

ビューの情報
必要ならスクロールさせるために、バッファが参照する最低限のビュー側の情報を通知する必要がある。これは文字通り最低限でなければいけなくて、なぜならキータイプごとにバックエンドに通知されるものだからだ。

  • 見えている領域の先頭行のインデックス
  • 見えている領域の行数
  • 見えている領域に対して最大何行表示できるか、の行数
  • 見えている領域のうち、隠れていない最初の行のオフセット
  • 見えている領域のうち、隠れていない最期の行のオフセット

程度のものでいい。

vi エディタとしての機能
この部分に関しては既存のコードの通り。ただし、新しい再表示のメカニズムに合わせた情報を追加で生成する必要がある箇所はある。

再表示用情報の生成
これがこの修正のミソになる。そしてこの情報も、ビュー側へ送るサイズは最低限のものにする必要がある。ところで再表示用情報と言っているのは当然エディタスクリーンに対してのものなのだけど、それは狭義のものであって、広義で言うとステータスラインの更新とか、コンソールの開閉とか、カーソルの表示非表示とか、そういうものも存在する。従って、広義の再表示情報群の構造は

[
{
type: 'repaint-editor',
...
},
{
type: 'show-cursor',
...
}
]

と言った、再表示命令の配列みたいな形になり、これを受け取ったビューが interpret する流れになる。

再表示用情報の構造
エディタの各機能を実行する前のカーソル位置と、後のカーソル位置とを比較して再表示すべき行を取り出す。

ちなみにそれ以外の副次的な情報も必要で:

{
type: 'repaint-editor',
length: 1000, // バッファの全行数
oldRowIndex: 0, // 直前のカーソル行位置
oldColIndex: 0, // 直前のカーソル桁位置
rowIndex: 1, // 現在のカーソル行位置
colIndex: 0, // 現在のカーソル桁位置
rowDelta: 1, // カーソル周辺から何行増減したか、の行数
updateLInes: {
'100': 'line #100',
'101': 'line #101'
},
updateKeys: [
100, 101
]
}

まあ大体こんな感じになる。いずれにしても、去年考えた通り、再表示にあたって最低限必要な行だけを算出する。もしスクロールが発生して 1 行だけ再表示する必要があれば updateLines は当然ただ 1 行分、そして再表示処理もただ 1 行分の DOM 生成だけ行い、すでに表示済みの行のうち再利用できるものは全て再利用する。また、カーソル移動だけの場合は当然再表示される行はない。

再表示完了イベント
ビュー側で再表示が完了したら、その旨をバックエンドに送信する。これは必要があってそうする。

再表示用情報の生成時にスクロールが発生することが判明したとして、ビューが上へ移動した場合はカーソルはビューの上端、下へ移動した場合はビューの下端に位置するようになる。しかし、vi のスクロールコマンドはそうならないのである。

  • [cci][/cci] 1 画面分ビューが下へ移動する。カーソルはビューの上端
  • [cci][/cci] 1 画面分ビューが上へ移動する。カーソルはビューの上端
  • [cci][/cci] 半画面分ビューが下へ移動する。カーソルは画面上の位置を保持する
  • [cci][/cci] 半画面分ビューが上へ移動する。カーソルは画面上の位置を保持する

しかし、これらの動作は今回導入するバッファでは実装することができない。例えばビューが下に移動しつつ、カーソルはビューの上端に持ってきたいとしても、ビューの上端に位置する行のインデックスをバッファは知らない。

どうするかというと、キー入力から再表示までのサイクルを複数回実行する必要がある。一旦スクロールだけ行い、再表示完了イベントをバッファへ送信する。その時、あらためて最新のビュー情報も送りつける。2 回目のサイクルでカーソルをビュー上端へ置く。2 回目の再表示でカーソルを表示する…という感じ。

そのために、コントローラ(バックエンド)では送りつけられたキー入力 + ビュー情報のオブジェクトをキューとして保持する必要がある(マップの展開により仮想的に複数のキーストロークに展開される可能性を考えるとどのみち必要なのだが)。また、再表示リストの最後にはカーソル表示コマンドを置くわけなのだが、それはキューをすべて処理した場合に限らないと表示がちらついてしまうのでその辺の細かい場合分けもしないといけない。

完全再表示
上記までの再表示の仕組みは、変更前と後の差分だけを取り扱う手法と言えるのだが、vi コマンドの中には差分ではなく完全再表示したほうが良いものもある。[cci][/cci] や [cci]zz[/cci] コマンドなどだ。なので完全再表示については独立した再表示命令としたほうが良いだろう。

* * *

とまあ大体こんな感じだろうか。

Separation #4

ビューに関わらない部分はだいたい書き終わった。従って、そろそろビュークラスのことを考える時期だ。ここで、もちろんビュークラスの内部も重要なのだが、どのように再描画を指示するか、そのプロトコルの形式がとても重要だ。

ビューがバッファの内容を表示する際、まず最も基本的に必要なのは

  • ビューの基点座標。これはピクセルベースかもしれないし、あるいは行・桁ベースかもしれない。また、ビューの左上が原点かもしれないし、それ以外かもしれないし、あるいは複数原点があるかもしれない
  • ビューの幅・高さ。これもピクセルベースかもしれない。いずれにしてもこの情報から派生して、スクリーンに何桁・何文字表示できるかもビューは把握できる

くらいだ。

基本の考え方
何かキー入力がなされ、バッファを更新し、その結果をスクリーンに反映する。これがテキストエディタのライフサイクルなのだけど。この中でスクリーンへの反映を考えるに、まずものすごく原始的には

for (var i = 0; i < SCREEN_LINES; i++) { display_line_of_buffer(view_top + i); }

という感じのコードを毎回呼び出すことになる。しかしこれだと、例えば単に 1 文字書き換えただけとか、あるいはもっといえばカーソルを移動しただけで毎回再描画されてしまうので実用にならない。はしょれるところを最大限はしょる必要がある。

最低限の部分だけを再描画する
再描画コードは、バッファが更新された後に呼ばれる。ということは、その時点でバッファの何行目から何行目が更新されたという情報が揃っているはずなのでそれを利用できるようにしよう:

function repaint (start, end) {
for (var i = 0; i < SCREEN_LINES; i++) { if (view_top + i < start) continue; if (view_top + i > end) continue;
display_line_of_buffer(view_top + i);
}
}

スクロールに備える
再描画は、バッファが更新された結果だけではなく、単にカーソルが移動しただけでも起こりうる。カーソルがビューの領域から出た場合、カーソルの位置から新しいビューの座標を求め、全体をスクロールさせる必要がある。ここでスクロールが再描画処理の中で独立したトピックなのは、大きく再描画をはしょれる要素だからだ。スクリーンに描画済みで、かつ、スクロール後にもスクリーンにとどまっている行は 1 から再描画を行う必要はなくて、単に描画済みの領域をスクリーン上で移動させるだけでいい。

ただし、描画済みの行であっても、上記の更新済み行に含まれる場合はやはり再描画を行わなければならないので、スクロールの処理と上記の repaint() に相当する処理は同一のループで行う必要がある。再描画することでその 1 行の高さが変化する場合があるからだ。

つまり、

  • スクロールが伴わない再描画処理
  • カーソルがビューの上方に逃げたために、下方向へスクロールさせつつ再描画する処理
  • カーソルがビューの下方に逃げたために、上方向へスクロールさせつつ再描画する処理

の 3 パターンをカーソルの位置によって使い分ける必要がある。さらに、このディスパッチは完全にスクロールと再描画する必要がなくなるまでループさせる必要があるだろう。たとえば更新済み行でスクリーン上部のある行が一気に 100 行分の折り返しを伴う再描画されたとする。そうするとスクリーン下部にあるカーソルは当然追い出されてしまうので、その時点で再度スクロールを伴う再描画を行う必要がある。

マークアップ
そういうわけで再描画を完全なブラウザ任せから一歩脱皮しようとしているわけであるが、結局は HTML の要素を用いたマークアップを用いる。ここ、例えば描画を全て canvas 要素に対して行うようにすればかなりブラウザ独立になるのだけど。ただしそうすると IME を通したインライン入力がちょっと面倒になる。それから、スタイリングが CSS でちょいちょいできないのもめんどくさい。

で。特に上方向にスクロールさせる場合、描画の基準となるカーソル位置がビュー下端になって、そこから上方向に行を配置していくことになるのだがそうなるとこの際、行は position:absolute で配置も自前でやってしまったほうがいいかもしれない。

Separation #3

テキストバッファの実装が DOM から文字列の単純な配列になると、基本的には操作も単純になるのだが、そうでない場合もある。DOM の場合、ある 1 行の構造が例えば

some text


などという形になっている。ここで span 要素は何かというと、マークだ。wasavi のマークは vi や vim のそれに比べてほんの少しだけ賢くて、つまり行内の文字の挿入や削除に応じて自動的に位置を調整する。

内部的には、マークを span 要素で表現し、かつテキストを操作する際は div の innerText で一気に書き換えるのではなく NodeIterator でテキストノードを走査し、必要な部分だけを書き換えるという処理を行う。その上で操作後に span 要素の新しいオフセット(つまり span より若い位置にあるテキストノードの文字数)を算出しなおし、それをマークの新しい桁位置にする…というやりかたになっている。

テキストの保持の仕方が単純な文字列になると、このやり方でマーク位置を追跡することはできなくなる。現状では Buffer クラスはマークのことは知らない。ただ DOM レベルでの要素の並びの通りに内容を編集しているだけだ。これを、Buffer がマークのことをよく知っているように変更する必要があるかもしれない。それと、バッファの編集操作の中で shift() だけはマークの仕様を知っている必要があったので、このメソッドは大掛かりに変更する必要がある。

Separation #2

ぼちぼち書いている。テキストを HTML ドキュメントではなく、単純に String[] に保持させる。面倒なのは、従来の Buffer クラスはモデルとビューを兼ねていたので Buffer クラスが getClosestOffsetToPixels() 等々ビジュアルな要素に係る機能を持ってたりするのだが、それを分離する必要があったりする。

それから、今までテストは Selenium を用いた機能テストが主で、実はユニットテストはあんまりしていなかったのだが(UAX #14 に関してだけ qunit でテストできるようにしていた)、nodeunit で大掛かりにテストするようにしてみた。

Separation

そろそろ、モデルとビューの分離について考えたい。

モデルはここではデータ構造、つまりテキストを保持するバッファで、ビューはそれを表示する仕組みだ。現状は、それらは wasavi の iframe が保持する html ドキュメントそのものになっているのだが、DOM ベースのドキュメントをテキストエディタのバッファ代わりにした時のメモリ効率はたぶんあんまりあるいは全然良くない(ブラウザの実装によるかもしれない)。

それから、ビュー側でそのレンダリング処理をブラウザ任せにしている。これはこれで楽といえば楽なのだが、無駄だ。ブラウザのレンダリングって要するにページ全体のビットマップイメージを保持してるわけで、1万行とかあるテキストの編集でそれを行うのはとっても無駄だ。見えてる部分だけレンダリングすればよいのだ。

そういうわけでモデルとビューを分離した上で、それぞれを効率のよい実装のものに置き換える必要がある。

Handling Unicode #11

様々な箇所で必要なら Unistring を使用するようにする修正が完了しつつある。実際には Buffer クラスの他、様々な箇所で Position クラスの col プロパティを直接インクリメント・デクリメントしており、それを修正していくことになる。

このアプローチはつまり、基本的には文字列が UTF-16 のシーケンスであることを意識した上で、各箇所で論理的な文字単位と UTF-16 のインデックスとを相互変換するということで、割と煩雑だ。

一方、異なるアプローチも考えられる。文字のインデックスは常に論理的な文字単位をベースにし、Buffer クラス内でレンダリングする際に Unistring を使う。おそらくは、こちらが正しい。ただ現在はレンダリングはブラウザ任せなので、やりたいけどそうはいかない。これは将来の課題だ。

ところで javascript で構築した vi という点でいろいろな人が作った諸々を見てみると、おそらくサロゲートペアと結合文字列を意識した動作をするものはない。たとえば CodeMirror の vim バインディングはなかなか良く出来ているが、上記のトピックを正しく処理しない。その点で wasavi のアドバンテージが 1 つ増えたわけで、これは誇っていいと思う。

Handling Unicode #10

他のモーションも修正する。

  • [cci]|[/cci] このコマンドは、与えられたカウントを桁数とみなしてカーソルをそこへ移動させる。従来はカウンタを単純に UTF-16 シーケンスのインデックスとして使用していたが、charWidth 変数(文字の平均幅をピクセル単位で表す)×カウンタの位置に最も近い書記素クラスタのインデックスを算出するようにした。
  • [cci]ga[/cci] このコマンドは、カーソル位置の文字のコードポイントを表示する。書記素クラスタ全体を対象とするように修正。
  • [cci]x/X[/cci] このコマンドは、カーソル位置の前方、あるいは後方に向かってカウント分文字を削除する。これを書記素クラスタ単位で削除するように修正。ただ、後方削除が実際にどういった削除を行うかは方法が 2 通り考えられる:
    • 一度に書記素クラスタ全体を削除する – vim など
    • 基底文字、結合文字単位で削除する – Chrome や Firefox の textarea など

    とりあえず vim に合わせてみる。

その他、モーション以外に編集コマンド、bound モード、line_input モードが残っている。なかなか先は長い。

Handling Unicode #9

[cci]motionUpDown[/cci]。

テキストエディタはカーソルの行位置・桁位置を管理保持する。このとき桁位置に関して、メモ帳のようなシンプルなエディタ以外はたいてい、カーソルの本来の桁位置とは別に架空の桁位置を持っている。この架空の桁位置は、カーソルが水平移動した時にカーソルの本来の桁位置に同期する。カーソルが垂直移動した時は変更されず、かつカーソルの桁位置は架空の桁位置に最も近い位置に算出される。

これで何が良くなるのかというと:

aaaaaaaaaaa

bbbbbbbbbbb

なんてテキストの先頭行の最終桁にカーソルがあった時、最終行の最終桁近辺を編集したくなったとする。そこで、下矢印キーを 2 回押すわけだが。架空の桁位置がないと中間の行でカーソル位置が行頭に固定されてしまい、最終行に達した時にはわざわざ最終桁へ移動させる手間が増えてしまう。架空の桁位置の仕組みがあると、最終行にカーソルが移動した時その桁位置は架空の桁位置に最も近い位置に再生されて、無駄なカーソル移動をしなくて済むというわけだ。このとき、絶対に等幅のフォントでしか描画しない!というわけでなければ、架空の桁位置はピクセル単位で保持することになる。そういうわけで、「あるピクセル位置に最も近い、文字列上の桁位置」というのを算出する処理が必要になる。

処理の内容は、素朴に考えれば、文字列の先頭から1文字ずつ切り出すループを設け、その部分文字列の offsetWidth を出し、それが基準ピクセル位置を超えていたならば、超える直前の offsetWidth と比較して距離が近い方を採用し、それに対応するループカウンタが結果の桁位置になる……という感じになる。

しかしこのまま組むと結構遅い。offsetWidth というのはそんなに軽くないメソッドなので、100 文字あったら 100 回 offsetWidth を呼ぶというのは避けなければならない。

で、実は [cci]motionUpDown()[/cci] はすでにそうなっていて(offsetWidth の呼び出しが算出される桁位置の log2 〜 2log2 で収まるようになっている)、それを Unistring を使うように修正する必要があり、そうした。

この修正は、折り返し行単位でのカーソルの上下移動とも関わるのでこれで終わりではない。

* * *

そういうわけで、その辺の諸々を更新。