The Traditional vi

issue #113

これはいわゆる vim の compatible mode を実装してくれという要望なのだが、あれを実装するのは大変そうだ。具体的にはテストが大変だ。様々なオプションのオン・オフの組み合わせを網羅するとテスト項目が半端ないものになってしまう。

件の issue の方は最低でも traditional な vi の uu だけでもいいということなのでそういう方向で行ってみたい。

vi の uu というのは、u で一旦 undo した後、続けて u を押すと今度は redo 動作になる、という不思議な動作のことだ。この動作のことを一般に何と言うのかわからないが、とりあえず flipping undo と呼んでおこう。

これをどうやって実装するかは、素直に boolean なフラグを保持しておいて(以下 flipped)、u が押された時にそれを元に動作を振り分けということになるだろう。

いくつか気になる点:

  • flipping undo とふつーの undo をどう切り替えるか? vim では

    :set compatible
    :set cpoptions+=u

    とすることで flipping undo モードになる。しかし前述の通り [cci]compatible[/cci] や [cci]cpoptions[/cci] の類はあんまり実装したくない。ちなみに vim では、flipping undo モードにするための方法はもう一つあり、それはつまり [cci]undolevels[/cci] を 0 にするというものだ。じゃあこれでいいかな。
  • wasavi では vim と同様、[cci]^R[/cci] に redo を割り当てている。flipping undo の場合これは何をするべきなのか? vim の場合は、どうも flipped 変数を変更しない undo として振る舞うようだ。つまり flipping undo 時の u コマンドは

    if (flipped)
    redo();
    else
    undo();
    flipped = !flipped;

    という動作になる。[cci]^R[/cci] もほぼ同じなのだけど、最後の flipped 変数の変更だけは行わない。

Two of three

issue をちまちま片付けていたのだがなぜが次々と新しい issue が上がってくる。なにこれ…と思ったら、3 日に Hacker News で wasavi が取り上げられていたようだ。

前にも書いたが、書いたプログラムが Hacker News と Reddit と Slashdot で取り上げられたらまあある程度浸透したのかもしれないと勝手に思っているのである。Reddit にはすでに取り上げられていて、今回は Hacker News なので 2/3 のスコアになった。

残りは Slashdot なのだが。しかし最近の Slashdot はなんか知らないがとても gdgd な状況になっているようなので、まあいいかなという気分になりつつある。

Access local files from wasavi #4

Chromeでローカルファイルの読み書きの目処が立ったということで、Firefox にも同じ機能を実装してみる。Firefox の拡張からローカルファイルを操作するための API はいくつかあるが、新規に実装するということで最もナウい OS.File を使う。どれくらいナウいのか、なんと言っても完全に Promise と融合したインターフェースになっていると言うことだろう。これは今風だ。ナウいぜイマいぜ超マブいぜ。

ちなみに Presto Opera に対しては同じ機能を実装することはできない。Presto Opera 上の wasavi で file: スキームのファイルの読み書きを試そうとしても必ずエラーになる。前の記事の通り、Opera Unite がポシャらなければそれを経由して何とかなったんだけど……。

もう一つちなむと、かねてから記事にしている通りビューとモデルの分離という大きな作業も並行して行っている。そして、それが完了したバージョンから、wasavi はもはや Presto Opera 向けにはリリースしない。

* * *

実装した。

Chrome と Firefox でローカルファイルの読み書きで最も違うのは、後者はローカルファイルシステムそのものを取り扱うことができるのに対し、前者は LFO アプリケーションというラッパーを通す際にマウント位置が自由であることで実質的にローカルファイルシステムが仮想的になってしまうということだ。これはファイルシステムのルート以外をマウントした場合に違いがはっきり現れる。

実際のファイルシステム上の /home/akahuku を LFO にマウントする。このとき、wasavi を起動する。wasavi 上のカレントディレクトリは [cci]/[/cci] なので、Chrome の場合は wasavi 上で [cci]:r [/cci] したとき補完されるファイル群はホームディレクトリ直下のファイルだが、Firefox の場合はルート直下のファイルになってしまう。

LFO でどこでもマウントできるという仕様自体に何か問題があるわけではないので、これは運用でカバーすべき問題かもしれない。つまり Chrome 版 wasavi でローカルファイルを扱うための現実的な作業手順というのは、

  • LFO でドライブのルートをマウントする
  • wasavi の exrc で [cci]cd /path/to/home[/cci] する

ということになる。

* * *

ローカルファイルアクセスが可能になることでもう一つ影響を受けるかもしれないものがあって、それは exrc だ。ホームディレクトリに .wasavirc を置いておいたほうが楽な場合は多々あることだろう。ただし、ここでも若干問題があり、Chrome の filesystem では現在のユーザのホームディレクトリがどこかを得る手段がない(そもそも現在のユーザというものが、OS にログインしているアカウントなのか、ブラウザの同期サービスにログインしているアカウントなのか?)

これは、LFO でホームディレクトリがどこかも登録させるようにすべきだろうか?

Access local files from wasavi #3

実際に wasavi 上のファイルシステムの1つとして組み込む。

のだが、その前に、ファイルの読み書きの他にもう一つコマンドが必要だ。それは任意のディレクトリ内のファイル群のリストで、いわゆる ls だ。これは wasavi では tab キーによるファイル名の補完時に呼ばれる。ls 的な処理を書いていて、いくつか奇妙なことに気がついた。

Chrome の filesystem API でそれを行うには、

  1. ルートとなる directoryEntry から、任意のパスへの directoryEntry を得る
  2. 得た directoryEntry の createReader() を呼び出す
  3. 得た DirectoryReader インスタンスの readEntries() を複数回呼び出す。

という処理を行う必要がある。

このとき、1. がなんだか奇妙だ。

rootEntry.getDirectory(
'', // path
{}, // options
function (dir) {
// success callback
},
function (err) {
// error callback
}
);

のうち、path。これは絶対パスか、相対パスを指定すると定義されている。rootEntry の実体が [cci]/home/akahuku[/cci] だったとして、例えばその中の [cci]bin[/cci] ディレクトリの下のファイルリストを取りたい場合、

  • bin
  • ./bin
  • /bin

いずれでも同じ結果が返るはずである。しかし、Chrome の実装では絶対パスを与えるとエラーになる。確かに、そもそもこの文脈での絶対パスというものの定義がよく分からない。結局のところ rootEntry からの相対パスとして扱えばいいのか、実際のファイルシステム上の絶対パスとして扱うべきものなのか W3C の仕様定義には書いてない。うーん?

もうひとつ、fileEntry や directoryEntry の親である fullPath プロパティもなんか変。上記の通り [cci]/home/akahuku[/cci] に対応する directoryEntry の fullPath を参照すると、[cci]/akahuku[/cci] なのである。…なんで? 仕様定義では、このプロパティは

The full absolute path from the root to the entry.

である。つまり [cci]/[/cci] じゃないとおかしい。

このへんの辻褄を何とか合わせる必要がある。

Access local files from wasavi #2

結論から書くと、できそうな感じがする。

Chrome apps から使える filesystem API が提供するメソッドを眺めてみると、

  • getDisplayPath
  • getWritableEntry
  • isWritableEntry
  • chooseEntry
  • restoreEntry
  • isRestorable
  • retainEntry
  • requestFileSystem
  • getVolumeList

という感じ。ところでこれらの API はいわゆる HTML5 のそれを拡張したものなのだが、ふつー filesystem (HTML5) では [cci]requestFileSystem()[/cci] で filesystem オブジェクトのインスタンスを得て [cci]getFile()[/cci] で fileEntry オブジェクトのインスタンスを得て [cci]file()[/cci] で file オブジェクトのインスタンスを得て…それを FileReader や FileWriter で読み書きという感じなのだけど、filesystem (Chrome apps) での [cci]requestFileSystem()[/cci] はキオスクモードという非常に限定されたモードでしか使えない。

どうするかというと、[cci]chooseEntry()[/cci] を呼ぶことでファイル選択ダイアログが開き、その結果 fileEntry オブジェクトインスタンスを得られる。

要するに、ユーザーとのインタラクティブな処理が必ず挟まる。これじゃ、ファイル操作系の ex コマンドと組み合わせられないじゃん! ということだ。

そんなわけで、ダメなのかな? と思いつつ他のメソッドを見てみると、[cci]restoreEntry()[/cci] と [cci]retainEntry()[/cci] というものがある。これは、fileEntry を間接的にキャッシュする。[cci]retainEntry(fileEntry)[/cci] すると何やら独自形式のハッシュ文字列が返ると同時に、Chrome 内部にそれが記憶される。で、[cci]restoreEntry(entryCache)[/cci] すると fileEntry が再生されて得られる。このときはインタラクティブな動作は必要ない。間接的、と書いたのはハッシュ文字列の保存と復帰はアプリケーション側に任されているからだ。

また、[cci]chooseEntry()[/cci] はオプションによってファイルではなくディレクトリを選択させることができる。その場合得られるのは fileEntry を継承した directoryEntry で、これは [cci]getFile()[/cci] メソッドを持っている。

というわけでこれらを組み合わせることでうまく行く。

まず Chrome apps 側のウィンドウに

LFO

こんな感じのシンプルな UI を設けて、ディレクトリを選択させる。これが wasavi 側の仮想的なルートディレクトリになる。

ディレクトリを得たら、[cci]retainEntry()[/cci] を通して得たハッシュ文字列を [cci]chrome.storage.local.set()[/cci] で憶えておく。

次に、background.js で [cci]chrome.runtime.onMessageExternal[/cci] イベントをリスンする。リスナー内で [cci]chrome.storage.local.get()[/cci] でハッシュ文字列を得て、[cci]restoreEntry()[/cci] で再生して、それに対して読み書きを行い、結果を呼び出し元に返す。

呼び出し側では、

chrome.runtime.postMessage('[Chrome app の ID]', {
command: 'read',
path: 'path/to/local/file'
},
function (response) {
// response = {
// path: '/absolute/path/to/local/file',
// name: 'file',
// content: '...',
// size: 100, // size in bytes
// lastModified: 00000... // UNIX time in milliseconds
// }
});

という感じで。

いくつか気になるのは、

  • Windows みたいにドライブが複数ある環境ではたぶん [cci]getVolumeList()[/cci] で得られるリストを予め得ておく必要がある、はず
  • このような動作をするアプリケーションは、他のどの extension/application からのメッセージも受け付ける、というわけにはいかない。油断すると即バックドア化してしまう。来たメッセージが確かに wasavi からのものか、確実にチェックする必要がある
  • ファイルシステムのルート以外を wasavi 側の仮想ルートに割り当てると、双方で絶対パスの位置が異なってしまい面倒そう
  • 他のブラウザでどうするか? さすがに Presto Opera は無理だが(とは言うものの、実は Presto Opera でも Opera Unite によってローカルファイルシステムへのアクセスはかつてはできた)、Firefox ならこんな面倒なことをしなくてもローカルファイルシステムへのアクセスは拡張から普通にできる。ただし、Firefox の拡張が WebExtensions へ収束する流れを考えると、そういった強い権限を必要とする機能はいずれ拡張には公開されなくなる恐れもある

Access local files from wasavi

issue を少し消化する。その中に、ローカルファイルシステムのサポートというものがあり、ちょっと考えている。ちなみに、この issue を書いた人は Chrome ユーザーだ。

いうまでもなく、Chrome の extension はローカルファイルシステムにアクセスすることはできない。issue の中でこれは使えるのではないか? という API がいくつか挙げられているのだが:

  • https://developer.chrome.com/apps/fileSystem: これは、いわゆる Chrome apps 向けの API だ。Chrome apps は extension と違い、よりローカルのリソースに近い層にアクセスできる API を使用できる。しかし extension からこの API を使うことはできない
  • http://www.html5rocks.com/en/features/file_access: これはローカルではあるが、ただし「サンドボックス」なファイルシステムを操作する API だ。従って既存のファイルを直接いじったりはできない

というわけで、残念ながらすぐに使えるというものはない。

現実的な妥協案としては、wasavi のうち http://wasavi.appsweets.net/ 上での動作モードを分離する、つまり extension としての wasavi、apps としての wasavi を独立してリリースするというものが考えられる。そうすれば apps モードはローカルファイルシステムにアクセスできて、よりローカルなアプリケーションっぽくなる。

この案の弱点としては、

  • 2 つのリリースを考えなければならないのが開発側として面倒
  • 各ページの wasavi はバックグラウンドを通して各種ヒストリやレジスタの内容や exrc その他を共有している。リリースが別になると、その繋がりから抜けることになる。特にレジスタと exrc が別になるのはすごく使い勝手が悪そうだ

もう少し別の角度から何か出てこないだろうか。もしも apps をアプリケーションではなく、ライブラリ的に使うことができればいいのだが。つまりエクステンションから接続を受け、ローカルファイルシステムの読み書きを代行する中間層として動作してくれればいいのである。そんなことが可能だろうか。

まあたぶん、こんな感じで誰でも思いつきそーな使い方は当然できないんだろうけど、一応それを確かめるためにちょっと試してみよう。

support the webapp

Pull Request が来ているのでサクッと片付けようとしたら、片付かなかったという話。

まず webapp 向けのマニフェストを追加して欲しい、というリクエスト

webapp とはなんぞやというのは、google によるドキュメントやそのサンプルW3C によるドキュメントを参照のこと。

で、そのマニフェストというのはつまり、webapp、いわゆる web アプリケーションがあったとして、それをまさにアプリケーションとして扱う場合にそのメタデータ(アイコンとか、エントリポイントとなるパスとか、画面の方向とか、色々)を記述しておくための統一書式に従って記述された json ファイルのことらしい。

Chrome 38+ では、バーガーメニューから例えば「デスクトップに追加…」みたいな項目を選ぶと、現在のページヘのショートカットをデスクトップに保存する。このとき、マニフェストを読み込み、アプリケーション名やタイトルや何やらが自動的に設定される。Android 版の Chrome にも似たようなメニュー項目があり、ホーム画面へ追加することができる。というか、最初 google のドキュメント読んでてタイトルが “for Android” なんてなってるものだから、思わずリクエストをくれた方に「これって Android 向けなの?」とか聞いてしまった。PC 版の Chrome にもあります。

wasavi の場合、textarea に乗せて起動する場合は webapp とは言えないが、http://wasavi.appsweets.net/ をブラウザで開いた場合は自動的に wasavi が起動し、ある程度 standalone なテキストエディタとして使える(ローカルファイルシステムへの読み書きはできず、web 上のストレージが対象になる)。このモードは確かに webapp と見なせて、件のリクエストもこのサイトに置くべきマニフェストを追加するというものだ。

そんなわけで、リクエストを単にマージし、試してみたのだがどういうわけか manifest.json を読んでくれない。何やらエラーになる。え…なんで…というわけで原因を突き止めるのに 1 日かかってしまった。

wasavi が起動する際、上記の通り 2 つのモードがある。textarea が対象となる場合は iframe のドキュメントを操作する。standalone で使用する場合は http://wasavi.appsweets.net/ 上のドキュメントを操作する。さらに iframe の場合はブラウザごとに微妙に分かれるので、計 6 パターン、実質的に 3 パターンがある。

  1. Chrome / textarea: エクステンション内の wasavi.html(web_accessible_resources にリストしてある)をソースとする。内容はそのまま使う
  2. Chrome / standalone: http://wasavi.appsweets.net/ をソースとする。エクステンション内の mock.html の内容で上書きする
  3. Opera / textarea: http://wasavi.appsweets.net/ をソースとする。エクステンション内の mock.html の内容で上書きする
  4. Opera / standalone: http://wasavi.appsweets.net/ をソースとする。エクステンション内の mock.html の内容で上書きする
  5. Firefox / textarea: about:blank をソースとする。エクステンション内の mock.html の内容で上書きする
  6. Firefox / standalone: http://wasavi.appsweets.net/ をソースとする。エクステンション内の mock.html の内容で上書きする

この内ソースを上書きするか否かだが、ソースの内容が元々空だったり(about:blank)、信用できない外部リソース(http://wasavi.appsweets.net/)の場合はエクステンションが用意する DOM の構造で上書きする。Chrome / textarea の場合は自分のリソースをそのまま使うので上書きする必要はない。

この時、上書きする head 要素のマークアップにマニフェストを指定する link 要素を含めているはずなのだが、実際にそれを「デスクトップに保存」すると動的に追加した link 要素ではエラーになってしまうようなのだ。これを突き止めるのに 1 日かかった(自分を擁護すると早い段階で原因の1つにはリストアップしてたのだけど、他の可能性を確認して最後の最後に試してみたのがこれだった)。これはバグなのか仕様なのかよく分からない。

とりあえずのワークアラウンドとしては、http://wasavi.appsweets.net/ が返す内容の head 要素にもマニフェストへのリンクを含めるようにし(これ自体は全くおかしくない)、standalone のときは head 要素の内容については上書きをせずそのまま使う、ということになる。

ここで1つ問題がある。そもそもなぜ上書きするのかといえば、上にも書いた通り信用できない外部リソースだからだ。http://wasavi.appsweets.net/ は単にプレースホルダドメインとしての役割以上の何物でもない。これを翻して、head 要素の内容をそのまま使うとすると、せめて http ではなく https にしないとまったく割が合わないのである。

でもねえ https ってけっこう維持費かかるのよねぇ〜と思っていたのだが、ぐぐってみると最近はかなり低コストで、というか無料で https 化できたりできなかったりするようだ。そのへんも含めて色々変える必要がある。

とこういうわけで 1 つの Pull Request にまったく四苦八苦しているのであった。

Separation #6

DOMの構造について。

1
a text


こんな感じにして、floaterは[cci]position:relative[/cci]にする。これは、カーソルが位置する基準がビューの上端か下端かのふた通りある仕様への対応。

各行はfloater内に生成し、それは行番号とテキスト本体を含む。従来は行番号はCSSによるナンバリングとbefore擬似要素で実装していたのだが、単純な要素にする。行要素自体(lineクラスのdiv)は単純に[cci]position:static[/cci]。行番号とテキストとを横並びにするのはCSS flex。

そろそろ、実際にwasaviのコードをいじり始めたい。まずビューとモデルを分離し、その完了後モデルをバックエンドへ移動させる。

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 で配置も自前でやってしまったほうがいいかもしれない。