IntelliJ IDEA のプラグインを作ってみた

この記事は
「DeNA IPプラットフォーム事業部 Advent Calendar 2017」
19日目の記事です。

私のいるチームではサーバーサイドエンジニアは全員IntelliJ IDEAを使用してコーディングしております。
最近ふと「IntelliJ IDEAのプラグインってどうやって作るんだろう？」と思い立ち、勉強しながらIDE上に現れるデスクトップマスコットみたいなものを作ってみたので、その流れついてまとめていこうかと思います。

## 作ったもの
IDEを起動すると変な生き物が出てきます。コーディングをすると経験値(Exp)がたまっていきます。
（もうちょっとコーディングを応援したりしてくれる機能も入れたかったけどまぁいいや）
![movie.gif](https://qiita-image-store.s3.amazonaws.com/0/101965/73de8eb7-12e7-0043-7ceb-b7e516039b08.gif)


## 環境構築
プラグインを作るのにはIntelliJ IDEAが必要です。（Community Editionでも良いです）
IntelliJ IDEA Community Edition のソースコードも落としてくるとプラグイン開発中のデバッグが楽になるようですが、今回は使用しませんでした。
細かな手順については[公式のドキュメント](http://www.jetbrains.org/intellij/sdk/docs/basics/getting_started/setting_up_environment.html)に書いてありますので割愛します。

## 取り敢えず作ってみる
### 新規プロジェクト
IntelliJ IDEA 上で [File > New Project > IntelliJ Platform Plugin > Next]
プロジェクト名は適当につけます。（今回はNunyuです）
<img width="512" alt="new_project1.png" src="https://qiita-image-store.s3.amazonaws.com/0/101965/96019e86-673d-8edc-d972-1a3245b4ce2f.png">

### コンポーネントを作る
プラグインのベースとなるのは以下の３種類のコンポーネントになります。

- Application level components: IDE 起動時に初期化されるコンポーネント
- Project level components: IDE上の各プロジェクトごとに作成されるコンポーネント
- Module level components: IDE上の各モジュールごとに作成されるコンポーネント

今回はIDEが起動した時にプロジェクトをまたいでマスコットがずっと出ていて欲しいのでApplication level componentで作っていきます。
Projectパネル上で右クリック、 [New > Plugin DevKit > Application Component]で新規に作成できます。
コンポーネント名は「Nunyu」としました。
<img width="428" alt="new_component.png" src="https://qiita-image-store.s3.amazonaws.com/0/101965/4585d5c6-365b-4b60-0260-1c239710e29b.png">


### 動いているか確かめる
実はこれでもうプラグインがIDE上で走ります！
コンポーネントの初期化処理 `initComponent` でログを出して動かしてみましょう。

```java:src/Nunyu.java
import com.intellij.openapi.diagnostic.Logger;
    ...
    @Override
    public void initComponent() {
        Logger.getInstance(Nunyu.class).info("ぬにゅ");
    }
    ...
```

実行はメニューの [Run > Debug] で行います。
するともう一つIntelliJ IDEAが立ち上がり、この中でプラグインが走ります。
そして、デバッグコンソールにメッセージが出ていることがわかります。
<img width="1660" alt="debug.png" src="https://qiita-image-store.s3.amazonaws.com/0/101965/efe99af0-33df-eaf4-3c43-b6b42f971f52.png">

## マスコットを表示してみる
### JWindow を出す
コンポーネントに `JWindow` を継承させ、初期化時にウィンドウを表示し、画像をレンダリングするようにしてみます。このあたりは `Swing` の話になりますね。

```java:src/Nunyu.java
import com.intellij.openapi.components.ApplicationComponent;
import org.jetbrains.annotations.NotNull;

import javax.swing.*;
import java.awt.*;
import java.awt.event.*;

public class Nunyu extends JWindow implements ApplicationComponent {
    private Image image;
    private ImagePanel imagePanel;

    public Nunyu() {
    }

    @Override
    public void initComponent() {
        initWindow();
        makeDraggable();
    }

    public void initWindow() {
        image = Toolkit.getDefaultToolkit().getImage(getClass().getResource("/images/nunyu-alpha.png"));

        setSize(100, 100);
        setAlwaysOnTop(true);
        setBackground(new Color(1, 0, 0, 0));

        imagePanel = new ImagePanel();
        add(imagePanel);

        setVisible(true);
    }
    
    public void makeDraggable() {
        final Point startPos = new Point();
        addMouseListener(new MouseAdapter() {
            @Override
            public void mousePressed(MouseEvent e) {
                startPos.setLocation(e.getPoint());
            }
        });
        addMouseMotionListener(new MouseMotionAdapter() {
            @Override
            public void mouseDragged(MouseEvent e) {
                setLocation(e.getXOnScreen() - startPos.x, e.getYOnScreen() - startPos.y);
            }
        }); 
    }

    public class ImagePanel extends JPanel {
        public ImagePanel() {
            setOpaque(false);
        }

        @Override
        protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            g.drawImage(image, 0, 0, this);
        }

    }

    @Override
    public void disposeComponent() {
        // TODO: insert component disposal logic here
    }

    @Override
    @NotNull
    public String getComponentName() {
        return "Nunyu";
    }
}
```
- マスコット的に背景が抜けていて欲しいので `setBackground(new Color(0, 0, 0, 0))` でウィンドウを透明に
- 透過PNGを `resources/images/nunyu-alpha.png` に準備して `setOpaque(false)` にした上でレンダリング
- 常に一番上に表示してほしいので `setAlwaysOnTop(true)`
- マスコットをドラッグできるようにしたいのでマウスイベントを見てちょちょっと実装 `makeDraggable()`

![nunyu-alpha.png](https://qiita-image-store.s3.amazonaws.com/0/101965/4489727a-6c2d-e265-61f1-79ede9d1c57d.png)

これで実行するとドラッグ出来るマスコットが表示されます！
<img width="324" alt="show1.png" src="https://qiita-image-store.s3.amazonaws.com/0/101965/b09e92a0-312a-649d-0b11-f1ce8f81ecd2.png">


### IDEが非アクティブの時にマスコットを隠す
このままだとIDEがフォーカスを失ってもマスコットが一番上に表示されたまま消えません。邪魔です。
なのでIDEがフォーカスを失った時に同時にマスコットも消す処理を入れてみます。

```java:src/Nunyu.java
    @Override
    public void initComponent() {
        initWindow();
        setupFocusEvent();
    }
    ...
    public void setupFocusEvent() {
        WindowManager.getInstance().addListener(new WindowManagerListener() {
            @Override
            public void frameCreated(IdeFrame frame) {
                WindowManager.getInstance().getFrame(frame.getProject()).addWindowFocusListener(new WindowFocusListener() {
                    @Override
                    public void windowGainedFocus(WindowEvent e) {
                        setVisible(true);
                    }

                    @Override
                    public void windowLostFocus(WindowEvent e) {
                        if (e.getOppositeWindow() == null) {
                            setVisible(false);
                        }
                    }
                });
            }

            @Override
            public void beforeFrameReleased(IdeFrame frame) {
            }
        });
    }
```
`WindowManagerListener` リスナを追加して、IDE上でフレームが作られるたびにそのウィンドウに `WindowFocusListener` を追加します。このリスナの中でウィンドウがフォーカスを得た時に発火する `windowGainedFocus` とフォーカスを失った時に発火する `windowLostFocus` を実装します。
どれでもいいのでIDE上のウィンドウがフォーカスを得たときにはマスコットを表示したいので `setVisible(true)` を呼べばOKです。逆にフォーカスを失った時には、`e.getOppositeWindow()` で次にフォーカスが当たる予定のウィンドウを取得できますが、これが `null` の時にはIDEがフォーカスを持っていないことになるので、`setVisible(false)`を呼びます。

## マスコットに経験値をあげる
### エディタのキー入力をフックする
さて、次にエディタでコーディングしたらマスコットに経験値をあげるようにしてみます。
これにはエディタのキー入力をフックする必要がありますが、`editorTypedHandler`を拡張してこれを実現してみます。

```java:src/MyTypedHandler.java
import com.intellij.codeInsight.editorActions.AutoFormatTypedHandler;
import com.intellij.openapi.actionSystem.DataContext;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.editor.actionSystem.TypedActionHandler;
import org.jetbrains.annotations.NotNull;

public class MyTypedHandler extends AutoFormatTypedHandler {
    public MyTypedHandler(TypedActionHandler originalHandler) {
        super(originalHandler);
    }

    @Override
    public void execute(@NotNull Editor editor, char charTyped, @NotNull DataContext dataContext) {
        super.execute(editor, charTyped, dataContext);
        Logger.getInstance(Nunyu.class).info("Typed: " + charTyped);
    }
}

```
```xml:resources/META-INF/plugin.xml
  ...
  <extensions defaultExtensionNs="com.intellij">
    <editorTypedHandler implementationClass="MyTypedHandler"/>
  </extensions>
  ...
```

エディタで何か文字がタイプされると `execute()` が呼ばれます。デフォルトの挙動を変更しないように、`super.execute()`はそのまま呼びつつ、押されたキーをログに出力してみます。これで実行してみて、立ち上がるIDE上で何か編集してみてください。入力したキーがinfoログに出るのが確認できると思います。
このキーのフックの仕方ですが[`TypedHandlerDelegate`を使用する方法もある](https://qiita.com/shiraji/items/5a7a1654dc7b6da20a60) みたいでしたが、試してみたところキー入力するうちにIDEでAutoCompletionPopupが出るとイベントが渡ってこなくなってしまうみたいでうまく行きませんでした。（正直何が正解かわからない。。）

### データを永続化する
マスコットに経験値をあげられるようにしてもIDEを再起動した時にリセットされては悲しいです。ですので、次に与えた経験値を永続化出来るようにしてみます。
プラグインでデータを永続化するには`PersistentStateComponent`を使用します。

```java:src/MyService.java
import com.intellij.openapi.components.PersistentStateComponent;
import com.intellij.openapi.components.ServiceManager;
import com.intellij.openapi.components.State;
import com.intellij.openapi.components.Storage;

@State(
        name = "MyService",
        storages = {
                @Storage("nunyu.xml"),
        }
)

class MyService implements PersistentStateComponent<MyService.State> {
    static class State {
        public Integer exp = 0;
    }

    State myState;

    public MyService() {
        this.myState = new State();
    }

    public State getState() {
        return myState;
    }

    public void loadState(State state) {
        myState = state;
    }

    public static MyService getInstance() {
        return ServiceManager.getService(MyService.class);
    }
}
```
単純に整数`exp`を持つだけの箱を実装しました。`@State`アノテーションで永続化する先などが設定できます。

また、忘れずに、`plugin.xml`の中でこのサービスを使用するように記述してやらないといけません。

```xml:resources/META-INF/plugin.xml
  ...
  <extensions defaultExtensionNs="com.intellij">
    <applicationService serviceInterface="MyService" serviceImplementation="MyService"/>
  </extensions>
  ...
```
アプリケーションレベルでのコンポーネントで使用するので`applicationService`として登録します。他にも`projectService`などが存在するので注意です。

### 繋ぎこむ
まずはじめに経験値を画面に出すところからやりましょう。
`ImagePanel`クラスの`paintComponent()`メソッドを書き換え、`MyService`の`exp`を表示するようにします。
また、経験値が増えた時に都度再描画出来るように、`repaint()`メソッドも準備しておきましょう。

```java:src/Nunyu.java
    ...
    public void repaint() {
        imagePanel.repaint();
    }
    ...
    public class ImagePanel extends JPanel {
        ...
        @Override
        protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            g.drawImage(image, 0, 0, this);

            g.drawString("Exp. " + MyService.getInstance().myState.exp, 0, 10);
        }
    }
    ...
```

次にキー入力時に経験値を増やし、再描画する処理を書きます。

```java:src/MyService.java
    ...
    public void increment() {
        myState.exp++;
    }
    ...
```
```java:src/MyTypedHandler.java
    ...
    @Override
    public void execute(@NotNull Editor editor, char charTyped, @NotNull DataContext dataContext) {
        super.execute(editor, charTyped, dataContext);
        ServiceManager.getService(MyService.class).increment();
        ApplicationManager.getApplication().getComponent(Nunyu.class).repaint();
    }
    ...
```

以上で終わりです。
コーディングすると経験値がどんどん増えていくようになりました！

## 最後に
さて、作ったのは何の役にも立たない（笑）プラグインでしたが、試行錯誤しながら進めていくことで何となくプラグインの作り方みたいなものが分かってきました。結構ドキュメントから情報を探すのが大変で、「こういうことをやりたいときはどうすればいいんだ？！」みたいな物がパッと出てこず、こんな簡単なものでも非常に苦労しました。
たくさんプラグインが出回っているのでその中から参考になりそうなコードを探してきたり、[コミュニティで検索](https://intellij-support.jetbrains.com/hc/en-us/search?filter_by=community&topic=200366979&utf8=%E2%9C%93)したりすると探している情報が見つかったり見つからなかったりします！
苦闘の末の記事になりましたが、何か指摘等あれば是非コメントください。