一方、Per-Monitor DPI対応を実装しようとすると未解決の部分があって、少しお話したところ、そもそもOS標準でどう処理しているか分からんことには……という話になったので、改めて調べてみました。
[追記] de:code
先日行われたde:codeのセッション「既存デスクトップアプリの最新OSへの対応」の録画が公開されてますが、この中で高DPI対応の説明があるので(21~45分頃)、参考までに。
1. 実装上の課題
初めに、Per-Monitor DPIを実装しようとすると、大まかに2つの課題があります。
- ウィンドウをどのタイミングで、画面上のどの位置になるようリサイズすればいいか。
- ウィンドウのサイズが狂うのをどう抑えるか。
2. WM_DPICHANGEDのlParam
この1番目の課題については引っ掛かっていたことがあって、WM_DPICHANGEDのlParamについて、これはWin32のRECT構造体で"a RECT structure that provides the size and position of the suggested window, scaled for the new DPI"とだけ説明があり、新しいDPIでのウィンドウの位置とサイズを表す四角形らしいのですが、実際に試してみると同じようにウィンドウを動かしてもその度に違った値が来るので、意味が分からず無視してました。
それが、改めてOS標準の処理である、Per-Monitor DPIに非対応でDPI仮想化されたウィンドウの挙動を見ているうちに気づいたことがあったので、改めてlParamを確認してようやく理解しました。
結論から言うと、この値はウィンドウをドラッグしているときのカーソルの位置と連動しています。
正直「何だそれは」という感じですが、ひとまずこの確認のためのテストアプリは以下のようなもの。
- 左側はウィンドウの現在の状態で、ウィンドウが移動されるかリサイズされる度に更新されます。"DPI X"はこのウィンドウが属するモニターのPer-Monitor DPI。
- 右側は最新のWM_DPICHANGEDの情報で、上にwParamに含まれるDPIのXとY、下にlParamに含まれるRECT構造体の位置とサイズをそれぞれ"Suggested Position"と"Suggested Size"として表示します。
- このアプリはマニフェストファイルでPer-Monitor DPI対応を宣言する一方、リサイズ機能はないので、常に同じサイズで表示されます。
- まずサイズについて、右の"Suggested Size"の432x176は左の"Current Size"の540x220を丁度120から96への比率である0.8倍に縮小した値になっています。これは予想どおり。
- 次に位置について、"Suggested Position"で示された位置に"Suggested Size"のサイズで描いたものが赤枠の部分です。このときのカーソル位置が赤丸です。赤枠は概ねウィンドウの左上角を固定して縮小していますが、わずかに右に寄っていてカーソルを中心に縮小していることを示しています。
- 最後にWM_DPICHANGEDが来たタイミングについて、ウィンドウが中央の境界から大きく右寄りに入った後になって来ています。これには意味があって、ウィンドウと赤枠の相対位置はカーソル位置とサイズによって固定されているとして、この赤枠の中心が中央の境界から右側に入ったばかりの位置になっています(境界のX座標の1680に対して赤枠の中心のX座標は1681)。つまり、この赤枠どおりにウィンドウをリサイズしても属するモニターが左側に戻ってしまわない位置というわけで、そうなるようOSがWM_DPICHANGEDを出すタイミングを計っていることを示唆しています。
- サイズはDPIの比率と同じく1.25倍になっています。
- 位置は赤枠がウィンドウよりわずかに左に寄っているとおり、これもカーソルの赤丸を中心に拡大しています。
- WM_DPICHANGEDが来たタイミングは、今度は中央の境界から大きく左寄りに入った後になって来ています。赤枠のとおりにリサイズすると大きく右側に膨らむわけですが、そうしても属するモニターが右側に戻ってしまわない位置になっています(境界のX座標の1680に対して赤枠の中心のX座標は1679.5)。
- 赤枠はカーソル位置を中心に縮小していることが一目瞭然。
- WM_DPICHANGEDが来たタイミングは、これも赤枠の中心が中央の境界を越えたとき(境界のX座標の1680に対して赤枠の中心のX座標は1681)。
- WM_DPICHANGEDが来たタイミングは、これも赤枠の中心が中央の境界を越えたとき(境界のX座標の1680に対して赤枠の中心のX座標は1679.5)。
- 赤枠は
右上角を固定して縮小していますが、これは丸めの問題だと思います。ウィンドウと右上角が重なって見えますが、わずかに左側にずれているので(ウィンドウ右端のX座標の1951に対して赤枠右端のX座標は1949)、カーソル位置を中心に縮小しています。 - WM_DPICHANGEDが来たタイミングは一見妙で、赤枠の位置からすればもっとウィンドウが左側にあった段階で出せたはずです。一方、これは丁度ウィンドウの中心が中央の境界を越えたときに当たり(境界のX座標の1680に対してウィンドウの中心のX座標は1681)、したがってこのウィンドウが属するモニターが右側に移ったときであることを考えると、WM_DPICHANGEDのアルゴリズムはウィンドウの属するモニターが変わったときに準備に入り、リサイズ後のウィンドウが移動先のモニターに属する位置になったときに発出される、少なくとも2段階になっているのではないかと推測できます。
- 赤枠はカーソル位置を中心に拡大しています。
- WM_DPICHANGEDが来たタイミングは、ウィンドウの中心が中央の境界に重なったときで、左の"DPI X"が120になっていることが示すようにウィンドウの属するモニターが左側に移ったときに当たります。これはアルゴリズムが2段階あるという推測を裏付けるものだと思います。
- リサイズでモニターが変わった場合、サイズも位置も元と同じものが通知されます(DPIの方だけ変化)。これはOS標準のエクスプローラも同じ挙動なので、ユーザーがウィンドウをリサイズしている最中に自動的にリサイズする必要はないという考え方なのだろうと思います。
- 赤枠は単純に左上角を固定して拡大、縮小しています。このリサイズで属するモニターが変わるような位置にウィンドウがあった場合にはさらに何かあるのかもしれませんが、とりあえず。
- ウィンドウをドラッグしてモニター間を移動させたときは、カーソル位置を中心としてリサイズし、リサイズしても元のモニターに戻らない位置の四角形を示す。
- ウィンドウをリサイズして属するモニターを変えたときは、元のウィンドウと同じ四角形を示す。
- ディスプレイ設定でDPIを変えたときは、左上角を固定してリサイズした四角形を示す。
3. Microsoftの導き
ここまで見ると「実はWM_DPICHANGEDのlParamのとおりにスケーリングすればいいんじゃないの?」と考えるのは自然で、
信じる者は救われる
というか、Microsoft自身のサンプルがそうなっています。
このサンプルの実体部分はC++ですが、ウィンドウのリサイズはこうなっています。
IntPtr PerMonitorDPIWindow::HandleMessages(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, bool% ) { double oldDpi; switch (msg) { case WM_DPICHANGED: LPRECT lprNewRect = (LPRECT)lParam.ToPointer(); SetWindowPos(static_cast<HWND>(hwnd.ToPointer()), 0, lprNewRect->left, lprNewRect- >top, lprNewRect->right - lprNewRect->left, lprNewRect->bottom - lprNewRect->top, SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_NOACTIVATE); oldDpi = m_currentDPI; m_currentDPI = static_cast<int>(LOWORD(wParam.ToPointer())); if (oldDpi != m_currentDPI) { OnDPIChanged(); } break; } return IntPtr::Zero; }これはWM_DPICHANGEDからlParamを取り出して、ノータイムでそのままSetWindowPos関数に入れてウィンドウの位置とサイズを設定しています。
「もうこれでいいかな……」と思ったりもするわけですが、
ところが、ぎっちょん!
このサンプルを実行してみると、モニター間を動かすうちにウィンドウのサイズがどんどん狂っていきます。初めに挙げた課題の2番目の話になりますが、サイズの狂いに脆弱な感じです。
では手を加えるかと思っても、既にアプリのコードとしてはほぼ最短の処理になっているので、この方法のままでは望み薄のような……。
またUIの観点からは、カーソル位置を中心に自分で計算してリサイズするものを試してみましたが、ウィンドウが飛ぶ印象が強いです。そもそもカーソル位置を中心にリサイズするようにしているのは、ドラッグ中に縮小したときにカーソルがウィンドウから外に出てしまう(注:この状態でもドラッグは継続する)ことを避ける狙いがあるのではないかと思いますが、ドラッグ中にカーソルを見ているわけでもなし、むしろウィンドウ位置が飛んでしまうデメリットがあるわけで、個人的にはベストのやり方とは思えないこともあります。
4. 実装
では結局どうするかというと、1番目の課題については以下のようにしました。
- ウィンドウをドラッグしてモニター間を移動させたときは、リサイズは左上角を固定して行うものとし、そのタイミングはOS標準と同じく、リサイズしても大丈夫な位置にウィンドウが来るのを待って実行する(前に考えた遅延型と同じ)。
- ウィンドウをリサイズして属するモニターが変わったときは、OS標準に従う(内部のスケーリングだけ行う)。
- ウィンドウが静止中にDPIが変わったときは、OS標準に従う(どうせなのでWM_DPICHANGEDのlParamを利用する)。
そこで次善の策として、ドラッグ開始時にウィンドウのサイズを内部的に記録しておき、ドラッグ中はその値をベースにリサイズの計算をし(ドラッグ中のウィンドウのサイズは信用しない)、最後の砦としてドラッグ終了後にもう一度リサイズ処理を行う(既に正常にリサイズされていれば何も変わらない)ことにしました。なお、リサイズしても大丈夫な位置までウィンドウが移動されなかった場合は、次の移動の機会に処理を継続します。
コーディングに当たっては、先達の実装例を参考にさせていただきました。
とくにぐらばくさんのDpi構造体は秀逸なアイデアだと思ったので、取り入れさせていただきました。
ということで、自分の実装は以下のとおりです。
[追記] 静止状態でのDPI変更
ウィンドウが静止している状態でディスプレイ設定でDPIを変えた場合(9番目と10番目)について、上記はシステムDPIが96の状態でPer-Monitor DPIを変更した結果でしたが、今更ながらシステムDPIが120の状態では違ってました。
まずシステムDPIが120の状態で(OSの起動時のDPIが120)、DPIを120から96に下げた結果を11番目として。
- これは10番目に対応しますが、10番目ではlParamの示すサイズはDPIを下げた分だけ縮小されてましたが(元の80%)、この11番目では元のサイズのままです。
- これは9番目に対応しますが、9番目ではlParamの示すサイズはDPIを上げた分だけ拡大されてましたが(元の125%)、この12番目では元の約156%になっています。
ということで、静止中にDPIが変わったときはlParamをそのまま使ってもいいかと思ってましたが、やはり他の場合と同じく自分で計算した方がいいようです。