読者です 読者をやめる 読者になる 読者になる

JavaFXでxeyes作ってみました

JavaFX RIA

今の自分の仕事場は環境的に結構恵まれています。広い机にエルゴヒューマンの椅子。
そしてディスクプレイが27インチのデュアルです! (iMac+Thunderbolt Display)
広いディスプレイはやっぱり快適です。あまりに快適なので家でも27インチのUltra Cinema Displayを買っちゃったくらいです。

でも広いディスプレイだと1つ困ることがあります。それはマウスカーソルを見失いやすいということです。
そこで古き良きxeyesを使うことにしました。(確かxeyeが元々作られた理由ってマウスカーソルの位置を把握するためでしたよね?)
ところがMacにはxeyesが確かに付属しているのですが、これはX Window上で動くアプリケーションで、Xのアプリケーション上にマウスカーソルがあるときしか反応してくれません...。

というわけでJavaFXを使って自分で作ってみることにしました。
が、まず一番最初でつまずきます。今のJavaFXAPIではマウスのグローバルな座標を取る手段が見当たりません。
仕方が無いのでAWTの MouseInfo を使って取得しようと思ったのですが、JavaFXのアプリケーション上で使うと、HeadlessExceptionが飛びます。
JavaFXはAWTを全く使わず一から作り直しているので、AWT的にはヘッドレスで動いていることになるのです。(システムプロパティ java.awt.headless に true がセットされています)

(2011/12/26 修正)
櫻庭さんからご指摘があり、SwingのEDT上で実行すれば、Swingを土台とせずともHeadlessExceptionは飛ばないとのことでした。
簡単なサンプルで確認したところ、Windows環境では問題なく動作しました。ただ、Mac環境ではやはりHeadlessExceptionが送出され、どうもMac環境固有の問題だったようです。
ただ、WinとMacで微妙に実行環境が異なるところがあったので (前者はJDK7update1 + JavaFX2.0.2で後者はJDK6update29 + JavaFX2.0.1beta) 、後ほど時間があるときにもう少し詳しく再現条件について調べてみます。
あと、改めてJavaFXとして作ってみます。
櫻庭さん、ご指摘ありがとうございました!

(2012/02/21 追記)
どうもJIRAで報告されているRT-13739が近い感じです。一応修正ターゲットは2.1になってますが、これを書いた時点ではまだアサインがないようです。

結局Swingを土台にしてその上にJavaFXで作ることにしました。全ソースコードはgistに貼り付けてあります。
https://gist.github.com/1519410

ちょっとしたアプリケーションですが、結構学ぶことがありました。
まず、Swingアプリケーション上にJavaFXアプリケーションを構築する方法です。

public class SwingFXEyes extends JFrame {
    private JFXPanel jfxPanel;
    private Eye leftEye;
    private Eye rightEye;
    private Timer timer;

    public SwingFXEyes() throws HeadlessException {
        initComponents();
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(new Runnable() {
            public void run() {
                new SwingFXEyes().setVisible(true);
            }
        });
    }

    private void initComponents() {
        setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);

        jfxPanel = new JFXPanel();
        add(jfxPanel);

        Platform.runLater(new Runnable() {
            public void run() {
                initJFXComponents();
            }
        });
        pack();
    }

    private void initJFXComponents() {
        Group root = new Group();
        Scene scene = new Scene(root);

        leftEye = new Eye();
        rightEye = new Eye();
        rightEye.setLayoutX(leftEye.prefWidth(-1) + 5);
        root.getChildren().addAll(leftEye, rightEye);

        jfxPanel.setScene(scene);

        SwingUtilities.invokeLater(new Runnable() {
            public void run() {
                startTimer();
            }
        });
    }

Swing上にJavaFXコンポーネントを載せるには JFXPanel を貼り (initComponents) 、その上にJavaFXのシーングラフを構築 (initJFXComponents) します。目玉部分は独自コンポーネント Eye クラスとして作りました。
注意点としてはSwingとJavaFXは実行スレッドが異なるので、互いのコンポーネントを触るときは SwingUtilities.invokeLaterPlatform.runLater を経由して触る必要があります。これがちょっとめんどくさいです。

マウス位置の取得はSwingのタイマーを使って行いました。

    /**
     * マウス位置取得処理を行うタイマーを起動する。
     */
    private void startTimer() {
        timer = new Timer(50, new ActionListener() {
            public void actionPerformed(ActionEvent ae) {
                updateMousePosition();
            }
        });
        timer.start();
    }

    /**
     * グローバルのマウス位置を取得し、目のマウス位置を更新する。
     */
    private void updateMousePosition() {
        final Point mouseLocation = MouseInfo.getPointerInfo().getLocation();
        Platform.runLater(new Runnable() {
            public void run() {
                updateEye(mouseLocation.x, mouseLocation.y);
            }
        });
    }

    /**
     * 左右のEyeにコンポーネントからの相対的なマウス位置を渡す。
     */
    private void updateEye(double mouseX, double mouseY) {
        Point panelLocaiton = jfxPanel.getLocationOnScreen();
        leftEye.updateMousePosition(mouseX - panelLocaiton.x, mouseY - panelLocaiton.y);
        rightEye.updateMousePosition(mouseX - panelLocaiton.x - leftEye.prefWidth(-1) - 5, mouseY - panelLocaiton.y);
    }

AWTの MouseInfo を使って、マウス位置を取得し、目玉コンポーネントにはコンポーネントからの相対的な座標を渡すようにしています。
JFXPanel は javax.swing.JComponent を継承しているので、getLocationOnScreen メソッドを使って画面上での位置を取得可能であることを利用しました。

目玉は独立したコンポーネントとして作ってみました。JavaFXでは子を持つコンポーネントParent クラスを継承する必要があります。

public class Eye extends Parent {
    private static final double CENTER_X = 42.5;
    private static final double CENTER_Y = 62.5;
    private static final double ELLIPSE_RADIUS_X = 40d;
    private static final double ELLIPSE_RADIUS_Y = 60d;

    private Circle eye;

    public Eye() {
        super();
        createChildren();
    }

    private void createChildren() {
        final Ellipse ellipse = new Ellipse(CENTER_X, CENTER_Y, ELLIPSE_RADIUS_X, ELLIPSE_RADIUS_Y);
        ellipse.setStrokeWidth(5.0);
        ellipse.setStroke(Color.BLACK);
        ellipse.setFill(null);

        eye = new Circle(CENTER_X, CENTER_Y, 10d, Color.BLACK);

        this.getChildren().addAll(ellipse, eye);
    }

    /**
     * 自分の位置からの相対的なマウス位置を受け取り、自身のステータス (つまり目玉の位置) を更新する。
     * @param mouseX マウスのx座標
     * @param mouseY マウスのy座標
     */
    public void updateMousePosition(double mouseX, double mouseY) {
        // マウスの自コンポーネントの中心からの相対座標を算出
        double localMouseX = mouseX - CENTER_X;
        double localMouseY = mouseY - CENTER_Y;
        computeEyePosition(localMouseX, localMouseY);
    }

    private void computeEyePosition(double mouseX, double mouseY) {
        double parameter = Math.atan2(mouseY, mouseX);
        double eyeX = (ELLIPSE_RADIUS_X - 7.5) * Math.cos(parameter);
        if (Math.abs(mouseX) < Math.abs(eyeX)) {
            eyeX = mouseX;
        }
        double eyeY = (ELLIPSE_RADIUS_Y - 7.5) * Math.sin(parameter);
        if (Math.abs(mouseY) < Math.abs(eyeY)) {
            eyeY = mouseY;
        }
        eye.setCenterX(eyeX + CENTER_X);
        eye.setCenterY(eyeY + CENTER_Y);
    }
}

目玉の縁を Ellipse で、目玉を Circle で書いて自分の子供に追加しています。
外からマウスの相対的な位置をもらって目玉の位置を決めるようにしました。 Math.atan2 を使って極座標に変換しています。

ということでこんな感じでできました!