2011年11月17日木曜日

wpf : マウスキャプチャとマウスカーソルの変更

wpfでマウスキャプチャをするサンプルプログラムを作ってみました。 キャプチャするには、

  • MouseLeftButtonDownなどのイベントでCaptureMouse。マウスカーソルをそれっぽいものに変更。
  • MouseLeftButtonUpなどのイベントでReleaseMouseCapture。そのときの座標で目的の処理をする。
  • キャプチャー終了時にLostMouseCaptureイベントが発行されるので、そこでマウスカーソルを戻す。

とやります。 LostMouseCaptureイベントはユーザーの操作でキャプチャーが終了したときも、他の原因(他のウィンドウがアクティブになるなど)でキャプチャーが終了したときにも発行されます。 キャプチャーの結果をもとに行いたい処理はMouseLeftButtonUpイベントのタイミングでやって、LostMouseCaptureイベントではマウスカーソルの切り替えだけ行います。 マウスカーソルの変更は、

mainWindow.Cursor = Cursors.なんたら;

です。 これだけ。

これだけでは何なので、

  1. キャプチャ中のマウスカーソルを自作カーソルに変更。
  2. xamlで描いたマウスカーソルを実行時に読み込み。
  3. マウスキャプチャ中にマウスが指しているウィンドウの情報(タイトル、ウィンドウクラス、実行ファイル名)を得る。

もやってみました。 「ウィンドウの情報を得る」ってのはDllImportでやったのでwpfネタじゃあないんですが、ここで一番ハマってしまった...

まあとりあえず、長くなりそうなのでこの投稿ではマウスカーソル関係についてのみ書きます。 ウィンドウ情報のネタは次の投稿で。

wpfでマウスカーソルを変えるには、さっき書いたように

mainWindow.Cursor = Cursors.なんたら;

とします。 Cursorsに登録されていない絵柄には変更できないようです。 本質的にはwpfで自作カーソルに変更するのは不可能なので、見かけだけ変えることにします。 具体的には、こうします。

  1. マウスの左ボタン押下でキャプチャー開始。マウスカーソルを非表示にし、マウスカーソルの代わりにカーソルの絵だけを描いた透過ウィンドウ(以下カーソルウィンドウ)を表示。
  2. キャプチャー中のMouseMoveでカーソルの座標に従ってカーソルウィンドウを移動。
  3. マウスの左ボタンを離すとキャプチャー終了。そのときの座標で目的の処理をする。
  4. キャプチャー終了時にLostMouseCaptureイベントが発行されるので、そこでカーソルウィンドウを隠し、マウスカーソルの表示を戻す。

普段のマウスカーソルの変わりに透過ウィンドウを使うのは、色々不具合があるかもしれません。 しかし、キャプチャー中に限定するなら大丈夫でしょう。

透過ウィンドウをマウスカーソル代わりに使うということで、色々属性を変更しなければなりません。 xamlはこんな風になります。

<Window x:Class="MouseCaptureTest.MouseCursorWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        WindowStyle="None"
        AllowsTransparency="True"
        Background="Transparent"
        ResizeMode="NoResize"
        Topmost="True"
        ShowInTaskbar="False"
        SourceInitialized="OnSourceInitialized">
</Window>

中身は実行時に読み込むので空になっています。 xamlファイルを読み込むコードはこんな感じ。

string cursorFile = Directory.GetParent(Environment.GetCommandLineArgs()[0]).FullName + "\\cursor.xaml";
XmlReader xmlReader = XmlReader.Create(cursorFile);
Canvas cursorElement = XamlReader.Load(xmlReader) as Canvas;
_mouseCursorWindow.Content = cursorElement;
_mouseCursorWindow.Width = cursorElement.Width;
_mouseCursorWindow.Height = cursorElement.Height;
_mouseCursorWindow.Show();
_mouseCursorWindow.Visibility = Visibility.Hidden; // なぜか必須

このサンプルでは実行ファイルと同じフォルダにあるxamlファイルを読みにいきます。 動的に読み込んだ場合、なぜかVisibilityの設定が必要でした。 詳細は不明。

あと、セキュリティの方も調べてないので不明です。 「xamlに変なコードが混ざっていたらどうなるか」とか、頭の片隅ででも考えておいた方がいいかも?

Canvasとして読み込んでいるところから分かるように、自作マウスカーソルはxamlファイルにCanvasとして書きます。 このサンプルでは、アプリケーションがカーソルのサイズに合わせてレイアウトを変えるようにしてみました。 今回用意したカーソルはこんなヤツです。

<Canvas
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Width="48"
        Height="48"
        Opacity="0.5">
    <Ellipse Canvas.Left="5" Canvas.Top="5" Width="37" Height="37" Stroke="Black"/>
    <Ellipse Canvas.Left="6" Canvas.Top="6" Width="35" Height="35" Stroke="White"/>
    <Ellipse Canvas.Left="14" Canvas.Top="14" Width="19" Height="19" Stroke="White"/>
    <Ellipse Canvas.Left="13" Canvas.Top="13" Width="21" Height="21" Stroke="Black"/>
    <Line X1="5" Y1="5" X2="16" Y2="16" Stroke="Black"/>
    <Line X1="5" Y1="41" X2="16" Y2="30" Stroke="Black"/>
    <Line X1="41" Y1="5" X2="30" Y2="16" Stroke="Black"/>
    <Line X1="41" Y1="41" X2="30" Y2="30" Stroke="Black"/>
    <Line X1="0" Y1="23" X2="14" Y2="23" Stroke="White"/>
    <Line X1="32" Y1="23" X2="47" Y2="23" Stroke="White"/>
    <Line X1="23" Y1="0" X2="23" Y2="14" Stroke="White"/>
    <Line X1="23" Y1="32" X2="23" Y2="47" Stroke="White"/>
    <Line X1="18" Y1="23" X2="28" Y2="23" Stroke="Black"/>
    <Line X1="23" Y1="18" X2="23" Y2="28" Stroke="Black"/>
    <Line X1="20" Y1="20" X2="26" Y2="26" Stroke="White"/>
    <Line X1="20" Y1="26" X2="26" Y2="20" Stroke="White"/>
</Canvas>

一応、背景が白でも黒でも分かるようにしたつもり。

キャプチャのときのカーソルについて、こしきゆかしきこちらの作法にのっとった動作になっています。

引用すると、

「プログラムを実行すると左のような(引用注:二重丸バッテンが表示された)ウィンドウが出現します。 真ん中の二重丸にバッテン印にカーソルをあわせてドラッグします。 すると、この絵が消えてカーソルが二重丸バッテンになります。 ユーザーはこの絵がとれて、カーソルになったような錯覚に陥ります。 そして、タイトルを変更したいウィンドウのタイトルバーのところでボタンを離します。 すると・・ 『おおっ!VC++のタイトルが変わったぞ!』 ということになります。」

と、いうわけで「カーソルウィンドウ用」と「キャプチャしていないときの表示用」、2つの絵が必要になります。 XamlReaderで読み込んだCanvasは使い回しができないので、2回読み込まなければなりません。 たいした手間ではないけど、使いまわすことを考えると2回読むよりテンプレートを使った方が良かったかもしれませんね? まぁ今回はテンプレートについて調べるのが面倒だったのでコレで。

MainWindow.xamlはこんな感じになります。 ↓のcursorIconが「キャプチャしていないときの表示場所」です。

<Window x:Class="MouseCaptureTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MouseCaptureTest"
        SizeToContent="Height"
        Width="400"
>
    <StackPanel>
        <StackPanel Orientation="Horizontal">
            <Border
                    Name="cursorWaitingArea"
                    BorderThickness="1"
                    BorderBrush="Black"
                    Margin="4"
                    Padding="4"
                    Background="LightGray"
                    MouseLeftButtonDown="cursorWaitingArea_MouseLeftButtonDown"
                    MouseLeftButtonUp="cursorWaitingArea_MouseLeftButtonUp"
                    MouseMove="cursorWaitingArea_MouseMove"
                    LostMouseCapture="cursorWaitingArea_LostMouseCapture"
            >
                <Canvas Name="cursorIcon"/>
            </Border>
        </StackPanel>
        <Grid Margin="4">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="120"/>
                <ColumnDefinition/>
            </Grid.ColumnDefinitions>
            <Grid.RowDefinitions>
                <RowDefinition/>
                <RowDefinition/>
                <RowDefinition/>
            </Grid.RowDefinitions>
            <Label Grid.Row="0" Grid.Column="0">タイトル</Label>
            <TextBox Grid.Row="0" Grid.Column="1" Name="textBoxTitle" IsEnabled="False"></TextBox>
            <Label Grid.Row="1" Grid.Column="0">ウィンドウクラス</Label>
            <TextBox Grid.Row="1" Grid.Column="1" Name="textBoxWndClass" IsEnabled="False"></TextBox>
            <Label Grid.Row="2" Grid.Column="0">モジュール</Label>
            <TextBox Grid.Row="2" Grid.Column="1" Name="textBoxModuleName" IsEnabled="False"></TextBox>
        </Grid>
    </StackPanel>
</Window>

キャプチャ開始時に、キャプチャ対象のコントロールをHiddenしないように注意。 Hiddenにするとキャプチャが中断してしまいます。 ということで、キャプチャ対象およびマウスイベントの発行をするコントロールはcursorWaitingArea(Border)、キャプチャしていないときにカーソルと同じ絵を載せておくコントロールを子のcursorIcon(Canvas)と分けています。

キャプチャ開始時にはcursorIconをHidden。 間違えてcursorWaitingAreaの方をHiddenにするとキャプチャ中断になってしまいます。

そういえば、細かいことをけっこう書いたけどまだメインのコードを載せてませんでしたね。 MainWindow.xaml.csのコードはこうなりました。

// MainWindow.xaml.cs
using System;
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Markup;
using System.Xml;

namespace MouseCaptureTest
{
    public partial class MainWindow : Window
    {
        public const string MOUSE_CURSOR_FILE_NAME = "MouseCursor.xaml";

        private MouseCursorWindow _mouseCursorWindow;
        private bool _getExeFileNameFromLatterFunction;

        public MainWindow()
        {
            InitializeComponent();

            int osMajorVersion = Environment.OSVersion.Version.Major;

            _getExeFileNameFromLatterFunction =
                Environment.Is64BitOperatingSystem &&
                6 <= osMajorVersion;

            _mouseCursorWindow = new MouseCursorWindow();
            try
            {
                string cursorFile = Directory.GetParent(Environment.GetCommandLineArgs()[0]).FullName + "\\" + MOUSE_CURSOR_FILE_NAME;

                XmlReader xmlReader = XmlReader.Create(cursorFile);
                Canvas cursorElement = XamlReader.Load(xmlReader) as Canvas;
                _mouseCursorWindow.Content = cursorElement;
                _mouseCursorWindow.Width = cursorElement.Width;
                _mouseCursorWindow.Height = cursorElement.Height;
                _mouseCursorWindow.Show();
                _mouseCursorWindow.Visibility = Visibility.Hidden; // なぜか必須

                xmlReader = XmlReader.Create(cursorFile);
                UIElement iconElement = XamlReader.Load(xmlReader) as UIElement;
                cursorIcon.Children.Add(iconElement);
                cursorIcon.Width = _mouseCursorWindow.Width;
                cursorIcon.Height = _mouseCursorWindow.Height;
            }
            catch
            {
                MessageBox.Show(this, "マウスカーソルの読み込みに失敗");
                Close();
            }
        }

        private void cursorWaitingArea_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
        {
            _mouseCursorWindow.Position = GetScreenPosition(cursorWaitingArea, e);

            Cursor = Cursors.None;
            _mouseCursorWindow.Visibility = Visibility.Visible;
            cursorIcon.Visibility = Visibility.Hidden;
            cursorWaitingArea.CaptureMouse();
        }

        private void cursorWaitingArea_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
        {
            cursorWaitingArea.ReleaseMouseCapture();
            MessageBox.Show(this, textBoxTitle.Text + "を選択");
        }

        private void cursorWaitingArea_MouseMove(object sender, MouseEventArgs e)
        {
            if (!cursorWaitingArea.IsMouseCaptured)
                return;
            Point screenPos = GetScreenPosition(cursorWaitingArea, e);
            _mouseCursorWindow.Position = screenPos;
            CheckWindowFromPosition(screenPos);
        }

        private void cursorWaitingArea_LostMouseCapture(object sender, MouseEventArgs e)
        {
            _mouseCursorWindow.Visibility = Visibility.Hidden;
            cursorIcon.Visibility = Visibility.Visible;
            Cursor = Cursors.Arrow;
        }

        private Point GetScreenPosition(UIElement uiElem, MouseEventArgs e)
        {
            Point controlPos = e.GetPosition(uiElem);
            return uiElem.PointToScreen(controlPos);
        }

        private void CheckWindowFromPosition(Point screenPos)
        {
            IntPtr hWnd = Win32dll.WindowFromPoint((int)screenPos.X, (int)screenPos.Y);

            if (hWnd == IntPtr.Zero)
            {
                textBoxTitle.Text = "そんなウィンドウありません";
                textBoxWndClass.Text = "そんなウィンドウありません";
                textBoxModuleName.Text = "そんなウィンドウありません";
                return;
            }

            IntPtr hAncestorWnd = Win32dll.GetAncestor(hWnd, Win32dll.GetAncestorFlags.GA_ROOT);
            if (hAncestorWnd != IntPtr.Zero)
                hWnd = hAncestorWnd;

            string title, wndClass, moduleName;
            CheckWindowInfo(hWnd, out title, out wndClass, out moduleName);
            textBoxTitle.Text = title;
            textBoxWndClass.Text = wndClass;
            textBoxModuleName.Text = moduleName;
        }

        private void CheckWindowInfo(IntPtr hWnd, out string title, out string wndClass, out string moduleName)
        {
            title = Win32dll.GetWindowText(hWnd);
            if (title == null)
                title = "?[タイトル不明]";

            wndClass = Win32dll.GetClassName(hWnd);
            if (wndClass == null)
                wndClass = "?[ウィンドウクラス不明]";

            moduleName = GetExeFileName(hWnd);
            if (moduleName == null)
                moduleName = "?[実行ファイルパス不明]";
        }

        private string GetExeFileName(IntPtr hWnd)
        {
            IntPtr hProcess = IntPtr.Zero;
            try
            {
                uint processID = 0;
                Win32dll.GetWindowThreadProcessId(hWnd, out processID);
                hProcess = Win32dll.OpenProcess(Win32dll.ProcessAccessFlags.QueryInformation | Win32dll.ProcessAccessFlags.VMRead, false, processID);

                if (_getExeFileNameFromLatterFunction)
                {
                    return Win32dll.QueryFullProcessImageName(hProcess, false);
                }
                else
                {
                    IntPtr[] hModules = new IntPtr[1];
                    uint needed;
                    bool isModuleExist = Win32dll.EnumProcessModules(hProcess, hModules, (uint)IntPtr.Size, out needed);
                    if (!isModuleExist || needed == 0)
                    {
                        return null;
                    }
                    return Win32dll.GetModuleFileNameEx(hProcess, hModules[0]);
                }
            }
            catch(Exception exc)
            {
                Debug.WriteLine("[" + exc.Message + "] " + exc.StackTrace);
                return null;
            }
            finally
            {
                if (hProcess != IntPtr.Zero)
                {
                    Win32dll.CloseHandle(hProcess);
                }
            }
        }
    }
}

CheckWindowFromPosition以降のメソッドはDllImportネタなので次回の投稿で説明するとして、今説明しておくことは...

  • App.xamlでShutdownMode="OnMainWindowClose"
  • 座標変換

これだけかな?

ShutdownModeの方は「何かの拍子にMainWindowだけ閉じられて、カーソルウィンドウが残ってしまったら?」と考えてそうしました。 それだけ。

座標変換についてはちゃんとした説明が必要ですね。 キャプチャー中のMouseMoveで受け取る座標はコントロールを基準にした座標なのでそのまま使うことが出来ません。 UIElement.PointToScreenでスクリーン座標にしただけでもダメ。

以前の投稿で書いたとおり、UIElement.PointToScreenで出てくるスクリーン座標はデバイスピクセル単位になるっぽいです。 (確証するための資料は未発見。) そして、ウィンドウの座標(Left、Topプロパティ)は論理ピクセル単位。 イベントで受け取ったマウスの位置をそのままカーソルウィンドウに設定するとズレてしまいます。 デバイスピクセル座標 → 論理ピクセル座標の変換が必要になります。

というわけで、座標変換のコードはこちら。

このコード、単発ネタのつもりだったのに、自分のブログではやたらとリンクしているような...

まぁ、それはおいといて。 座標変換を仕込んだカーソルウィンドウMouseCursorWindow.xaml.csのコードはこうなります。 (MouseCursorWindow.xamlは↑に載せた空のヤツ。)

// MouseCursorWindow.xaml.cs
using System;
using System.Windows;
using System.Windows.Interop;

namespace MouseCaptureTest
{
    public partial class MouseCursorWindow : Window
    {
        private PixelScaleTransformer _trans;
        private Point _screenPosition;

        public MouseCursorWindow()
        {
            InitializeComponent();
        }

        private void OnSourceInitialized(object sender, System.EventArgs e)
        {
            HwndSource hWndSource = (HwndSource)HwndSource.FromVisual(this);
            IntPtr hWnd = hWndSource.Handle;

            uint exStyle = Win32dll.GetWindowLong(hWnd, Win32dll.GWL_EXSTYLE);
            Win32dll.SetWindowLong(hWnd, Win32dll.GWL_EXSTYLE, exStyle | Win32dll.WS_EX_TRANSPARENT);

            _trans = new PixelScaleTransformer(this);
            UpdatePosition();
        }

        // スクリーン座標を受け取る
        public Point Position
        {
            set
            {
                _screenPosition = value;

                if(_trans != null)
                {
                    UpdatePosition();
                }
            }
        }

        private void UpdatePosition()
        {
            // この座標変換方法が全ての環境で正しく動作するかは未確認
            Point windowPos = _trans.DeviceToWpf(_screenPosition);
            Left = windowPos.X - Width / 2.0;
            Top = windowPos.Y - Height / 2.0;
        }
    }
}

座標変換コード(PixelScaleTransformerクラス)は「1度表示したことのあるウィンドウへの参照が必要」という制約があります。 (正確に言うと、SourceInitializedイベント前に座標変換をすると例外が発生してしまう。) そんな制約があるため、ちょっとだけコードが複雑になっています。

あと、Get/SetWindowLongでカーソルウィンドウの拡張ウィンドウスタイル(GWL_EXSTYLE)にWS_EX_TRANSPARENTを追加しています。 多くのウィンドウスタイルはwpf上のプロパティのどれかをいじれば設定できます。 しかし、WS_EX_TRANSPARENTはwpfからでは設定できない模様。 win32sdkからの設定が必要になります。

単一のwpfアプリケーション内で完結する処理を書く場合は、ドラッグ&ドロップなどの元から組み込まれている機能を組み合わせればなんとでもなるので、WS_EX_TRANSPARENTは必要ないかな? このサンプルコードではカーソル下にある別のウィンドウをチェックしているので使っています。

肝心のWS_EX_TRANSPARENTの効果は、

  • マウスイベントが発行されなくなり、ヒットテストにも引っ掛からなくなる。
  • 再描画が必要になってもレンダリングのイベントが起きない。

というような感じ? マイクロソフトのページにWS_EX_TRANSPARENTの説明がいくつかあります。

WS_EX_LAYEREDとWS_EX_TRANSPARENTを組み合わせると効果が現れるようですね。 WS_EX_LAYEREDはおそらく、xamlでAllowsTransparency="True"と設定すれば付くはず。 WS_EX_TRANSPARENTはSetWindowLongで設定です。

こんなことをしているのは、Topmostの透過ウィンドウをカーソル代わりにするとヒットテストでそれしか見つからなくなるから。 WS_EX_TRANSPARENTを設定することで本来探したい、カーソルの下のウィンドウを探せるようになります。 再描画の制限については、キャプチャー中は気にしなくてもOKですね。

...ここまでで、マウスキャプチャーで自作カーソルを使う部分については説明終わりです。 wpfだけで動くアプリケーションを書くなら、ここまでの説明で十分 ... なはず。 次の投稿は、おまけのつもりで手を出して躓いたウィンドウ情報の取得部分について書きます。