JavaFXでタイトルバーの色をJNAを使って変更する

<font color="red">**※2024-2-29追記**
**暫く使っているとアプリがハングアップするようになりました。やり方が強引でしたかね。**
**記事としては残しておきますが、まあ使えないです。**
**WM_NCPAINTをハンドリングする方法も試してたんですが一緒ですね。そもそもFindWindowでHWNDを取ってWindoProcをアレしようってのがどうなのかしら、ダメかしら。**
</font>

# タイトルバーの色を触ってるアプリって多いのに
これが意外と簡単にはやらせてくれない。**StageStyle.UNDECORATED を使ってタイトルバーを自分で描画したらできるよ**とか、なにそれ七面倒臭い。

でもまあやってみたかったのでやってみた。

## そしてこんな感じのものが出来た
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117698/bc659c65-b87f-6d56-8b83-d09dda7f8680.png)

## GitHubはこちら
https://github.com/mr-certain-a/ChangeColorTitlebar

# という事でざっくりとした解説
何はともあれ、StageStyle.UNDECORATEDで一切の装飾を拒否するところから始まる。

全体は**BorderPane**で作り、topにタイトルバーのペインをToolBarで、centerにクライアント領域のペインをSplitPaneで作ることにした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117698/5b79b33b-d1ac-d6ea-8da0-15b6545ebdf7.png)

因みにkotlinで書いてあるので、必要に応じて適時読み替えてほしい。

```App.kt
class App: Application() {
    companion object {
        const val TITLE_STRING = "Change the color of title bar in JavaFX."

        @JvmStatic
        fun main(args: Array<String>) {
            launch(App::class.java, *args)
        }
    }

    // ウインドウハンドルを取るために後で使用する
    private val temporaryTitle = UUID.randomUUID().toString()

    override fun start(stage: Stage) {
        stage.initStyle(StageStyle.UNDECORATED)

        val rootPane = BorderPane().apply {
            top = createToolBar(stage) // タイトルバーの作成
            center = createClientPane()
        }

        // アイコンと初期サイズとスタイルシートを指定
        stage.icons.add(Image(Paths.get("images/icon.png").toUri().toString()))
        stage.scene = Scene(rootPane, 800.0, 600.0).apply {
            stylesheets.add(Paths.get("design.css").toUri().toString())
        }
        // テンポラリの方のタイトルを設定しておく
        stage.title = temporaryTitle

        // 表示する
        stage.show()
    }
}
```

さてタイトルバーのペインを作る。といっても取り敢えずはアイコンとラベルとボタンを並べるだけだ。


```createToolBar.kt
    private fun createToolBar(stage: Stage) = ToolBar().apply {
        styleClass.add("toolBar")
        
        val height = 30.0
        prefHeight = height
        minHeight = height
        maxHeight = height

        // このペインを挟む事で、コントロールボックスを右に寄せることができる
        val pane = Pane()
        HBox.setHgrow(pane, Priority.ALWAYS)

        val label = Label("Change the color of title bar in JavaFX.")

        val icon = ImageView().apply {
            image = Image(Paths.get("images/icon.png").toUri().toString())
            fitWidth = 16.0
            isPreserveRatio = true
            isSmooth = true
        }

        // 3つのボタンは別のところで作っている（後述）
        items.addAll(icon, label, pane, minButton(stage), maxButton(stage), closeButton())
    }
```

# 最小化/最大化/閉じるボタンを作る
さてこのボタン類だが、それっぽく見せかければいいやという事で、それっぽく描画することにした。
要するにButtonにCanvasを貼り付けて**自力で描く。**

```ControlBox.kt
// コントロールボックスの描画色
private val foregroundColor: Color = Color.web("#EEEEEE")

private data class Rect(val left: Double, val top: Double, val right: Double, val bottom: Double)

fun closeButton() = Button().apply {
    styleClass.add("closeButton")
    // イベントをハンドルして・・・
    onAction = EventHandler {
        Platform.exit()
    }
    // 自力で描く
    graphic = Canvas(18.0, 30.0).apply {
        graphicsContext2D.stroke = foregroundColor
        with(Rect(width / 2 - 5, height / 2 - 5, width / 2 + 5, height / 2 + 5)) {
            graphicsContext2D.strokeLine(left, top, right, bottom)
            graphicsContext2D.strokeLine(right, top, left, bottom)
        }
    }
}

fun maxButton(stage: Stage) = Button().apply {
    styleClass.add("systemButton")
    onAction = EventHandler {
        stage.isMaximized = true
    }
    graphic = Canvas(18.0, 30.0).apply {
        graphicsContext2D.stroke = foregroundColor
        graphicsContext2D.strokeRect(width / 2 - 5, height / 2 - 5, 10.0, 10.0)
    }
}

fun minButton(stage: Stage) = Button().apply {
    styleClass.add("systemButton")
    onAction = EventHandler {
        stage.isIconified = true
    }
    graphic = Canvas(18.0, 30.0).apply {
        graphicsContext2D.stroke = foregroundColor
        graphicsContext2D.strokeLine(width / 2 - 5, height / 2, width / 2 + 5, height / 2)
    }
}
```

ここまでのスタイルシートはこんな感じ。
```design.css
.toolBar {
    -fx-background-color: #3c3f41;
    -fx-border-color: #2c2f31;
    -fx-alignment: center-right;
}

.closeButton {
    -fx-background-color: transparent;
    border-style: none;
}

.closeButton:hover {
    -fx-background-color: #E71123; /* 閉じるボタンだけ赤い */
}

.systemButton {
    -fx-background-color: transparent;
    border-style: none;
}

.systemButton:hover {
    -fx-background-color: #505354;
}
```

クライアント領域の方はまあ適当に作って貼り付けたらOKである。
ここまでで枠組みは完成。しかしこのままだとリサイズとタイトルバーを掴んで移動させる事が出来ない。

これらを機能させるためにはウインドウメッセージに正しく反応しなくてはならない。

具体的にはWM_NCHITTESTに反応して、**ウインドウの境界部分に今マウスが居てるよ！** とかそういう事をOSに教えてやればいいわけである。

ではWindowsが発行するウインドウメッセージをどうやって掠め取ればいいのか？
この辺はWindowsプログラムをやってる人なら即座に思い付く方法があると思う。

**そんなもんは SetWindowPos でウインドウプロシージャのポインタを書き換えてしまえ！**

まあ、その通りである。
Windowsはウインドウハンドルさえ取ってしまえば大体何とかなる。Windowsってそういうもんやから。

という事で、ウインドウハンドルを取るためにいよいよWindowsAPIに手を出す・・・ためにまずはJNAを使えるようにしておこう。

```build.gradle.kts
    implementation("net.java.dev.jna:jna:5.14.0")
    implementation("net.java.dev.jna:jna-platform:5.14.0")
```

これでUSER32.dllにアクセスできるようになるので、大体やりたい放題である。
SwingだったらHWNDを取る方法があるが、JavaFXでは正規の方法が見つからないので、FindWindowを使う事にする。

ウインドウタイトルをぶつけて目的のHWNDをもらってくる方法だ。
そのぶつける用にダミーで一意のタイトルを生成して、それを使う事にする。

```App.kt
    // ダミーで一意のタイトルテキスト
    val temporaryTitle = UUID.randomUUID().toString()

    // これを stage に設定しておく
    stage.title = TITLE_STRING

    // ダミーのタイトルでウインドウを引っ掛けてハンドルを取る
    val hwnd = User32.INSTANCE.FindWindow(null, temporaryTitle)
```

# 自前のウインドウプロシージャの作成
ウインドウプロシージャとは、いわゆるこれの事で、この関数ポインタを書き換える。
```winuser.h
LRESULT CALLBACK WndProc( HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam )
```

新しいウインドウプロシージャを作るために、JNAは**WinUser.WindowProc**というインターフェイスを持っているので、これを継承して作る。

```MyWindowProc.kt
class MyWindowProc: WindowProc {
    override fun callback(hwnd: HWND, uMsg: Int, wparam: WPARAM, lparam: LPARAM): LRESULT {
        // ここで受け取るuMsgがウインドウメッセージ
        return LRESULT(0)
    }
}
```

この自前の MyWindowProc を渡すためにUser32を拡張する。

```MyUser32.kt
interface MyUser32: User32 {
    fun SetWindowLongPtr(hwnd: HWND, index: Int, wndProc: WindowProc): LONG_PTR
}
```

```App.kt
    override fun start(stage: Stage) {
    
        //～～～～～～～～～～～～～～～～～～～～～～～～～～～～～～～～～～～～
        // ・・・省略・・・
        //～～～～～～～～～～～～～～～～～～～～～～～～～～～～～～～～～～～～
    
        // ダミーで一意のタイトルテキスト
        val temporaryTitle = UUID.randomUUID().toString()
    
        // これを stage に設定しておく
        stage.title = temporaryTitle

        // 表示する
        stage.show()
    
        // ダミーのタイトルでウインドウを引っ掛けてハンドルを取る
        val hwnd = User32.INSTANCE.FindWindow(null, temporaryTitle)
    
        // 拡張User32のインスタンスを作る
        val myUser32 = Native.load("user32", MyUser32::class.java, W32APIOptions.DEFAULT_OPTIONS) as MyUser32
    
        // SetWindowLongPtrでウインドウプロシージャを上書きする
        val wndProc = MyWindowProc()
        wndProc.defWndProc = myUser32.SetWindowLongPtr(hwnd, -4/*GWLP_WNDPROC*/, wndProc)
    
        // ウインドウタイトルを本来のものに変更
        stage.title = TITLE_STRING
    }
```

ここまでやると、**MyWindowProc.callback** にウインドウメッセージが飛び込んで来るようになる。
しかしその前にここで一工夫打っておく。

今回作ったBorderPaneはJavaFX的には子ウインドウだという認識である。
要するにトップレベルウインドウではない。

なのでこのままだといくらヒットテストに応えても、リサイズできないのである。
よってこのウインドウはリサイズ可ですよという事をOSに無理やり認識させる事にする。

```App.kt
    // 現在のスタイルを取得して、
    val style = User32.INSTANCE.GetWindowLong(hwnd, GWL_STYLE)

    // ウインドウスタイル WS_THICKFRAME(サイズ設定の境界線があるよ) を追加して設定する
    User32.INSTANCE.SetWindowLong(hwnd, GWL_STYLE, style.or(0x00040000/*WS_THICKFRAME*/))
```

これでWindowsがリサイズ可能ウインドウとして扱ってくれる。

# コールバックでウインドウメッセージを受け取る
```MyWindowProc.kt
    override fun callback(hwnd: HWND, uMsg: Int, wparam: WPARAM, lparam: LPARAM) =
        when (uMsg) {
            WM_NCCALCSIZE -> LRESULT(0)
            WM_NCHITTEST -> hitTest(hwnd)
            WM_DESTROY -> {
                myUser32.SetWindowLongPtr(hwnd, GWLP_WNDPROC, defWndProc)
                LRESULT(0)
            }
            else ->
                // 不要なものは元々のウインドウプロシージャに渡す
                myUser32.CallWindowProc(defWndProc, hwnd, uMsg, wparam, lparam)
        }
```

ここで必要なウインドウメッセージだけ処理していく。
自分が処理しないものは、元々のウインドウプロシージャにぶん投げる。

最後にhitTestメソッドの実装である。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117698/79085689-981b-392a-3606-a7fe5da921d5.png)

上の画像のように、ウインドウのどの位置にマウスポインタがあるかを判定して、それに対応した値を返却してやればあとはWindowsが良きに計らってくれる。

```MyWindowProc.kt
    private fun hitTest(hWnd: HWND): LRESULT {
        val point = POINT()
        val rect = RECT()

        User32.INSTANCE.GetCursorPos(point)
        User32.INSTANCE.GetWindowRect(hWnd, rect)

        val rcWindow = rect.toRectangle()
        val pt = Point(point.x, point.y)

        // タイトルバー判定矩形
        val titleBarRect = Rectangle(rcWindow).apply {
            x += ICON_WIDTH
            width -= CONTROL_BOX_WIDTH + ICON_WIDTH
            height = TITLE_BAR_HEIGHT + MAXIMIZED_WINDOW_FRAME_THICKNESS
        }

        // 上方リサイズ判定矩形
        val topResizeRect = Rectangle(rcWindow).apply {
            height = FRAME_RESIZE_BORDER_THICKNESS
        }

        // 下方リサイズ判定矩形
        val bottomResizeRect = Rectangle(rcWindow).apply {
            y += height - FRAME_RESIZE_BORDER_THICKNESS
            height = FRAME_RESIZE_BORDER_THICKNESS
        }

        // 左側リサイズ判定矩形
        val leftResizeRect = Rectangle(rcWindow).apply {
            width = FRAME_RESIZE_BORDER_THICKNESS
        }

        // 右側リサイズ判定矩形
        val rightResizeRect = Rectangle(rcWindow).apply {
            x += width - FRAME_RESIZE_BORDER_THICKNESS
            width = FRAME_RESIZE_BORDER_THICKNESS
        }

        return when {
            leftResizeRect.contains(pt) ->
                when {
                    topResizeRect.contains(pt) -> LRESULT(HTTOPLEFT)
                    bottomResizeRect.contains(pt) -> LRESULT(HTBOTTOMLEFT)
                    else -> LRESULT(HTLEFT)
                }
            rightResizeRect.contains(pt) ->
                when {
                    topResizeRect.contains(pt) -> LRESULT(HTTOPRIGHT)
                    bottomResizeRect.contains(pt) -> LRESULT(HTBOTTOMRIGHT)
                    else -> LRESULT(HTRIGHT)
                }
            topResizeRect.contains(pt) -> LRESULT(HTTOP)
            bottomResizeRect.contains(pt) -> LRESULT(HTBOTTOM)
            titleBarRect.contains(pt) -> LRESULT(HTCAPTION)

            else -> LRESULT(HTCLIENT)
        }
    }
```

キャプションを教える事で移動はもとより、ダブルクリックされたら最大化されるというシステムデフォルトの動作もやってくれるようになった。

# 大体は満足なんだけど
トップウインドウにフォーカスが当たった時に一瞬アウトラインが強調されるとか、DPIスケーリングに対応していないだとかはあるんだけど、まあ概ね満足。

自力でタイトルバー作ってるから、ここにメニューやら検索ボックス作る拡張は簡単に出来る。

というわけで、**よりカッコよくしてやったぜ**とかいう諸姉諸兄がおられましたら教えてもらえれば幸いでございます。ところで「幸」の象形文字って奴隷だって本当なんですかね。