2011年11月10日木曜日

wpf : 他のウィンドウを除外してヒットテスト

昨日の投稿、今読んだら日本語になってないところがあって訳分かりませんなぁ。 まぁ、書き直すのは面倒なのでコードを読んで察してください。 今回の投稿はその続きで、MainWindowの表示中の領域にマウスカーソルがある場合反応して、他のウィンドウの上にマウスカーソルがある場合除外するというヒットテストのコードを作成します。

昨日、「これやったら、もしかしたら他のウィンドウを除外できるかもしれないね?」ということで挙げた3点を試してみました。

  1. VisualTreeHelper.HitTestを使う。
  2. レンダリング領域やクリッピング領域のようなものが得られるメソッド/プロパティを探す。
  3. win32sdkのWindowFromPointを使ってカーソルが対象ウィンドウ上にあるか確かめる。

1はUIElement.InputHitTestと同じ動作でダメでした。 2のクリッピング領域も用途が違うらしくダメ、レンダリング領域の取得方法は検索で見つかりませんでした。 3のWindowFromPointは成功。 このやり方が最善なのかはわかりませんが、とりあえず3のやり方でヒットテストのコードを書いて整理してみました。

まずは昨日MouseHook.csとして書いたコードの書き換え。 win32sdkのWindowFromPointと、ついでに動作確認用のGetWindowTextのインポートを追加しました。 マウスフック以外のコードが増えたのでファイル/クラス名をWin32dllに変更。 書き換えた部分だけ載せると、

// Win32dll.cs
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;

namespace HitTestTest
{
    public class Win32dll
    {
        public const int STRING_BUFFER_LENGTH = 1024;

        ...省略

        [DllImport("user32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.StdCall)]
        private static extern int GetWindowText(IntPtr hWnd, string lpString, int cch);

        [DllImport("user32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.StdCall)]
        public static extern IntPtr WindowFromPoint(int x, int y);

        public static string GetWindowText(IntPtr hWnd)
        {
            string text = new string((char)0, STRING_BUFFER_LENGTH);
            int len = Win32dll.GetWindowText(hWnd, text, text.Length);
            if (len == 0)
                return null;
            else
                return text.Substring(0, len);
        }

        ...省略
}

MainWindow.xaml.csはこうなりました。

// MainWindow.xaml.cs
using System;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Imaging;

namespace HitTestTest
{
    public partial class MainWindow : Window
    {
        private IntPtr _hWnd;
        private UIElement _hitTestTarget;

        public MainWindow()
        {
            InitializeComponent();

            img.Source = BitmapFrame.Create(new Uri("test.png", UriKind.Relative));
            img.Opacity = 0.5;

            Window dummyWindow = new Window();
            dummyWindow.WindowStyle = WindowStyle.ToolWindow;
            dummyWindow.Title = "モードレスダイアログの遮蔽テスト";
            dummyWindow.Topmost = true;
            dummyWindow.Width = 60;
            dummyWindow.Height = 60;
            dummyWindow.Show();

            //_hitTestTarget = this;
            _hitTestTarget = clientArea;
            //_hitTestTarget = img;
        }

        private void OnSourceInitialized(object sender, EventArgs e)
        {
            HwndSource hwndSource = (HwndSource)HwndSource.FromVisual(this);
            _hWnd = hwndSource.Handle;

            try
            {
                Win32dll.Hook(MouseHookProc);
            }
            catch
            {
                MessageBox.Show(this, "マウスフック失敗");
                Close();
            }
        }

        private void OnClosed(object sender, EventArgs e)
        {
            Win32dll.UnHook();
        }

        // 引数で受け取るのをコントロール上の座標からスクリーン座標に変更
        private void HitTest(Point screenPoint)
        {
            string p = string.Format("({0:f2},{1:f2})", screenPoint.X, screenPoint.Y);
            label.Content = "null : " + p;

            Point clientPoint = _hitTestTarget.PointFromScreen(screenPoint);
            DependencyObject elem = _hitTestTarget.InputHitTest(clientPoint) as DependencyObject;

            IntPtr w = Win32dll.WindowFromPoint((int)screenPoint.X, (int)screenPoint.Y);

            if (w == _hWnd)
            {
                while (elem != null)
                {
                    if (elem == label)
                    {
                        label.Content = "label : " + p;
                        break;
                    }
                    else if (elem == img)
                    {
                        label.Content = "img : " + p;
                        break;
                    }
                    else if (elem == clientArea)
                    {
                        label.Content = "clientArea : " + p;
                        break;
                    }
                    else
                    {
                        label.Content = "[" + elem.GetType().FullName + "] : " + p;
                    }

                    elem = LogicalTreeHelper.GetParent(elem);
                }
            }
            else if(w != IntPtr.Zero)
            {
                string title = Win32dll.GetWindowText(w);
                if (title == null)
                {
                    label.Content = "?window : " + p;
                }
                else
                {
                    label.Content = "@" + title + " : " + p;
                }
            }
        }

        public void MouseHookProc(int x, int y)
        {
            HitTest(new Point(x, y));
        }
    }
}

wpfはWin32コンテンツなどをホストしないかぎりウィンドウのみがHWNDを持っていて、子コントロールにはHWNDがありません。 WindowFromPointで持ってきたHWNDとMainWindowのHWNDを比べれば、MainWindowの表示中の領域にマウスカーソルがあるのか、他のウィンドウの上にマウスカーソルがあるのかがわかります。

というわけでMainWindowのHWNDが必要です。 これはHwndSourceで得られます。 HwndSource.FromVisualが動くのはSourceInitializedイベント以降なので、そこでHWNDをプライベート変数(_hWnd)に記憶しています。 HitTestの中は_hWndとカーソルが指すウィンドウのHWNDが同じかどうかで分岐しました。 MainWindowの表示領域内の場合は昨日のコードと同じ動作、その他の場合はカーソルが指しているウィンドウのタイトルを表示するようになっています。

場合わけが正常に動いているのを確認。 とりあえず、目的は達成ですね。

そういえば、当初ペイントソフトどうこうと言っていたんですよね。 試しにImageコントロールを追加したんでした。 面倒なのでxamlのコードは載せませんが、ImageはLabelと違ってUIElement.InputHitTestで検出できましたよ...っと。

最後に、ラベルへのデバッグ出力を無くしたヒットテストだけをするクラスを載せておきます。

// HitTester.cs
using System;
using System.Windows;
using System.Windows.Interop;

namespace HitTestTest
{
    class HitTester
    {
        private IntPtr _hWnd;
        private UIElement _hitTestTarget;

        // SourceInitialized以降に作成すること
        public HitTester(UIElement hitTestTarget)
        {
            _hitTestTarget = hitTestTarget;
            HwndSource hwndSource = (HwndSource)HwndSource.FromVisual(hitTestTarget);
            _hWnd = hwndSource.Handle;
        }

        public bool Test(Point screenPoint)
        {
            Point clientPoint = _hitTestTarget.PointFromScreen(screenPoint);
            DependencyObject elem = _hitTestTarget.InputHitTest(clientPoint) as DependencyObject;

            if (elem != null)
            {
                IntPtr w = Win32dll.WindowFromPoint((int)screenPoint.X, (int)screenPoint.Y);
                return w == _hWnd;
            }
            else
            {
                return false;
            }
        }
    }
}

長々と書いたけど、本来欲しかったコードはこれだけか...