2013/10/24

WindowsフォームとPer-Monitor DPI(続)

Windows 8.1 Preview時点のものに続き、RTMではPer-Monitor DPIの要素のうちWM_DPICHANGEDメッセージに変更があったので、これへの対応について。

1. WM_DPICHANGEDが来るタイミング

RTMでは、OSからWM_DPICHANGEDが送られてくるタイミングが以下のように増えている。
  1. FormのLoadイベントが起こる前(おそらくコントロールが初期化される前)
  2. ウィンドウが属するモニターのDPIが変更されたとき
  3. ウィンドウがモニター間をまたいだとき(常に)
1.は初期化時にDPIが分かった方がいいというケースのためかと思うが、Loadイベントを起点にスケーリング処理を開始するアルゴリズムの場合、スルーするようにする必要があるかもしれない。

2.は純粋な改善で、モニターのDPI変更をトリガーとして、直ちにアプリがスケーリングすることが可能になった。

3.が本題で、Previewではモニター間をまたぐときに(途中でウィンドウをリサイズしても)1回しかWM_DPICHANGEDは来なかったが、RTMではまたぐ度に忠実に来るようになった。

これが何を意味するかというと、自分が観察した限りではOSはウィンドウとモニターの重なり合う面積が最大のものをそのウィンドウが属するモニターと見なし(Win32のMonitorFromWindow関数と同じ)、これに従ってWM_DPICHANGEDを送ってくるようなので、以下のループが発生し得る。
  1. ウィンドウがモニター間を移動するとき、移動のモニターに重なる面積と、移動のモニターに重なる面積を比べて、後者が大きくなったとき、すなわち属するモニターが変わったときにWM_DPICHANGEDが来る。
  2. これを受けて直ちにウィンドウをリサイズすると、リサイズはウィンドウの位置(左上角の位置)を固定して行われるので、移動のモニターに重なる面積と、移動のモニターに重なる面積が変化し、前者が大きくなることが起こり得る。
  3. 結果として、属するモニターが移動のモニターに一旦戻る形で変わるので、再度WM_DPICHANGEDが来る(以後、これが起こらなくなる位置までウィンドウが移動するまでループ)。
たぶんPreviewではこのループが起こらないようOS側でWM_DPICHANGEDを抑止していたのではないかと思うが、それでは何か不都合があったのか、RTMではアプリ側に対応を投げてきたということだと思う。

いずれにせよ、このループを回避するには、ウィンドウをリサイズしたときに移動先のDPIと同じになるような方策が必要となる。

2. 具体策

やり方は色々あり得るが、大きく二つに分けられると思う。
  • 即時型
    直ちに、リサイズ後に移動先のDPIと同じになる位置に積極的にウィンドウを移動させた後、リサイズする。

  • 遅延型
    直ちにリサイズはせず、リサイズ後に移動先のDPIと同じになる位置までウィンドウが移動するまで待ってから、リサイズする。
即時型の方がフラグ管理が不要なのでシンプルになるが、ドラッグ中にウィンドウの位置が飛ぶのは嬉しくないかもしれない。したがって、基本は遅延型になると思うが、ドラッグ中でない静止時には使えないので、ケースバイケース。

2.1. 遅延型

まず遅延型のアルゴリズムの例。
  1. ウィンドウの移動開始(Form.ResizeBegin)と移動終了(Form.ResizeEnd)イベントを捉えて、移動中か否かをチェックできるようにしておく。
  2. WM_DPICHANGEDが来たときは、それに含まれる移動先のDPIと、ウィンドウの現在のDPIを比べ、これが違うときで、移動中のときは待機に入り、移動中でないときは直ちにリサイズする。逆にDPIが同じときは、待機を解除する。
  3. ウィンドウの移動(Form.Move)イベントを捉え、待機中のときは、
    1. その位置でウィンドウをリサイズした場合と同じ長方形を生成する。
    2. この長方形が属するモニターをMonitorFromRect関数で得て、スクリーン外でないことを確認の上、そのモニターのDPIをGetDpiForMonitor関数で得る。
    3. これが移動先のDPIと一致するときはリサイズを実行し、待機を解除する。DPIが一致しないときはそのまま待機を続ける(以後、DPIが一致する位置に移動するまで繰り返し)。
  4. 待機中に移動元のモニターに戻ったときは、またWM_DPICHANGEDが来るので、2.によって待機は解除される。
この実装の主要部分。dpiOldは現在のウィンドウのDPI(この値は別途入れている)、dpiNewはWM_DPICHANGEDに含まれるDPI、AdjustWindowはウィンドウをリサイズするメソッド、W32はWin32のP/Invokeのためのクラス。
'Old (previous) DPI
Private dpiOld As Single = 0

'New (current) DPI
Private dpiNew As Single = 0

'Flag to set whether this window is being moved by user
Private isBeingMoved As Boolean = False

'Flag to set whether this window will be adjusted later
Private willBeAdjusted As Boolean = False

'Detect user began moving this window.
Private Sub MainForm_ResizeBegin(sender As Object, e As EventArgs) Handles MyBase.ResizeBegin
    isBeingMoved = True
End Sub

'Detect user ended moving this window.
Private Sub MainForm_ResizeEnd(sender As Object, e As EventArgs) Handles MyBase.ResizeEnd
    isBeingMoved = False
End Sub

'Catch window message of DPI change.
Protected Overrides Sub WndProc(ByRef m As Message)
    MyBase.WndProc(m)

    Const WM_DPICHANGED As Integer = &H2E0 '0x02E0 from WinUser.h

    If (m.Msg = WM_DPICHANGED) Then
        'wParam
        Dim lo As Single = W32.GetLoWord(m.WParam.ToInt32())

        'Hold new DPI as target for adjustment.
        dpiNew = lo

        If (dpiOld <> lo) Then
            If (isBeingMoved = True) Then
                willBeAdjusted = True
            Else
                AdjustWindow()
            End If
        Else
            willBeAdjusted = False
        End If
    End If
End Sub

'Detect this window is moved.
Private Sub MainForm_Move(sender As Object, e As EventArgs) Handles MyBase.Move
    If (willBeAdjusted = True) AndAlso IsLocationGood() Then
        willBeAdjusted = False

        AdjustWindow()
    End If
End Sub

'Check if current location of this window is good for delayed adjustment.
Private Function IsLocationGood() As Boolean
    If (dpiOld = 0) Then Return False 'Abort.

    Dim factor As Single = dpiNew / dpiOld

    'Prepare new rectangle shrinked or expanded sticking Left-Top corner.
    Dim widthDiff As Integer = Convert.ToInt32(Me.ClientSize.Width * factor) - Me.ClientSize.Width
    Dim heightDiff As Integer = Convert.ToInt32(Me.ClientSize.Height * factor) - Me.ClientSize.Height

    Dim rect As New W32.RECT() With {.left = Me.Bounds.Left,
                                     .top = Me.Bounds.Top,
                                     .right = Me.Bounds.Right + widthDiff,
                                     .bottom = Me.Bounds.Bottom + heightDiff}

    'Get handle to monitor that has the largest intersection with the rectangle.
    Dim handleMonitor As IntPtr = W32.MonitorFromRect(rect, W32.MONITOR_DEFAULTTONULL)

    If (handleMonitor <> IntPtr.Zero) Then
        'Check if DPI of the monitor matches.
        Dim dpiX As UInteger
        Dim dpiY As UInteger

        Dim result As Integer = W32.GetDpiForMonitor(handleMonitor, W32.Monitor_DPI_Type.MDT_Default, dpiX, dpiY)

        If (result = 0) Then 'If S_OK (= 0)
            If (Convert.ToSingle(dpiX) = dpiNew) Then
                Return True
            End If
        End If
    End If

    Return False
End Function
リサイズした場合の長方形の生成時には、タイトルバーを含めてクローム部分はDPIによって変わらないので、クライアント領域の分だけ元のウィンドウからサイズを増減しないと微妙にズレが出る点に注意。

2.2. 即時型

次に即時型のアルゴリズムの例。
  1. WM_DPICHANGEDが来たときは、現在のウィンドウの4つの角のそれぞれについて、その位置でウィンドウを固定してリサイズした場合と同じ長方形を生成する。この4つの長方形が移動先の候補となる。
  2. この長方形が属するモニターをMonitorFromRect関数で得て、スクリーン外でないことを確認する。さらに、この長方形の左上角と右上角のそれぞれについて、その位置を含むモニターをMonitorFromPoint関数で得て、少なくとも一方がスクリーン外でないことを確認する(タイトルバーがスクリーン外に行かないようにするため)。
  3. 長方形が属するモニターのDPIをGetDpiForMonitor関数で得る。
  4. これがWM_DPICHANGEDに含まれる移動先のDPIに一致するときは、この長方形の位置にウィンドウを移動させた後、リサイズする。
この4つの長方形の模式図。イメージ的には固定された角の対角にある角が移動して縮小・拡大する感じ。

これらを候補とするのは、モニターの位置関係、ウィンドウの移動方向、DPIの大小関係にかかわらず、この中に必ず移動先のモニターに属するものがあると思われるから(厳密に数学的に正しいかは知らない)。移動先のモニターと同じDPIのモニターが他にもある場合、そのモニターに属する長方形となる可能性もあるが、同じDPIならそのまま移動できるので問題にはならないと思う。

また、これらを調べる順番は、左上角に近い方から進めた方がタイトルバーが移動する可能性が減るので、ユーザーのストレスを抑えられると思う。

この実装の主要部分。変数名などは遅延型と同じ。
'Old (previous) DPI
Private dpiOld As Single = 0

'New (current) DPI
Private dpiNew As Single = 0

'Catch window message of DPI change.
Protected Overrides Sub WndProc(ByRef m As Message)
    MyBase.WndProc(m)

    Const WM_DPICHANGED As Integer = &H2E0 '0x02E0 from WinUser.h

    If (m.Msg = WM_DPICHANGED) Then
        'wParam
        Dim lo As Single = W32.GetLoWord(m.WParam.ToInt32())

        'Hold new DPI as target for adjustment.
        dpiNew = lo

        If (dpiOld <> lo) Then
            MoveWindow()
            AdjustWindow()
        End If
    End If
End Sub

'Move this window for immediate adjustment. 
Private Sub MoveWindow()
    If (dpiOld = 0) Then Exit Sub 'Abort.

    Dim factor As Single = dpiNew / dpiOld

    'Prepare new rectangles shrinked or expanded sticking four corners.
    Dim widthDiff As Integer = Convert.ToInt32(Me.ClientSize.Width * factor) - Me.ClientSize.Width
    Dim heightDiff As Integer = Convert.ToInt32(Me.ClientSize.Height * factor) - Me.ClientSize.Height

    Dim rectList As New List(Of W32.RECT)()

    'Left-Top corner
    rectList.Add(New W32.RECT() With {.left = Me.Bounds.Left,
                                      .top = Me.Bounds.Top,
                                      .right = Me.Bounds.Right + widthDiff,
                                      .bottom = Me.Bounds.Bottom + heightDiff})

    'Right-Top corner
    rectList.Add(New W32.RECT() With {.left = Me.Bounds.Left - widthDiff,
                                      .top = Me.Bounds.Top,
                                      .right = Me.Bounds.Right,
                                      .bottom = Me.Bounds.Bottom + heightDiff})

    'Left-Bottom corner
    rectList.Add(New W32.RECT() With {.left = Me.Bounds.Left,
                                      .top = Me.Bounds.Top - heightDiff,
                                      .right = Me.Bounds.Right + widthDiff,
                                      .bottom = Me.Bounds.Bottom})

    'Right-Bottom corner
    rectList.Add(New W32.RECT() With {.left = Me.Bounds.Left - widthDiff,
                                      .top = Me.Bounds.Top - heightDiff,
                                      .right = Me.Bounds.Right,
                                      .bottom = Me.Bounds.Bottom})

    'Get handle to monitor that has the largest intersection with each rectangle.
    For i = 0 To rectList.Count - 1
        Dim handleMonitor As IntPtr = W32.MonitorFromRect(rectList(i), W32.MONITOR_DEFAULTTONULL)

        If (handleMonitor <> IntPtr.Zero) Then
            'Check if at least Left-Top corner or Right-Top corner is inside monitors.
            Dim handleLeftTop As IntPtr = W32.MonitorFromPoint(New W32.POINT(rectList(i).left, rectList(i).top),
                                                               W32.MONITOR_DEFAULTTONULL)
            Dim handleRightTop As IntPtr = W32.MonitorFromPoint(New W32.POINT(rectList(i).right, rectList(i).top),
                                                                W32.MONITOR_DEFAULTTONULL)

            If (handleLeftTop <> IntPtr.Zero) OrElse (handleRightTop <> IntPtr.Zero) Then
                'Check if DPI of the monitor matches.
                Dim dpiX As UInteger
                Dim dpiY As UInteger

                Dim result As Integer = W32.GetDpiForMonitor(handleMonitor, W32.Monitor_DPI_Type.MDT_Default, dpiX, dpiY)

                If (result = 0) Then 'If S_OK (= 0)
                    If (Convert.ToSingle(dpiX) = dpiNew) Then
                        'Move this window.
                        Me.Location = New Point(rectList(i).left, rectList(i).top)
                        Exit For
                    End If
                End If
            End If
        End If
    Next
End Sub
P/Invokeが多いのは、標準にはないことをしている以上、まあ仕方ない。

3. デモアプリ

以上を引っくるめて修正したデモアプリ。VBに加えてC#でも作成した。
  • "CurrentAutoScaleDimensions"と"GetDeviceCaps (LOGPIXELSX)"は常に同じ(はず)。
  • Windows 8.1では、"GetDpiMonitor"にこのウィンドウが属しているモニターのDPIが表示される。
  • Windows 8.1では、"WM_DPICHANGED (Latest)"に最新のメッセージに含まれるDPIが表示される。
  • 中央のボックスにはWM_DPICHANGEDが来た時刻、そのwParamに含まれるDPI、lParamに含まれるRECTの位置・サイズが表示される。VB版でDEBUGビルドすると、追加情報も表示される。
  • ラジオボタンで即時型"Immediate"と遅延型"Delayed"の切り替えができる。遅延型の場合、その横に現在の待機状況が表示され、"Waiting"が待機中、"Resized"がリサイズしたこと、"Aborted"が待機に入ったが取りやめたことを示す。
  • 下の"Create Button"は動的にコントロールを作成する例として。
実行ファイル
完全なソースコード(VBとC#)

4. 残る問題

通知領域のあるモニターのDPI

モニター間の移動によるものでない、モニター設定によるDPIの変更は、そのモニターに属するウィンドウのみにWM_DPICHANGEDで知らされる。

それはそれで正しいのだが、通知領域アイコンを置いている場合で、アイコンの属するウィンドウが通知領域のあるモニターに現在表示されてない場合、そのモニターのDPIに変更があってもWM_DPICHANGEDは来ない。具体的には、そのウィンドウを最小化している場合や別のモニターに表示させている場合で、こういう場合は通知領域アイコンに付けたメニューなどをリサイズするトリガーがないことになる。

まあ何かのタイミングに引っ掛けて、例えばそのメニューが表示されるときのイベント(ContextMenuStripのVisibleChangedイベントなど)を捉えてDPIをチェックする方法が考えられるが、統一性には欠ける。

WM_DPICHANGEDのlParamのRECT

このRECTについて、MSDNの説明では"RECT structure that provides the size and position of the suggested window, scaled for the new DPI"となっている。

いかにもウィンドウのリサイズに利用できそうな感じがするが、実際に来ているものを見ると、関係があるっぽいのは分かるが正確に何を意味しているのかは分からないという……。別になくても困らないが、謎。

[追記]

通知領域のあるモニターのDPIに関する説明を増やした。

0 コメント :