ToolStripButtonとSpy++とClick-Through

今も昔も、Windows GUIを使ったアプリケーションのトラブルでやっかいなものの一つは、その最大の特徴であるWindows Messageと、そこから発火されるイベントに纏わるものであることに異論は無いだろう。

先日、.NET Framework 2.0、新たにWindows Formsに加わったコントロールであるToolStripと、その上に配置するToolStripButtonについての問題が発見されたので調べてみたのだが、これがバグなのか、仕様なのか判らなかったのだ。

  • 現象

他のアプリケーションにフォーカスがある時に、当該のWindowsFormsアプリケーションに配置された、ToolStrip上のToolStripButtonをクリックしても、クリックできない(クリック時に動作するはずの処理が動作しない)

試してみると、確かにそのような現象が発生する。アプリケーションがアクティブでは無い場合に、ToolStripButtonとして配置されたボタンのシンボルをマウスのボタンで押下しても、アプリケーションはアクティブになるのだが、ボタンをクリックしたことにならないのである。

  • 問題の確認

昔からWindows GUIのメッセージ、イベント関連の現象を確認するツールといえばMicrosoft Spyだろう。

今は"Microsoft Spy++"という名前の、このツールを引っ張り出して、久しぶりに調べてみることにした。

Spy++にはキャプチャしたWindowsMessageのログを記録・保存することができる。普通のボタンコントロール(System.Windows.Forms.Button)を押下した場合、以下のようなログになるはずだ。

<00032> 00310956 S WM_MOUSEACTIVATE hwndTopLevel:003809A2 nHittest:HTCLIENT uMsg:WM_LBUTTONDOWN
<00033> 00310956 R WM_MOUSEACTIVATE fuActivate:MA_ACTIVATE
<00036> 00310956 P WM_LBUTTONDOWN fwKeys:MK_LBUTTON xPos:33 yPos:17
<00039> 00310956 P WM_LBUTTONUP fwKeys:0000 xPos:33 yPos:16

この時、アプリケーション内でのマウスボタンイベントは以下の順で発生している。

MouseDown -> Click -> MouseUp

ちなみに、マウスメッセージ以外はキャプチャ対象から外し、更に不要なメッセージ(WM_SETCURSOR等)のログは削除しているため、番号が飛んでいる。WindowsMessageに詳しい方であれば、飛び飛びのメッセージであっても、これがどのような操作の結果、発生したメッセージかは、大体想像がつくことだろう。

一方、今度は同様の操作をToolStripに対して行ったログを見てみる。(問題となっているのはToolStripButtonだが、こいつはメッセージ処理をToolStripに任せている、ハンドルを持たないコントロールなので、メッセージをキャプチャすることはできないのだ)

<00029> 003B0A22 S WM_MOUSEACTIVATE hwndTopLevel:00350956 nHittest:HTCLIENT uMsg:WM_LBUTTONDOWN
<00030> 003B0A22 R WM_MOUSEACTIVATE fuActivate:MA_ACTIVATEANDEAT
<00041> 003B0A22 P WM_LBUTTONUP fwKeys:0000 xPos:17 yPos:9

一目瞭然だが、通常のボタンではポストされていたメッセージ"WM_LBUTTONDOWN"が、何故かToolStripではポストされていない。ReceiveしたWM_MOUSEACTIVATEメッセージのパラメタがMA_ACTIVATEではなく、MA_ACTIVATEANDEAT(マウスイベントを破棄する)になっており、これが原因ぽいことが判る。なお、このときにToolStripButtonのMouseUpとMouseDown、Clickイベントをトレースしたみたが、どれも発生していなかった。やはり、この現象が発生している時には、マウスメッセージの処理が完全に抜けているようだ。

正直、これがバグなのか想定内なのかは、私には解らない。何故ToolStripはアクティベートされる際にMA_ACTIVATEANDEATが設定されているのだろう。
技術的なことを抜きにして、アプリケーションのユーザは、通常のボタンもツールストリップ上のボタンも同じ「ボタン」という認識して使っている以上、同じように振舞うようにしなければならないのだけは確かだろう。
(ボタンのようにフォーカスを持つことだけは無理。アイテムの一つずつがフォーカスを持たなくてはならないのであれば、ToolStripは使うべきではないだろう)

  • 対処

対処としては、ポストされなかったメッセージをポストしてやれば良いのだが、WindowsFormsの一般的なAPIにはWindowsMessageを直接送出するものは無い。無論、P/Invokeを使えばどんなことでもできるのだが、それはマネジド-C#の場合は最終手段とすべきだろう。
今回の場合、問題になっているのは「他のアプリケーションがアクティブだった際」に限られており、その他の処理には問題がなさそうなので、新たな問題が発生するまではアドホックに、現象だけを対象とすることとした。

  • FormのActivatedイベントを処理する

他のアプリケーションからフォーカスが移ってきたのを検知するにはActivatedイベントを処理すれば良い。以下のサンプルコードで試してみた。
Form1というクラス名のFormにはtoolStrip1という名前のToolStrip、更にその上にtoolStripButton1という名前のToolStripButtonを配置してある。Activatedイベントの処理方法はいろいろあるだろうが、今回はForm1のコンストラクタに匿名デリゲートで書いた。以前の、オーバロードされたオペレータを使ってデリゲート変数に追加する書き方は、参照が余計に増えるのと、代入するためだけのメソッドを書かなければならず、例え、多少効率が落ちるとしても使う気がしないのだ。

public Form1()
{
    InitializeComponent();

    this.Activated += delegate(object sender, EventArgs e)
    {
        Point point = this.toolStrip1.PointToClient(Cursor.Position);
        ToolStripItem item = this.toolStrip1.GetItemAt(point);
        if (item == this.toolStripButton1)
        {
            if (this.toolStripButton1..Bounds.Contains(point))
            {
                MouseEventArgs args = new MouseEventArgs(MouseButtons.Left, 1, point.X, point.Y, 0);
                this.toolStripButton1.PerformClick();
            }
        }
    };
}

これで、対象のフォームがアクティブになった際に、ToolStripButtonのClickイベントが強制的に処理されるので万々歳かと思ったのだが...これでは駄目なことが判った。

  • MouseDown メッセージを正しく処理する

通常、マウスイベントを処理できるコントロールがClickイベントを発火するには、

1. コントロールのクライアント矩形上でマウスのボタンを押下する
2. 同、コントロールのクライアント矩形上でマウスのボタンから指を離す(押上る)

これらのイベントが順に発生する必要があるのだが、上のサンプルコードだと1.の処理を満たしただけでClickを処理していることになり、2.のイベントの発生を認識していない。当然だが、MouseDownとMouseUpイベントも発火しない。
あと、重要こととして、通常のButtonコントロールでは、ボタンを押下した状態で、マウスをクライアント矩形外に移動してボタンから指を離しても、Clickしたとは見なされないのだが、そのような振る舞いが実現できないのである。(どうやら、PerformClickメソッドはClickを実行するだけであり、それ以前にMouseDownとMouseUpを発生させるものではないらしい。)

ならばと思い、ToolStripButtonではなく、コンテナであるToolStripコントロールに対してマウス処理を強制してみようと、変更したのが以下のコードである。(Form上では、ToolStripを拡張したクラスである、ExtToolStripを使うように変更する必要がある)

public class ExtToolStrip : ToolStrip
{
    internal void PerformMouseDown()
    {
        Point point = this.PointToClient(Cursor.Position);
        MouseEventArgs args =
            new MouseEventArgs(MouseButtons.Left, 1, point.X, point.Y, 0);
        this.OnMouseDown(args);
    }
}

public Form1()
{
    InitializeComponent();

    this.Activated += delegate(object sender, EventArgs e)
    {
        Point point = this.toolStrip1.PointToClient(Cursor.Position);
        ToolStripItem item = this.toolStrip1.GetItemAt(point);
        if (item != null)
        {
            this.toolStrip1.PerformMouseDown();
        }
    };
}

今度は上手くいった。このコードによりSpy++のログが変わることは無いが、マウスイベントは通常のボタン同様に、以下の順で発生するようになるのを確認できる。

MouseDown -> Click -> MouseUp

Spy++のログで判明していた通り、ポストされていなかったWM_LBUTTONDOWNの代替として、フォームのアクティブ時にToolStripのOnMouseDownを呼び出すことでMouseDownが処理され、MouseDownが処理されることで、その後のMouseUpも処理され、更にClick処理も動いたのである。これでやっと期待した動作になった。
ToolStripクラスを拡張する必要が出たが、結果としてToolStripButton上にマウスカーソルがあるかどうかの判定を省けるようになったので、コード自体は却ってシンプルになったと思う。

日記で何度となく書いているが、.NETではデリゲートによるイベントの外部への委譲が可能になり、更にversio 2.0からは動的なコード生成や、匿名デリゲートにより、コントロールクラスの処理を動的に拡張する手段が多く提供されるようになったため、カスタムコントロールを継承する必要性は減っている。なもので、できるだけコントロールの継承クラスは作りたくは無かったのだが、ToolStripのOnMouseDownメソッドはprotectedであり、直接呼び出すことは適わなかったために、今回は仕方が無かった。

.NETのプログラミングを始めたときには、もうWindowsMessageなんて意識しなくてもいいんだろうな、とせいせいしたものだったが、結局こんなものだ。必要であれば、いつでもクラシックな知識は引っ張り出さなくてはならない。


追記1

エントリ書いていて気が付いたんだけど(これが良くあることなんだ)、ToolStripを拡張するならば、もっと素直にWndProcをオーバライドして、MA_ACTIVATEANDEATを無効にしてしまえばよいのではないか。

public class ExtToolStrip : ToolStrip
{
    const uint WM_MOUSEACTIVATE = 0x21;
    const uint MA_ACTIVATE = 1;
    const uint MA_ACTIVATEANDEAT = 2;

    protected override void WndProc(ref Message m)
    {
        base.WndProc(ref m);
        if ( m.Msg == WM_MOUSEACTIVATE && m.Result == (IntPtr)MA_ACTIVATEANDEAT)
        {
            m.Result = (IntPtr)MA_ACTIVATE;
        }
    }
}

ビンゴ。テストもばっちりだし、FormのActivatedイベントの補足も、もう不要だ。こっちのほうがシンプルというか、何より「何が問題で、対処として何をしているか」が明確だし。(どうして最初からここに到達できないのだろう)

Spy++のログも以下のように、通常のButtonと同様の"Click-Through"な振る舞いとなった。(MA_ACTIVATEANDEATが、MA_ACTIVATEに変わっているのが判るだろう)

<00032> 00020AD8 S WM_MOUSEACTIVATE hwndTopLevel:00030AAE nHittest:HTCLIENT uMsg:WM_LBUTTONDOWN
<00033> 00020AD8 R WM_MOUSEACTIVATE fuActivate:MA_ACTIVATE
<00036> 00020AD8 P WM_LBUTTONDOWN fwKeys:MK_LBUTTON xPos:189 yPos:10
<00038> 00020AD8 P WM_LBUTTONUP fwKeys:0000 xPos:189 yPos:10

追記2

今回、問題の現象としてきた

アクティブなウインドウがあって、そこから他のウインドウをアクティブにする際に、ボタン等のウィジェットがマウスクリックを即座に実行できる

この振る舞いを"Click-Through"と呼ぶらしい。(Web広告に対する用語とは違う)
ちなみに、この振る舞いだが、Mac OSではデフォルトのウインドウの振る舞いでは無いそうで、これが必要と思われる場合にサポートされたウインドウが持つ特別な振る舞いらしい。

Apple Human Interface Guidelines
Window Behavior - Click-Through


追記3

Mac OSではそうだったように、"Click-Through"が却って不要なケースもあるだろう。せっかくToolStripを拡張したのだから、プロパティを追加して"Click-Through"を制御できるようにするのがベターだろう。

public class ExtToolStrip : ToolStrip
{
    const uint WM_MOUSEACTIVATE = 0x21;
    const uint MA_ACTIVATE = 1;
    const uint MA_ACTIVATEANDEAT = 2;

    private bool enableClickThrough = true;

    public bool EnableClickThrough
    {
        get { return this.enableClickThrough; }
        set { this.enableClickThrough = value;}
    }

    protected override void WndProc(ref Message m)
    {
        base.WndProc(ref m);
        if ( this.enableClickThrough 
            && m.Msg == WM_MOUSEACTIVATE && m.Result == (IntPtr)MA_ACTIVATEANDEAT)
        {
            m.Result = (IntPtr)MA_ACTIVATE;
        }
    }
}

これで完成かな。
なんか、こうなってくると普通のボタンもClick-Throughを制御できるべき、なんて思ってしまうのが不思議なところ。