vi flavored browser UX #2

地道に試しているのだが、インストールしたらページが hjkl でスクロールできたよやったねたえちゃん! という観点で見ると違いはそれほどわからない。なんとか違いを探してみると:

開発の活発さ
Vichrome と Hometype は github 上のコードの最終更新が数年前と、すでに中の人がやる気をなくしている感がある。それ以外は活発なようだ。

vi/vim の機能の理解
ほぼすべてのエクステンションにおいて gt/gT で次のタブ・前のタブに切り替えることができるが、この時カウントを前置できる・できないエクステンションがある。Vichrome はできない(というよりカウントの前置という概念がないように思える)。Hometype はキーボードからのアクティブタブの移動という機能自体がない。見た目のセンスはいいと思うけど、全般的に機能が足りない感じがする。

一方で機能の豊富さで言うと vrome が優れているように思える。ただあんまり機能が多くても全部使うわけでもないので、個人的には vimium くらいの規模が妥当かなという感じ。

コンテントスクリプトのサイズ
フロントエンド側の機能を提供するコンテントスクリプトはすべてのタブのみならずすべてのサブフレームでも読み込まれるので、できるだけコンパクトな方が良く、jquery に代表される汎用的なライブラリ等には頼っていないほうが好ましい。そうじゃないとなんちゃらまとめサイトみたいな iframe 広告だらけのページを開いた時に悲惨なことになってしまう。

Vimium、Vrome、cVim はそのようなライブラリに頼っていない。特に cVim は機能の大多数をページに差し込む iframe 内に置いているようでコンテントスクリプトは非常にコンパクトであり、アーキテクチャは最も洗練されているように思える。

まとめ
後発なだけに cVim が優れている感じだが、Vimium や vrome も悪くはない。Vichrome と Hometype は今のところ一休み状態かなーという感じ。Vichrome は [cci]/[/cci] [cci]?[/cci] コマンドなどで日本語対応というのが売りらしいので、そういう方向ではいいのかも。といいつつ今 [cci]?[/cci] を押して何も入力しないまま enter 押したら Chrome ごと固まっちゃったが……。

vi flavored browser UX

テストのために VimFx を入れたり外してたりしたわけだが、普段はこの手の、ブラウザのインターフェースを vi 風味にする拡張というものを使っていない。正確には、Keysnail によって最低限の vi っぽいショートカット(hjlk とかその程度)を付け足してはいるが、その程度だ。hit-a-hint などのナウい機能は使わなくても何とかなっている。

これはなぜかと改めて振り返ってみると:

  • トラックポイント付きのキーボードを常用しているので、そもそもポインティングデバイスとキーボードの併用にストレスがあまりない
  • wasavi の動作テストをするにあたって、特定の拡張の影響を排除したい
  • この手の拡張を入れると、web ページが個別に定義するキーボードショートカットと拡張自身のショートカットのどちらを優先するかという問題が必ずつきまとう。この手の問題に煩わされたくない

ということだろうと思う。

そういうわけで個人的な要求としては、「ブラウザのナビゲーションにおいて vi っぽいショートカットは多少はあれば嬉しいけど、おおがかりに vi っぽくはしなくていいし、してほしくない」ということなのである。現在常用している Firefox においては、それは Keysnail で自分でちまちまとスクリプトを書けば対応できる。一方で Chrome はどうだろうか。Chrome も常用とはいかないが、それなりに使っている。

まず vi flavored なエクステンションはどうだろうか。Chrome におけるその手のエクステンションを列挙してみると:

…なんでこんなにあるんだ?

Compatibility to VimFx #3

色々やりとりしてみた所、何やら思いがけない方向に進んだ。

まず、wasavi 側で VimFx を意識した処理をする必要はなくなった。逆に、VimFx 側で wasavi の iframe を contenteditable な要素として扱ってもらえるようになった。のだが、途中から wasavi が起動中に blur させる方法がないのが問題、という話になり、正直なところへぁ? なんのこと? という気分に。

どうも VimFx の作法として、編集可能な要素がフォーカスを持っている場合は VimFx は自身のキーバインドを使用しないというものがあるようだ(逆に言うと、編集可能な要素の振る舞いを上書くことは VimFx ではできない)。ただし、編集可能な要素上で esc キーを押すとフォーカスを外す、つまり VimFx のキーバインドが再度有効になるようにはなっている。したがって、例えば通常の textarea で編集中に他のタブに切り替え、必要なものをコピー、戻って貼り付けなどという一連の動作をすべてキーボードで行うには、

  • textarea 上で esc
  • gt (次のタブへ)
  • yf 等々で必要なものをコピー
  • gT (前のタブへ)
  • gi (先頭、あるいは最後にフォーカスした編集可能要素に再度フォーカス)
  • wasavi 上で [cci]”*p[/cci]

というようなキーボード操作になる。で、これを可能とするために、wasavi も esc で blur して欲しいということのようだ。

というわけで、そういう風にした。ただ、この常にこの仕様で動作させて良いのか? という懸念はある。

  • vi 使いの中にはノーマルモードに戻るために esc を連打する人もいる
  • そうでなくても、うっかりノーマルモード上で esc を押してしまうことは有り得る
  • あくまで VimFx の作法である。VimFx がインストールされていれば、wasavi がフォーカスを失っても即 [cci]gi[/cci] で戻すことはできるが、インストールされていなければポインティングデバイスで wasavi をクリックし直すしかない

なので、新しいオプション [cci]esctoblur[/cci] を導入し、これがオンの場合のみに blur 処理を行うようにした。

Compatibility to VimFx #2

動作するようにはなったのだが、やはりイベントハンドラの実引数として渡された何かを取っておいてハンドラのスコープの外で使うというやり方はちょっと嫌な感じではある。

VimFx の issue に何か参考になるものがないのかなと見てみたら、丁度よく wasavi が動かない、正確には Pterosaur や CodeMirror や wasavi 等々、自前で色々キー入力を消費するタイプの拡張と競合する、というが上がっていたので乗っかってみよう。

Compatibility to VimFx

Firefox の拡張には、キーボード周りを含めたブラウザのインターフェースを「強力に」書き換える能力があるわけなのだが、それらと wasavi の相性は一般的によくない。wasavi もまたすべてのキーボード入力を自分で消費するからだ。すると、あるキー入力が他の拡張に横取りされてしまうとか、wasavi と同時に処理されてしまうと言った不具合が起きる。

それを避けるために、VimperatorKeysnail 用のプラグインスクリプトを用意してある。これらは主に 2 つの仕事を受け持つ:

  1. タブの切り替えを監視し、アクティブなタブのドキュメント上に実行中の wasavi が存在するかどうかで対象の拡張を一時的にサスペンドさせるかを制御する(ちなみにこの辺の処理は e10s が有効だと完璧に破綻する: プラグインは Chrome 権限上で動いている、そして content を直接触るためだ)
  2. ドキュメントのカスタムイベント [cci]WasaviStarted[/cci] [cci]WasaviTerminated[/cci] を監視し、各イベントに応じて適宜対象の拡張を一時的にサスペンドさせるかを制御する

ところで、こういったインターフェースを再定義する拡張はもちろんこれだけではない。issue では VimFx との相性がよくないというものが上がっているので、とりあえずそれに対応してみることにしよう。

* * *

VimFx というのはなんぞやというと、実はあまり知らないのだが、本質的には Vimperator と同様に Firefox のインターフェースを vim っぽくする系の拡張である。ただし、Vimperator がインターフェースを「ドラスティックに」変えるのに対して VimFx はもうちょっと大人しいようだ。

プラグインの機構はない。ただし、Chrome 権限のスクリプトから使用できる API が提供されている。

さて、対象の拡張が変わったとしても、やるべきことは上記の 2 点なのは変わらない。ただし 1 は上記のとおり、e10s を見越して content 内で処理する必要がある。普通の DOM のイベントでタブがアクティブ・非アクティブになったというのを判定するには Page Visibility という割と新し目の API で定義される visibilitychange イベントを使えば良いだろう。一方で、wasavi の状態によって他の拡張にアクセスするのは content ではなくバックエンドの chrome スクリプトでなければならないので、つまり visibilitychange イベントでやることはアクティブなタブだったらバックエンドにメッセージを投げる、ということになる。そんなわけで:


window.addEventListener('visibilitychange', function () {
if (document.hidden) return;
extension.postMessage({
type: 'visibility-change',
wasaviRunning: !!wasaviFrame
})
}, false);

というような感じ。

このメッセージをバックエンドで受け取り、wasavi が起動しているようなら VimFx をサスペンドさせればいいのである。

* * *

というわけで色々作って、VimFx の API を使ってどうこうするところまで来たのだが。

ドキュメントによれば、API はそれを提供するオブジェクトのインスタンスを通して使用する。インスタンスの取得は

let {classes: Cc, interfaces: Ci, utils: Cu} = Components
Cu.import('resource://gre/modules/Services.jsm')
let apiPref = 'extensions.VimFx.api_url'
let apiUrl = Services.prefs.getComplexValue(apiPref, Ci.nsISupportsString).data
Cu.import(apiUrl, {}).getAPI(vimfx => {

// Do things with the `vimfx` object here.

})

こんな感じで、ここで [cci]getAPI[/cci] に渡しているコールバックに渡される仮引数 vimfx がそのインスタンスなのだが。困ったことにその中に VimFx をサスペンドさせるようなそのものズバリの機能はないのである。

というより VimFx 自体にサスペンドという概念がない。その代わり、すべての入力を素通りさせて VimFx は関与しない ignore モードというのがあって、normal モードから ignore モードへ遷移することで結果的にサスペンドが行える。で、このモードを遷移するための [cci]enterMode()[/cci] メソッドはどこに属しているのかというと VimFx のコマンド群のそのハンドラに渡される vim パラメータが持っているらしい。しかしこれを API から直接的に得る手段はない。うーんどうすれば……。

しかし、間接的に得る手段はある。[cci]vimfx.on()[/cci] でいくつかのイベントハンドラを登録することができて、その時も vim パラメータが渡される。これを取っておいて、必要なときに使えばいけるかもしれない。

* * *

というわけで、期待した動きをするようになった。

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 へ収束する流れを考えると、そういった強い権限を必要とする機能はいずれ拡張には公開されなくなる恐れもある