プラグイン作って覚えたことを淡々と記録するよ(GUI編その2)

前回に引き続いてTextFieldネタを。

TextFieldWithHistory/TextFieldWithStoredHistoryクラスとPropertiesComponentクラス

履歴機能付きテキストフィールドってのは,...WithHistoryから察することができよう。WithStoredHistoryやPropertiesComponentについては,おいおい説明するとして,まずは外観をご披露。


これもキャプション部分はJPanel。左のTextFieldWithHistoryに「...」ボタンが付いているが,これは後付けしたもので,TextFieldWithHistory/TextFieldWithHistory単体での外観は右側のなんかアイコンが付いてるテキストフィールドがそう。


見た目から機能は推測できよう。こんな具合に入力履歴を保存できるテキストフィールドが,TextFieldWithHistory/TextFieldWithStoredHistory。


とはいえポトペタで貼っ付けとけば勝手に履歴が残るワケじゃなく,しかるべきタイミングでカレントの値を履歴に残さねばならない(TextFieldWithHistory.addCurrentTextToHistory()を呼んでね)。「しかるべきタイミング」ってのは...まぁ...各自で決めてくれと。


一般的な用途は,TextFieldWithBrowseButtonみたくダイアログと連動して入力値を残す事なんだが,コレ単体では「WithBrowseButton」がない。無けりゃ付けろってことで,GuiUtilsなるユーティリティの手助けを借りてブラウズボタンをくっつける。

textfieldWithHistory = new TextFieldWithHistory();
JPanel panel = GuiUtils.constructFieldWithBrowseButton(textfieldWithHistory, new ActionListener() {
        public void actionPerformed(ActionEvent e) {
            VirtualFile[] chooseFiles = FileChooser.chooseFiles(project,
                                                                new FileChooserDescriptor(true, false, false, false, false, false));
            if (chooseFiles.length > 0) {
                textfieldWithHistory.setText(chooseFiles[0].getPath());
                // 選択するたび履歴に残すなら,ここで
                //   textfieldWithHistory.addCurrentTextToHistory();
                // するもよし。
                // あとは,「OK」ボタン押したときとか。
            }
        }
    });
// GuiUtilsの戻り値はJPanelが多いので,UIデザイナでは
// 貼り付け先にパネルを仕込んでおいた方が何かと楽。
anchorPanel.add(panel);

GuiUtilsは,他にもUI加工用のメソッドがあるので,詳しくはSDKJavadocみてね。


TextFieldWithHistoryの説明はだいたい終わったんで,TextFieldWithStoredHistoryを...説明する前にPropertiesComponentを説明する。
#といっても,半分憶測混じりなので,そのつもりでどぞ。


PropertiesComponentってのは,HttpSessionやHttpServletRequestの属性(Attribute)みたいに,特定のスコープで値を保存できる入れ物のことだ(多分あってる)。特定のスコープってのは,勝手に名付けるが

  • アプリケーションスコープ
    • PropertiesComponent.getInstance()で取得。IDEAのインスタンス?プロセス?でひとつ。
  • プロジェクトスコープ
    • PropertiesComponent.getInstance(Project)で取得。IDEAのプロジェクトごとにひとつ。

の2つ。


持てる情報は,文字列とブール値くらいだが,あたしの思い違いでなければ,PropertiesComponentは勝手に永続化されるみたい。
実際,IDEA再起動してもPropertiesComponentから以前入れた値が取れるし。だけど,プロジェクトスコープのくせに,*.ipr, *.iml, *.iwsのどこみても永続化してるふうでもない。一体どこに永続化してんの?そいとも永続化してるって思いこんでるだけ??


まあいい,永続化の真偽はおいとくとして,PropertiesComponentはちょいとしたモノ入れに使っているみたい。見かけたのは,設定パネルみたいに寿命の短いGUIで設定した内容をちょいと保存しておくとか。そんな用途。
便利そうなんだけど,どこまで依存していいか見極めかねてる。


でだ。前置きが長くなったが,TextFieldWithStoredHistoryってのは,履歴情報を勝手にPropertiesComponentに保存してくれるコンポーネントだ。なんでま,WithStoredHistoryなんだろな。TextFieldWithStoredHistoryにはデフォルトコンストラクタはなく,プロパティ名を指定する。このプロパティ名を使ってPropertiesComponentに自身の履歴情報を格納するようだ。ただ,履歴の復元は,こちらが望まないとやってくれない(TextFieldWithStoredHistory.reset()を使う)。
長期的な履歴の保存と復元をしてくれるって点で,TextFieldWithStoredHistoryのほうが実用的なんだと思う。TextFieldWithHistoryは正直使い処が思いつかない。


軽くスルーしていたが,デフォルトコンストラクタを持たないUI部品をIDEAのUIデザイナで管理するには,ちょいとした仕掛けがある。通常,FormにバインドしたJavaクラスのコンストラクタでは,バインドしたUI部品のインスタンスは生成しているとみなしてコーディングできるんだが,デフォルトコンストラクタがないとそう簡単にはいかない(でも,そんな難しくもない)。
どうやって,そのインテンションが出たかは失念してしまったのだが,こんな感じのメソッドが自動生成されるので,そこでIDEAが勝手に生成できないUIパーツの生成コードを記述することができるようだ。こんなのはじめて知ったわ。

private void createUIComponents() {
    textfieldWithStoredHistory = new TextFieldWithStoredHistory("dummy");
    // 履歴を復元しとく
    textfieldWithStoredHistory.reset();
}


とまあ,こんな感じだ。TextFieldWithHistory/TextFieldWithStoredHistoryもPropertiesComponentもプラグインSDKにソース付いてきてるんで,もっと詳しいことはソース読んでくれ。
若干,蛇足な気もするが,こんなUIパーツもあるってんで,紹介しとこ。

ComboboxWithBrowseButtonクラス

TextFiledWithBrowseButtonのコンボボックス版。SDKJavadocで見つけただけで,使い道はよーわからん。

AlternativeJREPanelクラス

外観はこれ。


このあまりにも用途が限定されたUIパーツは,普通のSDKには含まれていない。プラグインで使いたい場合は,SDKのライブラリにidea.jarも仕込んでね。
あえて使い方を説明する必要あるの?って思うが,見ての通りJRE(というJavaSDK)を選択する。


一応,履歴にはすでにIDEAに登録してあるSDKが列挙するんだけど,「Javaだけ」という選別まではしてないので,RubySDKとかプラグインSDKとかもごっちゃになるけど,それはご愛敬。同じ理由で,ブラウズボタンで表示するファイル・チューザもJREだけフィルタリングしてはくれない。
Alternative JREとか言ってるワリに,実は言い値をそのまま持つと言う,びみょうにしょっぱいUIパーツだ。


パーツの完全性にケチつけてもしょうがないんで,話を進めるとしよう。AlternativeJREPanelは,

  • isPathEnabled()で,有効無効の判定
  • getPath()で,指定されたJRE(だと思う)パス

を取得できるんで,なんだかよく知らんが,個別にJREのパスを指定したいときなんかに使うと便利(どんなときだ?)。
あたしゃ,Winstone Integration Pluginで使わせてもらったが,もう二度と使うことはあるまい。:-)


GUI編は,まだ続く。

プラグイン作って覚えたことを淡々と記録するよ(VFS編)

GUIばっかりも飽きるので,VFS(Virtual File System)についてちょっと。VFSとはIDEAが管理しているファイルシステムのことで,プロジェクトだとかモジュール配下のソースファイルとかは,こいつ経由で操作するのが望ましいらしい(java.ioとか使うのはルール違反)。


たぶん,これもいろいろできるんだろうけど,理解が低いので,どんだけできるかとか全然知らない。:-)


とりあえず,習作がてら試してみたのは,こんなこと。

特定のファイルをリネームしたり,移動したりすると,それに紐付いたファイルも連動する。
具体的には,*.txtファイルをリネーム・移動すると,同じファイル名の*.bakファイルも連動する。

イメージしやすい例だと,Wicketのページクラス(*.java)とHTMLテンプレート(*.html)を連動したいとか,ディレクトリ離れているけどClickのページクラス(*.java)とVelocityテンプレート(*.htm)を連動したいとかだ。


今回は,VirtualFileListenerを使い,ファイルの変更イベントをフックして実現してみたんだけど,たぶん良いやり方ではないと思う。できることはできたんだけど,なんか強引な気がする。


そんな強引なサンプルコードを晒す。まずは,VirtualFileListenerの登録部分。VirtualFileListenerの登録は,まあおまじないみたいなもんだから,特に説明することはない。

public class VfsSamplePlugin implements ProjectComponent {
    private Project project;
    private MyVfsListener myVfsListener;

    public VfsSamplePlugin(Project _project) {
        project = _project;
    }

    public void initComponent() { }
    public void disposeComponent() { }

    @NotNull public String getComponentName() {
        return "VfsSamplePlugin";
    }

    public void projectOpened() {
        myVfsListener = new MyVfsListener();
        // ここで登録
        VirtualFileManager.getInstance().addVirtualFileListener(myVfsListener);
    }

    public void projectClosed() {
        // ここで解除
        VirtualFileManager.getInstance().removeVirtualFileListener(myVfsListener);
    }
}

んで,肝心のVirtualFileListenerについて。

public class MyVfsListener extends VirtualFileAdapter {
    private static Logger LOGGER = Logger.getInstance("MyVfsListener");

    // *.txtをリネームしたら,連動して*.bakもリネームする.
    public void propertyChanged(VirtualFilePropertyEvent event) {
        VirtualFile file = event.getFile();
        if (!file.getExtension().equals("txt")) return;

        VirtualFile parent = event.getParent();
        String fileName = FileUtil.getNameWithoutExtension((String) event.getOldValue());
        final VirtualFile bundleFile = parent.findChild(fileName + ".bak");
        if (bundleFile == null) return;

        final Object requestor = event.getRequestor();
        final String newName =  FileUtil.getNameWithoutExtension((String) event.getNewValue()) + ".bak";
        EventQueue.invokeLater(new Runnable() {
            public void run() {
                try {
                    bundleFile.rename(requestor, newName);
                }
                catch (IOException e) {
                    LOGGER.info(e);
                }
            }
        });
    }

    public void contentsChanged(VirtualFileEvent event) { }

    // *.txtを移動したら,連動して*.bakも移動する.
    public void fileMoved(VirtualFileMoveEvent event) {
        VirtualFile file = event.getFile();
        if (!file.getExtension().equals("txt")) return;

        VirtualFile oldParent = event.getOldParent();
        final VirtualFile bundleFile = oldParent.findChild(file.getNameWithoutExtension() + ".bak");
        if (bundleFile == null) return;

        final Object requestor = event.getRequestor();
        final VirtualFile newParent = event.getNewParent();
        EventQueue.invokeLater(new Runnable() {
            public void run() {
                try {
                    bundleFile.move(requestor, newParent);
                }
                catch (IOException e) {
                    LOGGER.info(e);
                }
            }
        });
    }
}

VirtualFileListenerのイベントのうち,リネームは propertyChanged(),移動は fileMoved() が受け付ける。コピーや削除んときは調べてないから知らない。contentsChanged()は,多分ファイルを保存したら反応すんじゃないかと。
フックするイベントがわかりさえすれば,あとはVirtualFileを操作するだけなんだが,VirtualFileに対する操作は,好き勝手な時にやっちゃいかんと言われる。
エラーメッセージやJavadocのコメントみると,write-actionはApplication.runWriteAction(Runnbale)配下でやんなさいとあるが,だからといって,こんなコードを書けばいいってもんでもないらしい。

ApplicationManager.getApplication().runWriteAction(new Runnbale() {
    public void run() {
        try {
            bundleFile.move(requestor, newParent);
        }
        catch (IOException e) {
            LOGGER.info(e);
        }
    }
});

上記のコードを実際に動かすと,一応目的は達成するんだけど,IDEAログにエラー出てんで,正しいやり方じゃないんだろう。


要するにだ。やりたいことを整理すると,こんな感じになる。

  1. リネーム/移動といったコマンド発動→イベント発火
  2. イベント発火をリスナが受信
  3. リスナ内で,新たなコマンドを作成(←いまここ)
  4. 作ったコマンドをコマンドキューに詰めて,この処理(スレッド)の後に実行してほしい
    • 知りたいのはこれ!!


最後の処理をどうにかできると信じて,あれこれ調べた結果,とりあえずたどり着いたのが,EventQueueを使う方法(これはAWTのAPIよ)。

EventQueue.invokeLater(new Runnable() {
    public void run() {
        try {
            bundleFile.move(requestor, newParent);
        }
        catch (IOException e) {
            LOGGER.info(e);
        }
    }
});

これだとエラーは出ないんだけど,正解だといえる自身もない。:-(
余談だけど,Subversionと繋げてるとちゃんと連動してくれるよ。こっちはVCSちゅう別の機構が作用しているハズなので,気にせずVFSを操作すりゃ,勝手に連動してくれんだろうという思惑通りの結果でありました。


つうわけで,俺俺プラグインで使う分には良さそうだけど他の方法も模索したいという,煮え切らない結果でスマソ。


ps.
Open APIみるとRefactoringActionHandlerFactoryとかRefactoringListenerManagerとか,如何にもなコンポーネントがあるんだけど,まだ調べてもいないので何とも言えん。


(追記)Instant Calculatorのソース読んで,こんな方法もあることがわかった。

EventQueue.invokeLater(new Runnable() {
    public void run() {
        CommandProcessor.getInstance().executeCommand(project, new Runnable() {
            public void run() {
                ApplicationManager.getApplication().runWriteAction(new Runnable() {
                    public void run() {
                        try {
                            bundleFile.move(requestor, newParent);
                        }
                        catch (IOException e) {
                            LOGGER.info(e);
                        }
                    }
                });
            }
        }, "My VFS Sample", null);
    }
})

EventQueue使うのは変わりないんだけど,コマンドの実行方法がお作法に則っているっぽいんだが,どうだろう?
以前のCommandProcessorを経由しない方法でも,VCSの連携やUndoで両方とも元に戻るんで実質問題はないんだけどね。