Mar 25, 2012
Fox SplitterのLinuxでの挙動の改善と、Mac OS Xで残された課題
Fox SplitterをWindows以外で使った時に 実にパネェ感じでストレスフルな挙動を示す件について、とりあえずLinuxではちょっとだけ改善できた気がする。
要点を先にまとめておくと、こういうことだ。
- Windows上では以前から、ウィンドウの1つが選択されて最前面に来たら、グループ化された他のウィンドウもそれによって「押し上げられ」て最前面に来る、という挙動になっていた。
- でもFirefoxの仕様上の制限で、LinuxとMac OS Xではそれができていなかった。
- 今回、Linuxではwmctrlを呼び出すことによってWindows上と同じような挙動を再現できるようになった。
- Mac OS Xで同じ事ができるのかどうかは分からないままである。
- JSDeferredはやっぱり素晴らしいです。
以下、背景も含めた詳しい話。
Fox Splitter(旧称:Split Browser/分割ブラウザ)は、最初のバージョンは「Firefoxのウィンドウの中に複数のペインを作る」という物だった。でも実装の仕方があまりに行儀が悪くて、他のアドオンとの互換性の問題をどう頑張っても解決できそうになかったので、諦めてほぼスクラッチで書き直して、1つのウィンドウの中身を分割する代わりに複数のウィンドウを連動させて1つの大きなウィンドウのように扱うという設計に変更して、バージョン2.0としてリリースした。
その時に課題となった事の1つが、Fox Splitterによって「1つのグループ」として管理された「複数のウィンドウ」と、他のアプリケーション(Firefox自身の他のウィンドウもだけど)が重なった時の挙動だった。言葉で説明するより見た方が早い。
こうなってる時に、後ろになってるFirefoxの(グループ化された)ウィンドウのうちの1つにフォーカスすると、
こうなる。
そうならないようにするためには、グループ化されたウィンドウのうちの1つが選択されて最前面に来たら、他のウィンドウもそれによって「押し上げられ」て最前面に来る、という風にするのが手っ取り早い。
どうやって「押し上げ」るのかというと、具体的には、nsIXULWindowインターフェースのzLevel
というプロパティをいじってそのウィンドウを1回最前面表示に切り替えて、すぐに通常表示に戻す、という事をやるようにした。window.openDialog()
の引数でalwaysRaised
が指定されなかったウィンドウでも、この方法で後から最前面表示に切り替えられるというわけ。
ということで課題は解決されたものと思ってたんだけど、LinuxとMac OS Xでは全然解決されていなかった。これらのプラットフォームではnsIXULWindowインターフェースのzLevel
が実装されていなくて、いじっても効果が無かった。これらの環境のウィンドウマネージャにはnsIXULWindowインターフェースが想定しているタイプのウィンドウの前後関係を管理する仕組みが無いとかで、完全な制御ができないからそれだったらなんもしないでおこう、みたいな判断があったみたいだ。
なので仕方なく、これらのプラットフォームではウィンドウにフォーカスを与えてすぐにフォーカスを外す(ただし、window.focus()
を使うとそのウィンドウの中のフォーカスが失われてしまうので、それを回避するような対策を打った上で)という代替手段を使う事にした。前述のスクリーンショットのような状態が発生するよりはマシだろ、と思って。
でも実際その状態で使ってみると「これはダメだ……」と強く感じた。僕は自宅はWindowsだけど会社ではLinux(Ubuntu)を常用していて、そのUbuntuで試してみた所、何か操作する度にフォーカスを奪われるというのは物凄いストレスになるということが身に染みて分かった。それで、場当たり的に、上記の挙動自体を抑制できる(他のウィンドウとグループ化されたウィンドウの重なり順がおかしくなっても気にしない)ような隠し設定を加えてみた。
その後、最近になって業務でFox Splitterを使いたい場面が出てきて(納品物としてじゃなくて、自分が業務を進めるにあたって作業環境を整えるためにという意味で)、作って以降多分今一番自分でまともにLinux上でFox Splitterを使ってるんだけど、やはりウィンドウの重なり順が変になるのも地味にストレスになったので、Linuxでもフォーカス移動に頼らずにウィンドウを最前面に押し上げるもっとマシな方法は無いものかと改めて検討していた。
それで目を付けたのが、wmctrlだった。wmctrlはコマンドラインでの指定でGUIのウィンドウを操作する物で、Ubuntuでもsudo apt-get install wmctrlなどとすればパッケージからインストールできる。FirefoxからnsIProcess経由でこれを叩いてやれば、フォーカス状態に影響を与えないでウィンドウの重なり順だけを変えられるという案配だ。Firefoxだけで完結しないで依存ライブラリとしてwmctrlが必要になってしまうけれども、Linux使ってるような人ならまあそれくらい(メッセージを表示するなりなんなりして案内しておけば)自分でできるでしょって事で、思い切って使ってみることにした。
まず、wmctrlに限らず一般的にコマンドを実行するための処理を書いた。
run : function wmctrl_run(aExecutable)
{
var deferred = new Deferred();
var args = Array.slice(arguments, 1);
var executable;
try {
if (aExecutable.indexOf('file:') == 0) {
const IOService = Cc['@mozilla.org/network/io-service;1']
.getService(Ci.nsIIOService);
const FileHandler = IOService.getProtocolHandler('file')
.QueryInterface(Ci.nsIFileProtocolHandler);
executable = FileHandler.getFileFromURLSpec(aExecutable);
}
else {
executable = Cc['@mozilla.org/file/local;1']
.createInstance(Ci.nsILocalFile);
executable.initWithPath(aExecutable);
}
}
catch(e) {
Deferred.next(function() {
deferred.fail(new Error(e+'\ninvalid executable: ' + aExecutable));
});
return deferred;
}
if (!executable.exists()) {
Deferred.next(function() {
deferred.fail(new Error('missing executable: ' + aExecutable));
});
return deferred;
}
var process = Cc['@mozilla.org/process/util;1']
.createInstance(Ci.nsIProcess);
process.init(executable);
process.runwAsync(args, args.length, {
observe : function run_observe(aSubject, aTopic, aData)
{
if (aTopic == 'process-finished')
deferred.call();
else
deferred.fail(new Error(aExecutable + ' failed'));
}
});
return deferred;
}
以前何かの理由で他のアプリケーションを起動する処理を書かないといけなくなったことがあって、その時に外部アプリケーションの終了を待ってから次に進むということをやるにあたって色々面倒だったんだけれども、Gecko 2以降ではオブザーバを使って確実に外部アプリケーションの終了を検知できるようになってたみたいだったので、その機能を使う事にした。
あと、nsIProcessは実行したいファイルをフルパスで指定しないといけないんだけれども、各コマンドの位置を割り出すのが面倒だったので、パッケージ内にシェルスクリプトを含めておいて(Fox Splitterの構成ファイルの一部になるので当然パスは分かる)、コマンド実行時はそっちのパスを渡すようにした。内容はこんな感じ。
#!/bin/sh
which wmctrl > $1
ここではwhcihコマンドを叩いてるけど、/usr/bin/whichというフルパスを知らなくても、このシェルスクリプト自体のパスさえ分かっていればnsIProcessでも間接的にwhichを実行できる。あと、この方法を使うと標準出力として返されるコマンドの実行結果を後から利用できるという利点もある(この例でも、実行結果をテンポラリファイルに書き出していて、後からその内容を読み込むことを想定している)。
……という一連の仕組みを使って、まずはwhichコマンドでwmctrlがあるかどうかを調べる。
get path()
{
return prefs.getPref(domain+'wmctrl.path');
},
initPath : function wmctrl_initPath()
{
var deferred = new Deferred();
var self = this;
Deferred.next(function() {
if (self.path) {
deferred.call(self.path);
return;
}
var pathFile = self.createTempFile('wmctrl-path');
return self.run(resolve('bin/which-wmctrl'), pathFile.path)
.next(function() {
var path = textIO.readFrom(pathFile, 'UTF-8');
pathFile.remove(true);
if (!path) {
deferred.fail(new Error(self.ERROR_WMCTRL_NOT_FOUND));
}
else {
prefs.setPref(domain+'wmctrl.path', path);
deferred.call(path);
}
})
.error(function() {
deferred.fail(new Error(self.ERROR_WMCTRL_NOT_FOUND));
});
});
return deferred;
},
この処理が成功すると、wmctrlのパスが設定値として保存されるので、以降は同じ事を何度もしなくてもいいようになってる。
wmctrlの位置が分かったら、今度はウィンドウのID(XのウィンドウのID)を取得する。
initId : function wmctrl_initId()
{
var self = this;
return this.getWindowId(this.window)
.next(function(aWindowId) {
return self.id = aWindowId;
});
},
getWindowId : function wmctrl_getWindowId(aWindow)
{
var self = this;
// wmctrlの位置がまだ分かっていないようであれば、初期化してからやり直す。
if (!this.path)
return this.initPath()
.next(function() {
return self.getWindowId(aWindow);
});
var originalTitle = aWindow.document.title;
var temporaryTitle = 'wmctrl-target-window-'+Date.now()+'-'+parseInt(Math.random() * 65000);
var listFile = this.createTempFile('wmctrl-window-list');
aWindow.document.title = temporaryTitle;
// wmctrl-listは、第1引数で渡されたパスをwmctrlとして実行し、
// 結果を第2引数で渡されたパスのファイルに書き出すだけのシェルスクリプト。
return this.run(resolve('bin/wmctrl-list'), this.path, listFile.path)
.error(function() {
// we must restore the original title anyway!
if (aWindow.document.title == temporaryTitle)
aWindow.document.title = originalTitle;
})
.next(function() {
aWindow.document.title = originalTitle;
var list = textIO.readFrom(listFile, 'UTF-8');
listFile.remove(true);
var match = list.match(new RegExp('^([^\\s]+)\\s.*'+temporaryTitle+'$', 'm'));
if (match)
return match[1];
return null;
});
},
wmctrl -lでウィンドウの一覧を取得できるんだけれども、その時ウィンドウのIDとタイトルが併記されるので、コマンドの実行前にウィンドウのタイトルを一意な物に変更しておいて、コマンドの実行結果からIDを取ってくるようにした。
最後は、wmctrlを叩いてウィンドウを「押し上げる」処理。
raise : function wmctrl_raise()
{
var self = this;
// ウィンドウのIDが分かっていなければ、IDを取得してからやり直す。
if (!this.id)
return this.initId()
.next(function() {
return self.raise();
});
return this.run(this.path, '-i', '-r', this.id, '-b', 'add,above')
.next(function() {
return self.run(self.path, '-i', '-r', self.id, '-b', 'remove,above');
});
},
どの処理も見ての通り、コールバック関数は使わずにJSDeferredで次の処理をつなげる形にしている。今回に限らず、Fox Splitter 2の中では非同期処理の連携は全てJSDeferredで統一するようにしてるので、これらの処理も全く違和感なく既存の処理の中に組み込めるという案配だ。こういう時にJSDeferredはちょう便利すぎるしちょよんごさんは神です。
以上で一応、今回定めた目標は達成できたんだけれども、より良い形として、wmctrlに依存しないで単体で完結できないか?という事も一応考えてはみた。
wmctrlのソースを見てみた所、要するにXlibのAPIを叩いてるみたいで、そのXlibの各機能はC言語で書かれててlibxulにも静的にリンクされてるようだったから、js-ctypesでlibxul.soを開いて関数を呼んでやればwmctrlを使わなくても同じ事ができるっぽいなぁという所までは調べた。ただ、Xlibの各機能を呼ぶために必要な構造体とかの型の定義が膨大で、Cのヘッダファイルをincludeできないjs-ctypesではそれらをいちいち自分で定義し直さないといけなくて、そこまではさすがにやってられなかったので諦めた。次期UbuntuでXを捨ててWaylandという全然別の仕組みを使うとか使わないとかいう記事をこないだ読んだから、Xべったりの実装を滅茶苦茶頑張ってもそれだけじゃ乗り切れないケースがすぐに出てくるんだろうなあと思ったというのも、諦めたもう1つの理由だ。
それから、今回はLinux向けの改善だけで、Mac OS Xについては相変わらず状況は改善されていないというのもなんとかしたいなーとは思ってる。思ってるんだけど、Mac OS Xのウィンドウ管理システムを直接叩く方法についてはまだ調べが付いていないので、一人でやってる限りは実現は相当先のことになりそうだ。AppleScriptとかでうまいことやる方法がもしあれば、教えて貰えると嬉しいんだけど……
wikieditish message: Ready to edit this entry.