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

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

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万行とかあるテキストの編集でそれを行うのはとっても無駄だ。見えてる部分だけレンダリングすればよいのだ。

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

weblioPane fixed #2

以前 weblioPane の変更版を作ったのだが、それ以来いくつかのリクエストがあって:

  • pdf 上で動作しない(オリジナルはできた)
  • mht 上で動作しない(オリジナルはできた)
  • EPubReader 上で動作しない(オリジナルは不明)

なぜオリジナルの weblioPane で出来ていたことが、変更版ではできなくなっているかというと、これは選択範囲が生成されたことを検知する仕組みを変えたからだ。

オリジナル版では SDK が提供する selection モジュールを使用しているのだが、しかしこのモジュール、e10s を有効にすると動かなくなる。この不具合は Mozilla 内でも認識済みのようなのだが、どういうわけか一向に治る気配がない。

そんなわけで、変更版では selection モジュールではなく PageMod モジュールでコード片を各ページに差し込み、選択範囲が生成されたイベントを拡張側に通知するようにしてある。従って、変更版で動かなくなったページがあるとしたらこの変更点が最も怪しいということになる。