ラベル DPI の投稿を表示しています。 すべての投稿を表示
ラベル DPI の投稿を表示しています。 すべての投稿を表示

2017/04/09

WPFのPer-Monitor DPIサポート(その4)

完結編。


Windows 10 Creators Updateがリリースされましたが、Windows 8.1から3年経過して、なぜか分かりませんがMicrosoftがようやくデスクトップアプリのPer-Monitor DPI対応に力を入れてきています。今更WinFormsまで対応してくるとは予想外でした。
新APIの追加など色々ありますが、WPFの場合は特別なことをしていない限り、関係するのはマニフェストへのPerMonitorV2の追加だけです。
これでWPFでも、例のNon-client areaのスケーリングが自動的にかかるようになります(Creators Update上では。なお、この例はAnniversary Updateより前の対応は自前でやる前提のもの)。

これによって、WPFのPer-Monitor DPI対応はひとまず完成ということで、何か特別なことをしない限りマニフェストに書くだけです。まあUIを作っていると、その何か特別なことが往々にして必要になりますが。

2016/09/18

WPFのPer-Monitor DPIサポート(その3)

先月のWindows 10 Anniversary Updateに合わせて.NET Framework 4.6.2がリリースされ、WPFのPer-Monitor DPI機能も正式公開になりました。この機能の正式名はとくにないようですが、区別のため、ここではBuilt-in Scalingと呼びます。


1. 条件


このエントリへの質問に対するRohit Agrawal(WPFチームの人)の回答と
例によってDeveloper Guideをまず参照。
まとめると、Built-in Scalingの前提条件は以下のとおりです。
  • OSがWindows 10 Anniversary Update(Redstone 1)以降であること
  • Target Frameworkが.NET Framework 4.6.2以降であること
  • マニフェストのdpiAwarenessがPerMonitorであること
最初に対応OSについて。Windows 8.1については触れられてませんが、サンプル等を見るとWindows 10 Anniversary Updateより前はまとめてSystem DPI Awareにしてしまいなよ、という扱いなので、Windows 8.1のPer-Monitor DPIについてはさっくり放置する方針のようです。

つまり、Windows 8.1については従来どおり独自対応で行くしかないという方向で確定と。まあMicrosoft全社挙げてWindows 10推しの現況では、それ以前のOSに対してリソースの割り当てがないのだろうと思います。今となってはWindows 8.1は過渡期のOSであることは否定できない感じはします。

次に新しいdpiAwarenessについて。従来のdpiAwareに対して、新しくマニフェスト(app.manifest)に導入されました。この二つは併用が可能で、OSがWindows 10 Anniversary Update以降で、かつdpiAwarenessが指定されていれば、この指定の方が有効になる、ということのようです。

例として、サンプルでの指定方法。

上のdpiAwarenessではPer-Monitor DPI Aware、下のdpiAwareではSystem DPI Awareの指定になっています。この場合、コメントにあるとおり、Windows 10 Anniversary Update以降ではPer-Monitor DPI Awareとなりますが、Windows 8.1ではdpiAwareのみが効いてSystem DPI Awareとなり、仮想スケーリングがかかります。

なぜわざわざdpiAwarenessを追加したのかを推測するに、dpiAwareでtrue/PM(またはPer-Monitor)で指定する方法のままだと、(独自対応なしでは)Windows 8.1上で全くスケーリングがかからなくなってしまうので、それを避けたかったのかなと思いますが、何か要らぬ手間がかかっている気がします。

また、これらの基本条件に対して、コンフィグ(App.config)の方でSwitch.System.Windows.DoNotScaleForDpiChangesを指定すると、その値によって設定をオーバーライドできます。
  • True - 上記の条件を満たしていてもBuilt-in Scalingを無効にする
  • False - Target Frameworkが4.6.2より前であってもBuilt-in Scalingを有効にする
例として、有効にする場合(supportedRuntimeは無関係)。

これの使いどころとしては、Target Frameworkは4.6.2に上げたくないけど、Built-in Scalingは使いたいような場合でしょうか。DPIに応じて変化するような独自コントロールがあるのでもなければ、4.6.2の機能がなくても困りませんし。

条件としては以上です。

2. 独自対応との兼ね合い


結局、Windows 8.1上で仮想スケーリングに甘んじたくなければ、引き続き独自対応が必要になります。あるいは、Windows 10以降も独自対応で通したい場合もあるかもしれません。

独自対応との兼ね合いでありそうな問題としては、
  • Built-in Scalingが不要な場合の抑止

    これは実は簡単で、WM_DPICHANGEDが来たときのハンドラー内で「handled = true」として処理済みにしてしまえば、Built-in Scalingは発動しないようです。 

  • DPI変化の伝播ルートの統一

    .NET Framework 4.6.2より前では、VisualTreeのルートたるWindowから傘下のコントロールにDPI変化を伝えるためには、独自に伝播ルートを張る必要がありました。これが4.6.2以降でBuilt-in Scalingが発動したときは、各コントロールのOnDpiChangedメソッドかImage等のDpiChangedイベントで自動的に伝播されます。

    であれば、独自対応の場合でもこのルートを利用した方が楽ですが、この伝播はVisualTreeHelper.SetRootDpiメソッドで発生させることができます。したがって、独自対応でスケーリングさせると同時に、これを実行するようにすることで伝播ルートを統一できます。
ということで、Windows 8.1上だけ独自対応にし、Windows 10 Anniversary Update以降はBuilt-in Scalingに任せるように切り替えることは意外と難しくないです。

自作ライブラリでは、XAMLから指定可能なWillForbearScalingIfUnnecessaryプロパティでこれが可能なようにしています。

3. Non-client areaの新API


やや蛇足になりますが、Anniversary Updateの後にNon-client area(NCA、ウィンドウの枠のクローム部分)のスケーリングに関してエントリが出ています。
既存デスクトップアプリのPer-Monitor DPI対応を促すために遅まきながらWin32 APIを追加するという話ですが、これまで手つかずだったNCAのスケーリングの話もあります。

WPFに関しては、
For the Windows 10 Anniversary Update WPF is being updated to support automatic NCA scaling.

とありますが、実際に試してみると「いや、効いてないし……」という状態。

そこで、NCAのスケーリングを有効にする新APIのEnableNonClientDpiScalingを実行しようとすると、WPFでウィンドウハンドルを取得できるタイミングとしてはおそらく最早のWindow.OnSourceInitializedメソッド内では成功しないし、エラーコードにも有意な情報がなく。

以下のスレッドのコメントによれば、WM_NCCREATEが来たときのハンドラーで実行すればいいらしいですが、
WPFでウィンドウメッセージを捕捉するにはウィンドウハンドルが必要なので、いずれにしてもWindow.OnSourceInitializedメソッドより早くはできなくて、このときにはこのメッセージは通り過ぎた後らしくて捕捉できない、と手が出せない状態。

まあ出せるものから出していくという姿勢は否定しませんが、既にとっ散らかる兆候を見せているので、出来上がったときにはなるべくまとまった形になっているといいな、というのがささやかな希望です。

[追記]

EnableNonClientDpiScalingの効果は、WinFormsであれば簡単に確認できます。

2016/05/29

WPFのPer-Monitor DPIサポート(その2)

少し前になりますが、.NET Framework 4.6.2 PreviewでWPFのプラットフォームによるPer-Monitor DPI機能が試せるようになっています。といっても普通に動きすぎて特記することもなく、またプレビュー段階であまり深入りするのも意味がある気がしないので、簡単にまとめてみます。


1. 概要


まずはWPF-SamplesにあるDeveloper Guideから。View Rawでダウンロードできます。
これを読むと、試すには以下が条件になっています。
  • .NET Framework 4.6.2 Previewのインストール
  • Windows 10限定(Windows 8.1については記載なし)
方法としては、基本的にapp.manifestのdpiAwareをtrue/PMにしておけば終わりです。WPFのレイアウトシステムに乗ったUIであれば、他に何も要りません。それでは対応できない場合のために個別に救済策が用意されていますが、引っ掛かることがなければ使う機会はないと思います。

DPI変化は、VisualのレベルでOnDpiChangedメソッドが用意されているので、これをオーバーライドすることで感知できます。さらにWindowやImageにはDpiChangedイベントが追加されているので、これでも感知できます。

細かくは、DpiScaleInfoはDpiScaleに名前が変わりました。またDpiChangedイベント用にDpiChangedEventHandlerとDpiChangedEventArgsが追加されたので、これだけから新旧のDPIを取得できます。

[追記] VisualTreeHelper.GetDpiメソッド

.NET Framework 4.6.2で追加されたVisualTreeHelper.GetDpiメソッドで対象VisualのDpiScaleを簡単に取得することができますが、これが正確に機能するのはAnniversary Update以降の場合です。それ以前の場合は従来どおり自前の方法で取得する必要があります。

2. テスト


簡単に動作をテストするアプリを書きました。

気づいた点を挙げると、
  • 実際のスケーリングとOnDpiChangedやDpiChangedは結びついていない。つまり、OnDpiChangedをオーバーライドして変えてもスケーリングは操作できない。
  • VisualTreeHelper.SetRootDpiを実行するとVisualTreeで波及的にOnDpiChangedを実行させることができるが、これも実際のスケーリングとは関係しない。
  • WM_DPICHANGEDメッセージを受信したときにhandledをtrueにするとスケーリングを抑止できる。つまり、実行時にスケーリングさせないよう動作を変えることも可能。
実はWindows 8.1に対応させる気があるかどうか分からないので、保険としてWM_DPICHANGEDを受けてスケーリングを直接起こさせる手もあるかと思ったのですが、そうは行かないようで。

一応、このアプリのレポジトリ。
何にせよ、すべては現段階での話です。

2015/11/14

WPFのPer-Monitor DPIサポート(その1)

WPF自体のPer-Monitor DPIサポートについて。


従来、WPF自体にはPer-Monitor DPIのサポートはなく、Win32 APIを駆使する必要があったわけですが、.NET Framework 4.6.1の後のバージョンでようやくサポートが入るようです。
このエントリ中にあるサーベイに登録すればプレビュー版を試せるとのことで、登録しておいたところ、昨日招待メールが届いたのでダウンロードして試してみました。

ダウンロードできるのは.NET Framework 4.6.1のプレビュー版のようで、それを利用するサンプルコードは既にGitHubで公開されています。
このサンプルコードの範囲内で簡単にまとめると、以下が追加されています。
  • System.Windows名前空間
    • DpiScaleInfo構造体(DPI情報を格納する)

  • System.Windows.Media.VisualTreeHelperクラス
    • public static DpiScaleInfo GetDpi(Visual visual)メソッド
    • public static void SetRootDpi(Visual visual, DpiScaleInfo dpiInfo)メソッド

  • System.Windows.Windowクラス
    • protected override void OnDpiChanged(DpiScaleInfo oldDpiScaleInfo, DpiScaleInfo newDpiScaleInfo)メソッド

  • System.Windows.Controls.Imageクラス
    • protected override void OnDpiChanged(DpiScaleInfo oldDpiScaleInfo, DpiScaleInfo newDpiScaleInfo)メソッド
    • public event RoutedEventHandler DpiChangedイベントハンドラー
さらにコメントを見ると、大元のSystem.Windows.Media.VisualクラスにOnDpiChangedメソッドとDpiChangedイベントハンドラーが追加されるようです。

つまり、他の色々なイベント処理と同じように、各コントロールでDPI変化イベントを捉えて処理できるようになる、ということです。当然コントロールによって予め処理が作り込まれているものもあって、Windowクラスではモニターをまたがって移動したときに自動的にサイズ変更がされるようになっていました(位置はWM_DPICHANGED準拠)。さらに必要な処理があれば、OnDpiChangedをオーバーライドするなりDpiChangedに登録するなりして入れ込めばいいわけですね。

どんなものが来るのかなと思ってましたが、自然な拡張のようでとりあえず安心しました。

[追記1]

DpiChangedイベントハンドラーはRoutedEventHandlerとなっているとおり、引数はただのRoutedEventArgsなので、DPI情報を直接取得はできません。これは少しだけ面倒なので、以下のような専用のDpiChangedEventArgsを使うようにしてはどうかとフィードバックしてみました。

[追記2]

フィードバックへの返信で、このサポートが入るのは4.6.1ではなく、その後のバージョンだと訂正されたので、修正しました。

2014/08/21

Visual Studio "14"のツールバーアイコン

Visual Studio "14" CTP3でツールバーなどのアイコンがHigh Resolution Iconsになったというので。
ウィンドウの左右について、それぞれ上がVisual Studio 2013、下がVisual Studio "14" CTP3で、DPI 150%の状態。

確かにアイコンが変わっている。ただ輪郭がまだぼけているところを見るに、たぶんリソースにラスターデータの画像ファイルを持っていて(Visual Studio Image Libraryには以前からサイズ別の画像が用意されている)、起動時にDPIを見て適当なサイズのものをImageコントロールに読み込み、それをImageコントロールが自動リサイズしているのではないかと思う。

一方、タイトルバーにある「通知」と「フィードバック」のアイコンは以前から高DPIに対応していて、こちらは輪郭のぼけが少ないところを見るに、ベクターデータだと思う。左上角の最小化などのボタンもたぶん同じ。Microsoftの田中さんの記事によればラスターデータの画像でも高解像度のもの(最大256x256ピクセル)を使っているらしい。
ちなみに、ベクターデータだから輪郭がぼけないということはなくて、論理的な描画位置が実数(Double)で管理されているのに対して、実際に表示するピクセルは整数なので、その間に端数が生じるとぼけの原因になる。これはアプリを高DPI化するには避けて通れない問題で、DPI 100%ではきっちり整数で合わせていても、拡大/縮小すると端数が必然的に出てくる。

これを抑止するには描画位置をピクセルに合わせて整数になるよう寄せてやる必要があって、そのために使えるプロパティが色々ある。
これらの効果は一筋縄では行かなくて自分でも把握し切れてないが、これらで満足する結果が得られない場合はGuidelineSetクラスを使って描画位置を明示的に指定してやる必要がある。ただし、これはかなり面倒なので、多少のぼけは諦めた方が楽かもしれない。

[修正]

タイトルバーのアイコンについて、こちらもラスターデータの画像によるものと修正した。

2014/08/01

WPFのクローム部分におけるPer-Monitor DPI対応

Per-Monitor DPI対応のラストピースともいうべき、ウィンドウのクローム部分における対応について。

1. 背景


Per-Monitor DPI対応のためにウィンドウを拡大/縮小するとき、アプリ自身からスケーリングできるのは基本的に内側のクライアント領域の部分だけで、外側のクローム部分(枠部分)はOSのコントロール下にあり、Per-Monitor DPI対応を宣言したアプリのウィンドウをOSはスケーリングしないため、結果的にクローム部分だけスケーリングから取り残されるということが起きます。

これを避けるにはクローム部分もアプリ側でコントロールすればいいわけですが、WPFの場合はWindowChromeクラスを使うことで比較的容易にこれが実現できます。これについてはぐらばくさんが基本的な説明をされています。
あえて付け足すとすれば、CaptionHeightで設定される領域はダブルクリックによる最大化の機能も担っているので、0にするより設定した方が標準ウィンドウの機能を失わずに済みます。なお、CaptionHeightの起点とウィンドウ上端の間にはResizeBorderThickness.Topが挟まるので(0でなければ)、計算するときは注意を。

2. デモ


Per-Monitor DPI対応のためのライブラリに、ビヘイビアによるもの、添付プロパティによるものに加え、WindowChromeを使ったものを"ExtendedWindow"として加えて再構成しました。Per-Monitor DPI対応の機能は共通なので、リサイズ時の問題への対策もされています。
初期設定ではWindows 8の標準ウィンドウとほとんど同じ外観です。

ここでPer-Monitor DPIを変えると、クローム部分もスケーリングされます。

どうせなのでサンプルテーマも用意してみました。

大体の項目はプロパティを通しても設定できますが、キャプションボタンのマウスオーバー時とクリック時の色は直接ResourceDictionaryを作成してThemeUriに設定する以外の方法は用意していません。といっても、実際にアプリを作ろうとすると色々手を加えないといけないと思いますが。

ちなみに、デモアプリの"Rain"をクリックするとアニメーションが表示されます。

クローム部分(のように作ってある部分)もアプリ側からコントロールできることが分かると思います。

3. まとめ


というわけで、Per-Monitor DPIに完全対応を果たしつつ、ウィンドウの全てを自由にデザインできるので、アイデア次第で魅力的なUIを作り上げることができると思います。

2014/07/21

Per-Monitor DPIのビヘイビアによる実装

WPFでPer-Monitor DPI対応を実装する方法として、これを実装したWindowから継承するやり方を取りましたが、標準のWindowにPer-Monitor DPI対応を付加する方法もあります。

その一つとしてespresso3389さんが添付プロパティを使ったやり方を示されています。
これで基本的な用は足りるわけですが、ウィンドウ内の構成によってはDPIの値等を直接取得して処理したい場合もあります(例えば、画像をDPIに応じて切り替える)。

これを簡単に取得できるようにするにはどうすればいいか考えたところ、Per-Monitor DPI対応のためには要は対象Windowのインスタンスが参照できればいいので、ビヘイビアを使ったやり方があると思いついたところで、このやり方には先人がいたことを思い出しました。
これをどうせならXaml内で完結できるといいかなと思ったので、Expression Blendの(というより既に標準ライブラリと言っていいと思うSystem.Windows.Interactivityの)Behaviorとして実装を書いてみました。
これを使うとWindowのXamlで以下のような使い方ができます。
<Window x:Class="WpfPerMonitorDpiBehavior.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
        xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions"
        xmlns:local="clr-namespace:WpfPerMonitorDpiBehavior"
        Title="WPF Per-Monitor DPI Behavior"
        FontFamily="Segoe UI"
        Width="400" Height="200">
    <i:Interaction.Behaviors>
        <local:PerMonitorDpiBehavior x:Name="DpiBehavior"/>
    </i:Interaction.Behaviors>

    <Grid x:Name="LayoutRoot">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>

        <Grid Margin="4">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition Width="*"/>
            </Grid.ColumnDefinitions>
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="*"/>
            </Grid.RowDefinitions>

            <Label VerticalAlignment="Center"
                   Content="System DPI"/>
            <TextBox Grid.Column="1"
                     Width="80" Height="22" Margin="6,4"
                     Text="{Binding ElementName=DpiBehavior, Path=SystemDpi, Mode=OneWay}"/>

            <Label Grid.Row="1"
                   VerticalAlignment="Center"
                   Content="Per-Monitor DPI"/>
            <TextBox Grid.Column="1" Grid.Row="1"
                     Width="80" Height="22" Margin="6,4"
                     Text="{Binding ElementName=DpiBehavior, Path=WindowDpi, Mode=OneWay}"/>
        </Grid>
    </Grid>
</Window>
10-12行目でこのビヘイビアを指定し、35と42行目でビヘイビア内のプロパティをバインディングして表示するようにしています。

なお、ここでは簡単にするためウィンドウのリサイズにはWM_DPICHANGEDのlParamの値をそのまま使うようにしています。したがって、実用にするにはリサイズ時にサイズが狂う問題への対策が必要です。また、Per-Monitor DPI対応であることをOSに伝えるため、利用するアプリのアプリケーションマニフェストでWindows 8.1対応であること、Per-Monitor DPI対応であることを示す必要があります。

[追記] 添付プロパティによる実装

一応、同じことは添付プロパティでも可能なので、書いてみました。
クラスのインスタンス自身をプロパティとして持たせるという、よくあるパターンを添付プロパティでやったものです。WindowChromeクラスのWindowChrome添付プロパティが同じような感じです。

これでビヘイビアとよく似た使い方ができます。
<Window x:Class="WpfPerMonitorDpiProperty.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:WpfPerMonitorDpiProperty"
        Title="WPF Per-Monitor DPI Property"
        FontFamily="Segoe UI"
        Width="400" Height="200">
    <local:PerMonitorDpiProperty.AttachedProperty>        
        <local:PerMonitorDpiProperty x:Name="DpiProperty"/>
    </local:PerMonitorDpiProperty.AttachedProperty>

    <Grid x:Name="LayoutRoot">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>

        <Grid Margin="4">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition Width="*"/>
            </Grid.ColumnDefinitions>
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="*"/>
            </Grid.RowDefinitions>

            <Label VerticalAlignment="Center"
                   Content="System DPI"/>
            <TextBox Grid.Column="1"
                     Width="80" Height="22" Margin="6,4"
                     Text="{Binding ElementName=DpiProperty, Path=SystemDpi, Mode=OneWay}"/>

            <Label Grid.Row="1"
                   VerticalAlignment="Center"
                   Content="Per-Monitor DPI"/>
            <TextBox Grid.Column="1" Grid.Row="1"
                     Width="80" Height="22" Margin="6,4"
                     Text="{Binding ElementName=DpiProperty, Path=WindowDpi, Mode=OneWay}"/>
        </Grid>
    </Grid>
</Window>
こちらだとSystem.Windows.Interactivity.dllは当然不要です。

[追伸] Visual Studio 2013のデバッガーにおけるアプリケーションマニフェスト

Per-Monitor DPI対応であることをアプリケーションマニフェストで示したアプリをVS2013内のデバッガーで実行すると、Per-Monitor DPI対応にならない問題がUpdate 2まで発生していて(SetProcessDpiAwareness関数を使った場合は問題ない)、どうもデバッガーでアプリケーションマニフェストが反映されない既知の問題があったようです。この問題はUpdate 3 RCを試したところ発生しなかったので、Update 3で修正されたのだと思います。

2014/06/07

WPFにおけるPer-Monitor DPI対応の実装

先日参加しためとべや東京勉強会でぐらばくさんが現在のWPFについてセッションされた中でHigh DPI対応について説明されてました。丁度同じ日にプログラミング生放送勉強会で八巻さんがWindowsの画面スケーリングについてセッションされたようで、共通したメッセージとして、High DPI、さらにはPer-Monitor DPIの時代がとっくに来てるのでアプリは対応していく必要がある、ということでした。
一方、Per-Monitor DPI対応を実装しようとすると未解決の部分があって、少しお話したところ、そもそもOS標準でどう処理しているか分からんことには……という話になったので、改めて調べてみました。

[追記] de:code

先日行われたde:codeのセッション「既存デスクトップアプリの最新OSへの対応」の録画が公開されてますが、この中で高DPI対応の説明があるので(21~45分頃)、参考までに。

1. 実装上の課題


初めに、Per-Monitor DPIを実装しようとすると、大まかに2つの課題があります。
  1. ウィンドウをどのタイミングで、画面上のどの位置になるようリサイズすればいいか。
  2. ウィンドウのサイズが狂うのをどう抑えるか。
1番目は、ウィンドウをリサイズする際、その位置によってはそのウィンドウの属するモニター(重なりが一番大きいモニター)が移動元のモニターに戻ってしまい、結果的にリサイズが繰り返される現象に対するもので、2番目は、リサイズがうまく行かずサイズが狂っていってしまう問題に対するものです。WM_DPICHANGEDが来たときに単純にスケーリングをかけるだけでは問題なしとは行かないんですよね。

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対応を宣言する一方、リサイズ機能はないので、常に同じサイズで表示されます。
では、1番目に左側のDPI 120のモニターから右側のDPI 96のモニターへ、タイトルバーの左端にカーソルを置いてドラッグした場合。ウィンドウはWM_DPICHANGEDが来た位置で止めています。
  • まずサイズについて、右の"Suggested Size"の432x176は左の"Current Size"の540x220を丁度120から96への比率である0.8倍に縮小した値になっています。これは予想どおり。
  • 次に位置について、"Suggested Position"で示された位置に"Suggested Size"のサイズで描いたものが赤枠の部分です。このときのカーソル位置が赤丸です。赤枠は概ねウィンドウの左上角を固定して縮小していますが、わずかに右に寄っていてカーソルを中心に縮小していることを示しています。
  • 最後にWM_DPICHANGEDが来たタイミングについて、ウィンドウが中央の境界から大きく右寄りに入った後になって来ています。これには意味があって、ウィンドウと赤枠の相対位置はカーソル位置とサイズによって固定されているとして、この赤枠の中心が中央の境界から右側に入ったばかりの位置になっています(境界のX座標の1680に対して赤枠の中心のX座標は1681)。つまり、この赤枠どおりにウィンドウをリサイズしても属するモニターが左側に戻ってしまわない位置というわけで、そうなるようOSがWM_DPICHANGEDを出すタイミングを計っていることを示唆しています。
2番目に、カーソル位置は左端のまま逆方向にドラッグした場合。
  • サイズはDPIの比率と同じく1.25倍になっています。
  • 位置は赤枠がウィンドウよりわずかに左に寄っているとおり、これもカーソルの赤丸を中心に拡大しています。
  • WM_DPICHANGEDが来たタイミングは、今度は中央の境界から大きく左寄りに入った後になって来ています。赤枠のとおりにリサイズすると大きく右側に膨らむわけですが、そうしても属するモニターが右側に戻ってしまわない位置になっています(境界のX座標の1680に対して赤枠の中心のX座標は1679.5)。
3番目に、タイトルバーの中心にカーソルを置いてDPI 120からDPI 96にドラッグした場合。
  • 赤枠はカーソル位置を中心に縮小していることが一目瞭然。
  • WM_DPICHANGEDが来たタイミングは、これも赤枠の中心が中央の境界を越えたとき(境界のX座標の1680に対して赤枠の中心のX座標は1681)。
4番目に、この逆の場合。
  • WM_DPICHANGEDが来たタイミングは、これも赤枠の中心が中央の境界を越えたとき(境界のX座標の1680に対して赤枠の中心のX座標は1679.5)。
5番目に、タイトルバーの右端にカーソルを置いてDPI 120からDPI 96にドラッグした場合。
  • 赤枠は右上角を固定して縮小していますが、これは丸めの問題だと思います。ウィンドウと右上角が重なって見えますが、わずかに左側にずれているので(ウィンドウ右端のX座標の1951に対して赤枠右端のX座標は1949)、カーソル位置を中心に縮小しています。
  • WM_DPICHANGEDが来たタイミングは一見妙で、赤枠の位置からすればもっとウィンドウが左側にあった段階で出せたはずです。一方、これは丁度ウィンドウの中心が中央の境界を越えたときに当たり(境界のX座標の1680に対してウィンドウの中心のX座標は1681)、したがってこのウィンドウが属するモニターが右側に移ったときであることを考えると、WM_DPICHANGEDのアルゴリズムはウィンドウの属するモニターが変わったときに準備に入り、リサイズ後のウィンドウが移動先のモニターに属する位置になったときに発出される、少なくとも2段階になっているのではないかと推測できます。
6番目に、この逆の場合。
  • 赤枠はカーソル位置を中心に拡大しています。
  • WM_DPICHANGEDが来たタイミングは、ウィンドウの中心が中央の境界に重なったときで、左の"DPI X"が120になっていることが示すようにウィンドウの属するモニターが左側に移ったときに当たります。これはアルゴリズムが2段階あるという推測を裏付けるものだと思います。
これでウィンドウを移動させたときについては把握できたと思いますが、ウィンドウのリサイズによって属するモニターが変わったときにどうなるかというと、右側に伸ばした場合が7番目、左側に縮めた場合が8番目。
  • リサイズでモニターが変わった場合、サイズも位置も元と同じものが通知されます(DPIの方だけ変化)。これはOS標準のエクスプローラも同じ挙動なので、ユーザーがウィンドウをリサイズしている最中に自動的にリサイズする必要はないという考え方なのだろうと思います。
最後にウィンドウが静止している状態でディスプレイ設定でDPIを変えたときを確認すると、96から120に変えた場合が9番目、120から96に変えた場合が10番目。
  • 赤枠は単純に左上角を固定して拡大、縮小しています。このリサイズで属するモニターが変わるような位置にウィンドウがあった場合にはさらに何かあるのかもしれませんが、とりあえず。
以上をまとめると、WM_DPICHANGEDのlParamは、
  • ウィンドウをドラッグしてモニター間を移動させたときは、カーソル位置を中心としてリサイズし、リサイズしても元のモニターに戻らない位置の四角形を示す。
  • ウィンドウをリサイズして属するモニターを変えたときは、元のウィンドウと同じ四角形を示す。
  • ディスプレイ設定でDPIを変えたときは、左上角を固定してリサイズした四角形を示す。
ということになります。Microsoftにはしっかりドキュメント化してくれよと言いたいところ。

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を利用する)。
2番目の課題については、色々試しましたが、どうも原因が絞り込めませんでした。現象としてははっきりしていて、ドラッグ中の忙しいときにリサイズすると設定どおりに描画されず、そのままスルーされることがある、というものです。これを確実に避けるにはドラッグ終了後にリサイズすればいいのですが、それも面白くないので試行錯誤しましたが、確実に抑え込める方法は分かりませんでした。

そこで次善の策として、ドラッグ開始時にウィンドウのサイズを内部的に記録しておき、ドラッグ中はその値をベースにリサイズの計算をし(ドラッグ中のウィンドウのサイズは信用しない)、最後の砦としてドラッグ終了後にもう一度リサイズ処理を行う(既に正常にリサイズされていれば何も変わらない)ことにしました。なお、リサイズしても大丈夫な位置までウィンドウが移動されなかった場合は、次の移動の機会に処理を継続します。

コーディングに当たっては、先達の実装例を参考にさせていただきました。
とくにぐらばくさんの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番目では元のサイズのままです。
次にこの状態からDPIを120に戻した結果が以下の12番目。
  • これは9番目に対応しますが、9番目ではlParamの示すサイズはDPIを上げた分だけ拡大されてましたが(元の125%)、この12番目では元の約156%になっています。
これらの数字がどうして出てきたかは前後のDPIなど関係する数字から計算すれば分かるだろうと思いますが、それぐらいなら初めから自分で計算した方が早いんですよね。

ということで、静止中にDPIが変わったときはlParamをそのまま使ってもいいかと思ってましたが、やはり他の場合と同じく自分で計算した方がいいようです。

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に関する説明を増やした。

2013/09/08

スクリーン座標とPer-Monitor DPI

Per-Monitor DPI環境下でウィンドウのスクリーン座標を取得する際に気づいたことがあったので、検証してみた。

1. 検証アプリ


少し前にWindows 8.1 PreviewのPer-Monitor DPI環境下でDPI仮想化(DPI Virtualization)のかかったウィンドウのスクリーン座標を正確に取得できないことがあるのに気づいたので、検証アプリを作ってみた。
  • 上2つの"Chaser"は下の"Runner"のスクリーン座標を追いかけるためのアプリで、
    • "Process Name"にあるプロセス名のプロセスを見つけて、そのウィンドウの座標をWin32のGetWindowRect関数を使って表示する("GetWindowRect Left"がウィンドウの左上角のX座標、"GetWindowRect Top"が同じくY座標)。
    • "Process DPI Awareness"はそのプロセスのDPI Awarenessを示す。
    • タイトルバーにはこのChaser自身のDPI Awarenessを表示。

  • 下の"Runner"はドラッグして動かしてみるためのアプリで、
    • Runner自身の座標をWin32のGetWindowRect関数とForm.Locationプロパティを使って表示する(これらは常に一致する、はず)。
    • "Return Zero"はスクリーンの原点(左上角)にウィンドウを移動させる。原点はいかなる場合でも一致することの確認用。
    • "Move One Right"はウィンドウをドラッグによらず、右に座標1つ分だけずらす。
    • タイトルバーにはこのRunnerのDPI Awarenessを表示。
このChaserとRunnerはそれぞれ同じコード(下記参照)で、マニフェストファイルでDPI Awarenessだけ変えたものを用意した。

2. 検証結果


モニターは、1680x1050(メイン)と1024x768(セカンド)のマルチモニター。

これを元の96DPIの環境から、Per-Monitor DPI環境(メインは120DPIになり、セカンドは96DPIのまま)に設定後、サインアウトまたは再起動する前の、新しいDPIがまだデスクトップに反映されてない状態で、Chaser (Per-Monitor Aware)とChaser (System Aware)を起動しておく。

ここでRunner (System Aware)を起動し、メインからセカンドに移る直前まで動かした状態が以下。
DpiLocTest 96(120)-96 System Aware 1

正確な座標を示しているのはChaser (Per-Monitor Aware)で、Runner (System Aware)はDPI仮想化がかかっていて、拡大された分だけ座標は小さな値になっている。Chaser (System Aware)はRunner (System Aware)と同じで、正確でない。なお、Chaser (System Aware)にDPI仮想化がかかっているが、検証には関係ない。

このまま右にドラッグするとセカンドに移るが、敢えてMove One Rightで座標1つ分だけずらすとこうなる。
DpiLocTest 96(120)-96 System Aware 2

Runner (System Aware)のウィンドウが左上に飛んでいる。「何これ?」という感じだが、DPI仮想化の効果が切れ、座標も元に戻って、そこに移動している。三者の示す座標は同じ。普通にウィンドウを動かしている限りは陥らない状態だが、システム的にはこうなるということ。

この状態、あるいは最初の状態からドラッグして右に動かし、セカンドの左端に合わせたところ。
DpiLocTest 96(120)-96 System Aware 3

メインの幅は1680なので、三者とも座標は正確。ということは、Runner (System Aware)とChaser (System Aware)にとっては、座標が大きくジャンプしたことになる。その間の座標はどこに行ったかというと、上の状態のとおり。

さて、次にRunner (Per-Monitor Aware)を起動して、同じようにメインからセカンドに移る直前まで動かす。
DpiLocTest 96(120)-96 Per-Monitor Aware 1

Chaser (Per-Monitor Aware)とRunner (Per-Monitor Aware)は正確な座標を示しているが、Chaser (System Aware)は先ほどと同じく小さくなった値を示している。

同じようにMove One Rightで座標1つ分だけずらす。
DpiLocTest 96(120)-96 Per-Monitor Aware 2

Runner (Per-Monitor Aware)はそのまま波乱なく移動。Chaser (System Aware)は値がジャンプした。

このままセカンドの左端まで動かす。
DpiLocTest 96(120)-96 Per-Monitor Aware 3

三者とも正確な座標で一致。

これで結論は見えてきたが、確認のため一旦サインアウトして新しいDPIがデスクトップに反映された状態にし、同じようにChaser (Per-Monitor Aware)とChaser (System Aware)を起動しておく。

まずRunner (System Aware)を起動。
DpiLocTest 120-96 System Aware 1

ここまでは三者とも一致して正確。

Move One Rightで座標1つ分だけずらす。
DpiLocTest 120-96 System Aware 2

またRunner (System Aware)のウィンドウが飛んでいる。その座標をChaser (Per-Monitor Aware)は正確に捉えているが、Runner (System Aware)とChaser (System Aware)はそのまま右にずれたかのように認識している。ウィンドウにはDPI仮想化が縮小する方向にかかっている。

セカンドの左端まで動かす。
DpiLocTest 120-96 System Aware 3

Chaser (Per-Monitor Aware)は正確だが、Runner (System Aware)の座標はDPI仮想化で縮小した分だけ大きな値になっている。Chaser (System Aware)も同様。

最後にRunner (Per-Monitor Aware)を起動。
DpiLocTest 120-96 Per-Monitor Aware 1

ここまでは三者とも一致。

Move One Rightで座標1つ分だけずらす。
DpiLocTest 120-96 Per-Monitor Aware 2

Chaser (Per-Monitor Aware)とRunner (Per-Monitor Aware)は至って正確だが、Chaser (System Aware)は座標がジャンプした。

セカンドの左端まで動かす。
DpiLocTest 120-96 Per-Monitor Aware 3

Chaser (Per-Monitor Aware)とRunner (Per-Monitor Aware)は正確で、Runner (Per-Monitor Aware)にDPI仮想化もかかっていないが(当然)、Chaser (System Aware)の座標は大きくなった値のままずれている。

3. まとめ


以上から、とりあえずの結論。
  1. アプリのDPI AwarenessがPer-Monitor Awareに設定されていない場合、Per-Monitor DPI環境下ではそのプロセスで実行したGetWindowsRect関数が正確な値を返さないことがある。これは対象のプロセスが自分のアプリか他のアプリか(そして、その、他のアプリがPer-Monitor Awareに設定されているか否か)には関係ない。

  2. これは対象のアプリにDPI仮想化がかかっているか否かにも直接関係ない(この存在を示唆するものだとは思うが)。

  3. GetWindowsRect関数以外でも同様のことは起こり得る(GetClientRect関数でも起こることは確認)。

  4. したがって、ケースバイケースだが、自分のアプリのUIをPer-Monitor DPIに合わせて積極的に変えたりしない場合でも、スクリーン座標に関係する処理があればPer-Monitor Awareに設定した方が安全、と思われる。
初めはバグかとも思ったが、Per-Monitor Awareに設定すれば問題は起きないので、まあ仕様なのかと。RTMで変わるかもしれないが。

[追記]

Windows 8.1 RTMで試した結果も同じだったので、これが仕様らしいと確定。ついでに、Previewでは動作の怪しかったSetProcessDpiAwarenessが正しく機能するようになっていた。

4. コード


検証アプリは.NET Framework 4.5のWindowsフォームアプリで、Visual Studio 2013 Previewで作成。言語はC#。

Chaserの主要部分。
private Timer timerChaser;
private Process processRunner = null;

private void ChaserForm_Load(object sender, EventArgs e)
{
    timerChaser = new Timer();
    timerChaser.Tick += new System.EventHandler(this.CheckRunner);
    timerChaser.Interval = 400;
    timerChaser.Enabled = true;
}

private void CheckRunner(object sender, EventArgs e)
{
    if (String.IsNullOrWhiteSpace(this.textBox_ProcessName.Text)) return;

    GetProcess(this.textBox_ProcessName.Text);

    CheckLocation();
    CheckDpiAwareness();
}

// Get process of Runner.
private void GetProcess(string nameRunner)
{
    if (!String.IsNullOrWhiteSpace(nameRunner))
    {
        try
        {
            processRunner = Process.GetProcessesByName(nameRunner).FirstOrDefault();
        }
        catch (Exception ex)
        {
            Debug.WriteLine("Failed to get process. " + ex.Message);
        }
    }
}

// Check location of Runner.
private void CheckLocation()
{
    try
    {
        if ((processRunner != null) &&
            (processRunner.MainWindowHandle != IntPtr.Zero))
        {
            W32.RECT rct;

            bool result = W32.GetWindowRect(processRunner.MainWindowHandle, out rct);

            if (result)
            {
                this.textBox_GetWindowRectLeft.Text = rct.Left.ToString();
                this.textBox_GetWindowRectTop.Text = rct.Top.ToString();
            }
            else
            {
                throw new Win32Exception(Marshal.GetLastWin32Error());
            }
        }
        else
        {
            this.textBox_GetWindowRectLeft.Text = "";
            this.textBox_GetWindowRectTop.Text = "";
        }
    }
    catch (Exception ex)
    {
        this.textBox_GetWindowRectLeft.Text = "Failed";
        this.textBox_GetWindowRectTop.Text = "Failed";

        Debug.WriteLine("Failed to get window rectangle. " + ex.Message);
    }
}

// Check DPI awareness of Runner.
private void CheckDpiAwareness()
{
    if (!OsVersion.IsEightOneOrNewer()) return;

    try
    {
        if ((processRunner != null) &&
            (processRunner.Handle != IntPtr.Zero))
        {
            W32.PROCESS_DPI_AWARENESS awareness;

            int result = W32.GetProcessDpiAwareness(processRunner.Handle, out awareness);

            if (result == 0) // If S_OK
            {
                this.textBox_DpiAwareness.Text = DpiAwareness.NameAwareness(awareness);
            }
            else
            {
                throw new Exception(result.ToString());
            }
        }
    }
    catch (Exception ex)
    {
        this.textBox_DpiAwareness.Text = "Failed";

        Debug.WriteLine("Failed to get DPI awareness. " + ex.Message);
    }
}
Runnerの主要部分。
private void RunnerForm_Move(object sender, EventArgs e)
{
    CheckLocation();

    this.textBox_LocationX.Text = this.Location.X.ToString();
    this.textBox_LocationY.Text = this.Location.Y.ToString();
}

// Check location of this Runner.
private void CheckLocation()
{
    IntPtr handleThis = Process.GetCurrentProcess().MainWindowHandle;

    if (handleThis != IntPtr.Zero)
    {
        try
        {
            W32.RECT rct;

            bool result = W32.GetWindowRect(handleThis, out rct);

            if (result)
            {
                this.textBox_GetWindowRectLeft.Text = rct.Left.ToString();
                this.textBox_GetWindowRectTop.Text = rct.Top.ToString();
            }
            else
            {
                throw new Win32Exception(Marshal.GetLastWin32Error());
            }
        }
        catch (Exception ex)
        {
            this.textBox_GetWindowRectLeft.Text = "Failed";
            this.textBox_GetWindowRectTop.Text = "Failed";

            Debug.WriteLine("Failed to get window rectangle. " + ex.Message);
        }
    }
}

private void button_ReturnZero_Click(object sender, EventArgs e)
{
    this.Location = Point.Empty;
}

private void button_MoveOneRight_Click(object sender, EventArgs e)
{
    this.Location = new Point(this.Location.X + 1, this.Location.Y);
}
完全なコードはここからダウンロードできる。

2013/09/02

WindowsフォームとPer-Monitor DPI

Windows 8.1のPer-Monitor DPIに対応するための基本情報を元に、実際にWindowsフォームアプリを対応させる作業に関して。なお、Windows 8.1 Previewに基づくものなので、RTMでは変更があり得る。

[追記] RTMでOS側に変更があったので、続を加えて再構成した。

1. 3つのDPI


Windowsフォームのスケーリングに関する出発点としては以下を参照。この中の「現在の自動スケーリングのサポート」は.NET Framework 2.0の場合として書かれているが、自分が試した範囲では.NET Framework 4.0でも同じだった。
これを踏まえて要点をまとめると、以下の3つのDPIを考慮する必要がある。
  • デザイン時のDPI

    概要 : Visual StudioのデザイナーでUIをデザインしたとき(最後にデザイナーで変更を加えたとき)のDPI。FormのAutoScaleDimensionsプロパティの値。下手な訳では意味が不明瞭になるので、MSDNの説明を引けば、"this property will be set by the Windows Forms designer to the value your monitor is currently using"で、"the AutoScaleDimensions property represents the design-time reference dimensions of the design environment for the current control"となる。

    取得方法 : デザイン時のDPIが100%なら96。実行時にはAutoScaleDimensionsはPerformAutoScaleメソッドによりCurrentAutoScaleDimensionsと同じ値になるので(AutoScaleModeプロパティがDpiの場合)、実際のデザイン時の値は取得できない。あえて実行時に取得するなら対象Formをインスタンス化だけして読み取るなど。Visual Studioで直接確認するには、対象Formのデザイナーファイルを見ればいい。

  • 実行時のDPI(以下、仮に「共通DPI」)

    概要 : 実行時の、全モニター共通のDPI。従来のDPIといえば、これのこと。FormのCurrentAutoScaleDimensionsプロパティの値。MSDNの説明を引けば、"the current run-time dimensions of the screen"で、"The CurrentAutoScaleDimensions property represents the reference dimensions on the current screen"となる。

    取得方法 : CurrentAutoScaleDimensionsから取得。Win32のGetDeviceCapsからLOGPIXELSXまたはLOGPIXELSYフラグで取得したものと、自分が試した範囲では同じだった。CurrentAutoScaleDimensionsが内部的に参照しているものも出所は同じではないかと思う。

  • 実行時のDPI(Per-Monitor DPI用)(以下、仮に「個別DPI」)

    概要 : 実行時の、個別モニターのDPI。Windows 8.1以降のみ。96、120、144、192の4段階(現在のところ)。

    取得方法 : Win32のGetDpiForMonitorから取得するか、WM_DPICHANGEDが来たときは、それに含まれているものを見る。
従来のWindowsフォームの高DPI対応(System DPI Aware)では、基本的にFormのAutoScaleModeをDpiに設定するだけでよかった。これには内部的にデザイン時のDPIと実行時の共通DPIが使われている。

で、Per-Monitor DPI環境での対応(Per-Monitor DPI Aware)はどうかとなると、BUILD 2013のプレゼンテーション(Making Your Desktop Apps Shine on High-DPI Displays)の際、Windowsフォームの対応方法について質問した人が素気なく却下されたのを見るに、Microsoftが何か用意してくれそうな感じではないので、実行時の個別DPIを見て自分で実装することになる。

2. 実装


以下は、従来どおりAutoScaleModeをDpiに設定して、その上にPer-Monitor DPI対応を重ねる形で実装する方法。つまり、CLRにSystem DPI Aware対応をさせた上でPer-Moniter DPI対応を加えることになる。AutoScaleModeをNoneにして、全ての場合のスケーリングを自分で実装する方がシンプルになるかもしれないが、副作用がよく分からなかったので。言語はVB。

2.1 コントロールの位置・サイズ


冒頭のMSDNの説明にあるとおり、PerformAutoScaleは内部的にScaleメソッドを使っている。これは親コントロールから子コントロールに波及していくので、一番の親であるFormにScaleメソッドを使えばいい。この引数にはその前の状態からの倍率を入れる。

例えば、デザイン時のDPIが96、実行時の共通DPIが120、個別DPIが144の場合、アプリの起動時に自動的にPerformAutoScaleで96から120へ1.25倍の拡大が行われる。そこから個別DPIへは120から144へ1.2倍に拡大する必要があるので、以下のようにSizeF構造体にしてScaleを実行する。
Me.Scale(New SizeF(1.2F, 1.2F))
実行中に個別DPIが変わったときは、その前の状態から現在の個別DPIへの倍率を同様に適用すればいい。

ちなみに、これをPerformAutoScaleで行うことはできない。というのは、PerformAutoScaleは内部的にCurrentAutoScaleDimensionsを参照してこれに合わせるメソッドになっていて、CurrentAutoScaleDimensionsはReadOnlyプロパティなので外部から変更できないので。

2.2. フォント


フォントの方は結構トリッキー。

フォントサイズの倍率


第一に、フォントサイズを直接指定はできない。どういうことかというと、コード内で指定されたサイズから、デザイン時のDPIから実行時の共通DPIへの倍率で自動的に拡大されたサイズで表示される。

例えば、デザイン時のDPIが96で実行時の共通DPIが120の場合、コード内で9ポイントと指定されたフォントは1.25倍に拡大されて11.25ポイントで表示される。ここでサイズを参照しても9ポイントのまま。つまり、この表示のスケーリングはサイレントに行われ、コードから直接感知できない。

さらに、コントロールのPerformAutoScaleは基本的にアプリの起動時に行われるだけだが、このフォントのスケーリング効果は実行中ずっと続いている。RPGに例えるなら、基本ステータスは変わらないまま、ずっと拡大のバフ(魔法効果)がかかっているようなもの。

したがって、目的のサイズで表示させるには、このサイレントに行われるスケーリングの倍率を差し引いた倍率で拡大してコード内で指定する必要がある。といっても難しいことはなくて、実行時の共通DPIから個別DPIへの倍率をサイズに掛ければいいだけ。これが1.2倍の場合、以下のようにFontを設定し直す。
Me.Font = New Font(Me.Font.FontFamily, 
                   Me.Font.Size * 1.2F, 
                   Me.Font.Style)
実行中に個別DPIが変わったときは、コントロールと同じくその前の状態から現在の個別DPIへの倍率を適用すればいい。

対象とするコントロール


第二に、フォント設定の対象とするコントロールの問題がある。デザイン時にコントロールのフォント設定をしていない場合(デザイナーでFontプロパティが太字でない状態)、実行時には親コントロールのフォント設定が使われる。したがって、全フォントが同じであればFormのフォント設定だけすればよく、実行時にサイズを変えるときもそれだけを対象にすれば足りる。

が、実際のアプリではそういうわけにも行かないと思うので、以下のようなことが起こる。
  • 個別のコントロールによってフォント設定をした場合、そのコントロールは親コントロールのフォント設定には影響されない(当然)。よって、実行時にサイズを変えるにはそのコントロールを直接対象にする必要がある。

  • ならば、Form中の全コントロールを対象に舐めるようにフォント設定していけばいいかといえば、そうも行かない。デザイン時にフォント設定されていないコントロールには、親コントロールにかけられたスケーリングと直接かけられたスケーリングの両方の効果がかかってしまう。例えば、親コントロールのフォント設定で1.2倍にした後で、子コントロールのフォント設定で1.2倍にすると、合わせて1.44倍の拡大がかかる。
これを回避するには、うまく対象のコントロールを抽出する必要がある(デザイン時に全コントロールに個別にフォント設定する方法は避けたい)。

大まかには以下の流れになると思うが、これ一発でという方法はたぶんない。
  1. Form中のコントロールを配列かコレクションの形で再帰的に取得する(必要であれば、世代を限定する)。

  2. 取得した配列かコレクションからLINQなどでコントロールの型や特定の条件によって抽出する。
例として、Form中のButtonのみを個別にフォント設定している場合。GetChildInControlは引数のコントロールを親とする全コントロールを取得するメソッドで、この中からButtonの型で抽出している。
For Each b As Button In GetChildInControl(Me).OfType(Of Button)()
    b.Font = New Font(b.Font.FontFamily, 
                      b.Font.Size * 1.2F, 
                      b.Font.Style)
Next
別に難しくはないが、うまくやろうとすると頭を使わないといけないという意味で面倒な部分。

2.3 動的なコントロール


実行中に動的にコントロールを作成したり、位置・サイズを変更したりする場合は、
  • 位置・サイズについては、デザイン時の位置・サイズをコード内に持っておいて、デザイン時のDPIから個別DPIへの倍率を掛けるのが簡単だと思う。

  • フォントについては、同じくデザイン時のフォントをコード内に持っておいて、実行時の共通DPIから個別DPIへの倍率を掛ければいい。
掛ける倍率が違うのに注意。

なお、文字表示に必要なコントロールのサイズを求めるのにTextRenderer.MeasureTextメソッドを使うことがあると思うが、これは表示のスケーリングがかかった状態のサイズが取得できるので、とくに手を加える必要はない。ただし、一旦これでサイズを設定した後に個別DPIが変わってScaleメソッドをかけた場合、新しいDPIで取得したTextRenderer.MeasureTextのサイズと微妙にずれが出てくるので、再設定した方がいいかもしれない。

ちなみに、タイトルバーのアイコンと通知領域のアイコンについては、表示される大きさが共通DPIに従って固定されているようなので、変更する意味はとりあえずない。

2.4 正しくスケーリングされないコントロール


一部のコントロールはScaleメソッドを実行しても正しくスケーリングされない。自分が直接知っている例は以下のもの。
  • TextBoxをMultilineにしたときの高さ
  • ListViewをDetailsにしたときのColumnHeaderの幅
弥縫策的には個別にスケーリングした値を与えてやれば済むが、問題を修正したカスタムコントロールを作成してもいいかもしれない。

ということで、TextBoxの高さの方は色々試してみたが、オーバーロードできるメソッドではなかなか難しくて、諦めた(SetBoundsCoreメソッドを使えば何とかなるかもしれない)。が、実はこれにはデザイン時にできる簡単な回避策があって、Panelなどのコンテナコントロールに入れてDockプロパティをFillにすればいい。Panelは正しくスケーリングされるので、中のTextBoxのサイズも正しくスケーリングされるようになる。

ちなみに、これらはScaleメソッドの問題なので、従来の高DPI対応でも発生する。

3. WM_DPICHANGEDの変更


Windows 8.1 RTMではWM_DPICHANGEDが来るタイミングが変更されたので、これへの対応方法を変える必要がある。以下、にて。

2013/07/04

Per-Monitor DPIに備える

Windows 8.1 PreviewにおけるDPIの変化に関して。

1. 背景


Windows 8.1ではマルチモニターにしているときに、モニターごとに違うDPIで使えるようになった。これはユーザーがDPI(scaling level)を大ざっぱに指定するとOSの方で自動的に設定するもので、個別に手動設定できるわけではない。具体的には、「ディスプレイ」設定でスライダーをSmallerからLargerの間で動かして指定する(無段階ではない)。

下にある「Let me choose one scaling level for all my displays」をチェックすれば直接DPIを指定できるようになるが(これも無段階ではない)、その場合DPIは共通になるので、モニターごとに変えることはできない。

どうせならモニターごとに全て手動設定できるようにして欲しい気もするが、推測すれば、大多数のユーザーにとっては自動設定の方がよいと判断した、あるいは細かいカスタマイズを許すとUIの動作検証で死ぬ、という事情があるのかなと思う。アプリを作る側としても後者は無視できない問題なので。

ともかく、デスクトップアプリはこうしたモニターごとに違うDPIに対応できるように、というのがMicrosoftからの宿題になる。

2. コーディング


基本情報としては以下のとおり。
この中でキーになるのは以下のもの。
  • DPIの変化を知らせるウィンドウメッセージ: WM_DPICHANGED
  • モニターごとのDPIを取得するAPI: GetDpiForMonitor
  • アプリがDPI Awareであることを示すマニフェストの拡張: 「Per Monitor」と「True/PM」
で、現時点で出ている情報は基本的にC++向けなので、これをVBで書いたWindowsフォームアプリから試してみた。環境はWindows 8.1 Preview上のVisual Studio 2012 Expressで、対象のフレームワークを.NET Framework 4 Client ProfileとしたWindowsフォームアプリをVisual Basicで作成。

WM_DPICHANGED


このウィンドウメッセージのID番号はhiyohiyoさんに教えていただいて0x02E0と分かったので(Visual Studio 2013 PreviewのSDK中のWinUser.hにある)、後はLOWORDとHIWORDのマクロをメソッド化して作成。
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 = GetLoWord(m.WParam.ToInt32)
        Dim hi As Single = GetHiWord(m.WParam.ToInt32)

        Me.TextBox_LoWord.Text = lo.ToString()
        Me.TextBox_HiWord.Text = hi.ToString()

        'lParam
        Dim r As RECT = CType(Marshal.PtrToStructure(m.LParam, GetType(RECT)), RECT)

        Me.TextBox_Position.Text = String.Format("{0},{1}", r.top, r.left)
        Me.TextBox_Size.Text = String.Format("{0}x{1}", r.bottom - r.top, r.right - r.left)
    End If
End Sub

Private Function GetLoWord(dword As Int32) As Int16
    Return Convert.ToInt16(dword And &HFFFF)
End Function

Private Function GetHiWord(dword As Int32) As Int16
    Return Convert.ToInt16(dword >> 16)
End Function

<StructLayout(LayoutKind.Sequential)>
Private Structure RECT
    Private left As Integer
    Private top As Integer
    Private right As Integer
    Private bottom As Integer
End Structure

GetDpiForMonitor


これはまずアプリのウィンドウハンドルを取得し、次に現在のモニターのハンドルをMonitorFromWindowをP/Invokeで実行して取得し、その後にGetDpiForMonitorをP/Invokeで実行する流れで作成。
Private Sub Button_GetDpi_Click(sender As Object, e As EventArgs) Handles Button_GetDpi.Click
    'Get handle to this window.
    Dim windowHandle As IntPtr = Process.GetCurrentProcess().MainWindowHandle

    'Get handle to monitor that contains this window.
    Dim monitorHandle As IntPtr = MonitorFromWindow(windowHandle, MONITOR_DEFAULTTOPRIMARY)

    'Get DPI (If the OS is not Windows 8.1 or newer, calling GetDpiForMonitor will cause exception).
    Dim dpiX As UInteger
    Dim dpiY As UInteger
    Dim result As Integer

    Try
        result = GetDpiForMonitor(monitorHandle,
                                  Monitor_DPI_Type.MDT_Default, 
                                  dpiX, dpiY)

    Catch ex As Exception
        result = 1 'Not S_OK (= 0)
    End Try

    If (result = 0) Then 'If S_OK (= 0)
        Me.TextBox_dpiX.Text = dpiX.ToString()
        Me.TextBox_dpiY.Text = dpiY.ToString()
    Else
        Me.TextBox_dpiX.Text = "Failed"
        Me.TextBox_dpiY.Text = "Failed"
    End If
End Sub

<DllImport("user32.dll", SetLastError:=True)>
Private Shared Function MonitorFromWindow(ByVal hwnd As IntPtr,
                                          ByVal dwFlags As Integer) As IntPtr
End Function

Private Const MONITORINFOF_PRIMARY As Integer = &H1
Private Const MONITOR_DEFAULTTONEAREST As Integer = &H2
Private Const MONITOR_DEFAULTTONULL As Integer = &H0
Private Const MONITOR_DEFAULTTOPRIMARY As Integer = &H1

<DllImport("Shcore.dll", SetLastError:=True)>
Private Shared Function GetDpiForMonitor(ByVal hmonitor As IntPtr,
                                         ByVal dpiType As Monitor_DPI_Type,
                                         ByRef dpiX As UInteger,
                                         ByRef dpiY As UInteger) As Integer
End Function

Private Enum Monitor_DPI_Type As Integer
    MDT_Effective_DPI = 0
    MDT_Angular_DPI = 1
    MDT_Raw_DPI = 2
    MDT_Default = MDT_Effective_DPI
End Enum
MonitorFromWindowの第2引数とGetDpiForMonitorの第2引数の選択は要検討。なお、GetDpiForMonitorは「Shcore.dll」のない環境では当然ながら例外を起こす。

DPI Awareのマニフェスト


プロジェクトにマニフェストファイルを追加し、そこに<dpiAware>を記述。
<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
  <asmv3:windowsSettings
       xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">
    <dpiAware>Per Monitor</dpiAware>
  </asmv3:windowsSettings>
</asmv3:application>
ここの値を「Per Monitor」(ハイフンなし)か「True/PM」にしないとWM_DPICHANGEDは来ない模様。

3. テスト


このアプリの見た目は以下のとおりで、コード中の記述との対応関係は見れば分かると思う。「Get DPI」のボタンは手動でGetDpiForMonitorを実行するもの。最初にDPI120のモニター上にある状態。

これをドラッグしてDPI96のモニターに移すとこうなる。

さらにドラッグしてDPI120のモニターに戻すとこうなる。

まずは成功で、見ているとアプリのウィンドウがモニターの境界を半分過ぎたぐらいでWM_DPICHANGEDが来ている感じ。これで問題なしかというとそうでもなくて、スライダーを動かして「適用」したときにDPIが変わっても反応がなかったり、一旦サインアウトしないと効かないこともあったりして、「あれー?」というところはある。

ともあれ、以上のように割と少ない手間で実装はできると思う。その上でDPIに応じたUIを作り込むのはまた別問題だけど。

[追記] Windows 8.1のバージョンの取得

Windows 8.1のバージョン番号は6.3で、Windows 8の6.2からマイナー番号が1つ上がっている。これはverコマンドでも確認できる。

が、.NETのSystem.Environment.OSVersionで見ても、Win32のGetVersionExで見てもバージョンは「6.2.9200」と返ってくるので、「ベータだからか~」と思っていた(念のため、Visual Studio 2013 Previewでアプリを作成し、Visual Studioの中から実行するとちゃんとWindows 8.1のバージョンが返ってくる。ただし、同じアプリをVisual Studioの外から実行するとダメ)。

実際はそういうことではなくて、意図的なものだった。
GetVersionExはあえて古いバージョンを返すように変更されたということで、System.Environment.OSVersionについて言及はないが、内部的にGetVersionExを呼び出しているようなので同じ結果になる。

この対応策としては以下の二通り。
  1. 対応しているOSとしてWindows 8.1をマニフェストに追加する。
  2. Win32のVerifyVersionInfoを使う。
VerifyVersionInfoはOSに関係なく使えるが、こちらから提示したバージョンに対して正否を返してくるAPIなので、少しだけ面倒。一方、マニフェストへの追加はこの問題と関係なくやるだろうし、この方が正攻法、かつコード量もごく少なくて済む。

具体的には、以下のようなもの。
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
  <application>
    <!-- Windows 7 -->
    <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
    <!-- Windows 8 -->
    <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
    <!-- Windows 8.1 -->
    <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
  </application>
</compatibility>
これでSystem.Environment.OSVersionも、GetVersionExも普通に「6.3.9431」を返してくるようになった。というわけで、OSのバージョンを見てPer-Monitor DPI対応を切り替えることができる。

一応VerifyVersionInfoを使った方も。Windows 8.1以降か否かを判別可能。
Private Sub CheckEightOneOrNewer()
    'Set expected OS version.
    Dim osvi As New OSVERSIONINFOEX()
    osvi.dwMajorVersion = 6 'Major version number
    osvi.dwMinorVersion = 3 'Minor version number
    osvi.dwOSVersionInfoSize = Convert.ToUInt32(Marshal.SizeOf(osvi))

    'Set condition mask (equal to or newer than designated OS version).
    Dim cm As UInt64 = 0
    cm = VerSetConditionMask(cm, VER_MAJORVERSION, VER_GREATER_EQUAL)
    cm = VerSetConditionMask(cm, VER_MINORVERSION, VER_GREATER_EQUAL)

    'Perform VerifyVersionInfo.
    Dim result As Boolean = VerifyVersionInfoW(osvi,
                                               VER_MAJORVERSION Or VER_MINORVERSION,
                                               cm)

    If (result = True) Then
        MessageBox.Show("OS is Windows 8.1 or newer.")
    Else
        If (Marshal.GetLastWin32Error() = 1150) Then 'If ERROR_OLD_WIN_VERSION
            MessageBox.Show("OS is older than Windows 8.1.")
        Else
            MessageBox.Show("Failed to check OS version.")
        End If
    End If
End Sub

<DllImport("kernel32", SetLastError:=True)>
Private Shared Function VerifyVersionInfoW(ByVal osvi As OSVERSIONINFOEX,
                                           ByVal dwTypeMask As UInt32,
                                           ByVal dwlConditionMask As UInt64) As <MarshalAs(UnmanagedType.Bool)> Boolean
End Function

<DllImport("kernel32", SetLastError:=True)>
Private Shared Function VerSetConditionMask(ByVal dwlConditionMask As UInt64,
                                            ByVal dwTypeBitMask As UInt32,
                                            ByVal dwConditionMask As Byte) As UInt64
End Function

<StructLayout(LayoutKind.Sequential)>
Private Structure OSVERSIONINFOEX
    Public dwOSVersionInfoSize As UInt32
    Public dwMajorVersion As UInt32
    Public dwMinorVersion As UInt32
    Public dwBuildNumber As UInt32
    Public dwPlatformId As UInt32
    <MarshalAs(UnmanagedType.ByValTStr, SizeConst:=128)>
    Public szCSDVersion As String
    Public wServicePackMajor As UInt16
    Public wServicePackMinor As UInt16
    Public wSuiteMask As UInt16
    Public wProductType As Byte
    Public wReserved As Byte
End Structure

Private VER_MINORVERSION As UInt32 = &H1
Private VER_MAJORVERSION As UInt32 = &H2
Private VER_BUILDNUMBER As UInt32 = &H4
Private VER_PLATFORMID As UInt32 = &H8
Private VER_SERVICEPACKMINOR As UInt32 = &H10
Private VER_SERVICEPACKMAJOR As UInt32 = &H20
Private VER_SUITENAME As UInt32 = &H40
Private VER_PRODUCT_TYPE As UInt32 = &H80

Private VER_EQUAL As Byte = 1
Private VER_GREATER As Byte = 2
Private VER_GREATER_EQUAL As Byte = 3
Private VER_LESS As Byte = 4
Private VER_LESS_EQUAL As Byte = 5
Private VER_AND As Byte = 6
Private VER_OR As Byte = 7

2012/12/29

High DPI Cursor Changer

High DPI Windows 8 Cursor Setで作成したカーソルファイルの導入と設定を自動化するツールとして、High DPI Cursor Changerを作成しました。Windows 8でDPIを200%以上に上げたときにカーソルの輪郭が汚くなる問題に対応するものです。

DPIを200%にしたときの見た目はこんな感じです。標準のカーソルに似せていますが、全てスクラッチで、個人的に気になった点は変えたりもしているので、微妙に違いがあります。

現状、DPIを200%以上にしている人は少ないかもしれませんが、おいおい需要が増えてくるものと予想して。

プロジェクトサイト: High DPI Cursor Changer 英語 / 日本語 at SourceForge.net
実行ファイル from SourceForge.net

2012/08/26

High DPI Windows 8 Cursor Set

Windows 8のDPIを200%に設定した場合の問題は、結局RTMでも修正されてなかった。それはさて置き、そもそもこの問題はプログラムに問題があるというより、アイコンあるいはカーソルの画像リソースが足らないだけということに気づいた。

以下はRTM評価版のエクスプローラを見たところだが、ウィンドウのデザインは変わったが、カーソルと上向き矢印の問題は変わってない。

リンクなど他の状態のカーソルも同様。

この問題は実際にMacBook Pro RetinaにWindows 8 RTMをインストールし、DPIを200%にしたときにも確認されている。

[参考] 例
TechRepublic: Combining Windows 8 and a Retina MacBook

で、Windows 8でアイコンの表示を確かめていたときに、この輪郭が汚くなった状態は、アイコンファイルに現在のDPIで表示されるべきサイズの画像が含まれておらず、それより小さなサイズの画像が引き伸ばされて表示されたときの状態と同じことに気づいた。

いや、引き伸ばされたからといって直ちに形状が崩れる必然性はないが、引き伸ばす際に縦横比が微妙に狂ったりして、一言でいえばOSによる画像の拡大が下手なのだと思う(縮小する方は上手なのだが)。

ともかく、そういうことなら本来表示されるべきサイズ(200%なら64x64)の画像をファイルに含めてやればいいわけで、そういうカーソルファイルを作成してみた(エクスプローラの上向き矢印の方は、システムファイルに埋め込まれたリソースを使っているようで、これをいじるのは面倒なので諦めた)。

以下は作成したカーソルに変えた場合の表示。

輪郭がこのDPIにしては少し細いような気もするが、とりあえずこんなところ。

[追記1] Animated Cursor Packer

3つ以上のサイズを持つアニメーションカーソルファイルを作成できる既存のアプリがなかったので、作成したもの。サイズ別に分かれていたものを統合できれば、DPIによってファイル指定を変える必要もなくなるので。

アニメーションカーソルはRIFFファイル形式の一種だが、複数のサイズを持たせる方法が見つからなかったのでOS標準のものを覘いたところ、ファイル構造は単一のサイズだけのアニメーションカーソルと変わらず、中に含まれる個々のカーソルデータが複数のサイズの画像を持っているかどうかの違いだけのようだった。

それならと、カーソルデータの作成は他のアプリに任せ、カーソルデータ(実際はアニメーションなしのカーソルファイルそのまま)をまとめてアニメーションカーソルファイルにする部分だけを作ったのがこのアプリ。元のカーソルファイルの中身は関知しないので、単一のサイズだけのカーソルファイルを元にすれば単一のサイズのアニメーションカーソルファイルが出来、複数のサイズのカーソルファイルを元にすれば同じ複数のサイズのアニメーションカーソルが出来るという具合い。

アニメーションカーソルを作成できる既存のアプリは、カーソルデータの作成とアニメーションカーソルファイルにまとめることの両方をやるわけだが、これを分離することで、複数のサイズのカーソルファイルを用意すれば同じ複数のサイズのアニメーションカーソルファイルが作成できるようになり、サイズの数に制限がなくなるというのがポイント。

……と書きつつ、Microsoftの資料にないことなので確証はないが(アニメーションカーソルに関する情報はWin95の頃で止まっていて、その後複数のサイズを持つアニメーションカーソルが出てきてもアップデートされておらず、複数のサイズを持たせるための情報がない状態)、とりあえずうまく行っているようだからよしとする。

[参考] アニメーションカーソルのRIFFファイル形式に関する数少ない包括的な説明
O'Reilly: Microsoft RIFF
("Size is the number of bytes in the subchunk that appear after the Size field. This value is always 32."にある32は、36の間違いだと思う。この10進数に対応する位置の16進数は24なので)

[追記2]

High DPI Cursor Changerの方に統合した。