に投稿 4件のコメント

[Qt4] QDockWidgetのアクティブ動作をWindows標準にする

Qtプログラミングをやっていて、MFCよりも手軽にドッキングペインを持つソフトが作成できることに日々感謝しています。しかし、Windows上で使用する場合に限り、ちょっとだけ動作が不自然な部分があります。

それは、QDockWidgetでドッキングしたウィンドウをフローティングした時のフレームのアクティブ状態がWindowsアプリケーションの本来の動作と異なるという点です。

●Qtアプリの動作を観察してみる

 次のファイルをダウンロードして解凍し、uiファイルをQtDesignerで開いてみてください。

mainwindow.zip

 そして、FormeメニューのPreview…でこのUIを表示してみましょう。これがドッキングウィンドウを装備したメインウィンドウの一例です。

ここまでは何ら問題はありません。では、右にドッキングしている「1」と「2」のウィンドウのタイトルバーをドラッグしてフローティングさせてみてください。2つともフローティングさせたら、メインウィンドウの何処かをクリックしてください。

すると、次の図のようになります。多少ウィンドウサイズを小さくしました。

 Windowsをずっと使っている人は、この画面を見て「何か変だな??」と感じると思います。

 一般の人が「〇〇が変だ」と直接指摘できるほどではないと思いますが、違和感を感じる人は多いでしょう。その理由を徐々に説明します。

 では、「1」のウィンドウのタイトルバーをクリックしてみてください。

すると、上記のように「1」のウィンドウにだけ色が付きます。この時、メインウィンドウの右上のボタンの色が透明になっています。

次に「2」のウィンドウのタイトルバーをクリックしてみてください。

 このようになります。

●何がWindows標準と異なっているのか?

 上の図で、何処が本来のWindowsアプリと異なるのか、もう気づいた人は多いでしょう。まだ、気付かない人のために、私が現在MFCで同時開発中のAniLaPaint ver1.3の画面をつぎに示します。

 これが、Windows標準のドッキング可能ツールウィンドウがフローティングした時の状態となります。ウィンドウのタイトルバーの色にご注目!

 そうです。「×」ボタンが全て赤色になっていますよね。これがWindowsのGUIの約束事なのです。アクティブなアプリケーションのツールウィンドウ(タイトルバーが若干細い付属的なウィンドウ)のアクティブ状態は、常にメインウィンドウと同じでなければならないのです。

 他のアプリをクリックして、メインウィンドウが非アクティブになったら、ツールウィンドウも全て非アクティブにならなければなりません。また、ツールウィンドウのどれか一つをユーザがクリックしてアクティブ化したら、メインウィンドウや他のツールウィンドウもアクティブ化させなければなりません。

 Qtの場合、そのお約束が守られていません。

●本題! Qtアプリにこの動作を実装してみる

 最初にそのアイデアが書かれている掲示板を教えてくれた、 @takumiasaki 様にはこの場を借りて御礼申し上げます。

 では、Qt製Windowsアプリにこの動作を実装してみましょう。数行のコード追加で完了です。なお、ここでは説明のためQApplicationのサブクラスをMyApp名で説明します。この部分は貴方の作ったクラス名に置き換えて理解して下さい。

 貴方が適当にQDockWidgetを利用して作ったサンプルアプリのソースファイルを、次の手順で一部分コードを追加してみてください。

 まず、myapp.hファイルを開いて下さい。(貴方のアプリ名で置き換えたhファイル)

#ifdef Q_WS_WIN
    #include <QColor>
#endif //Q_WS_WIN

まだQColorのヘッダーをインクルードしていない場合は、上記のように書いて下さい。

次に、MyAppの宣言内にメンバを追加して下さい。

#ifdef Q_WS_WIN

    virtual bool winEventFilter(MSG *message, long *result);

private:

    QColor savedInactiveButtonText_;

#endif //Q_WS_WIN

 winEventFilterは、QCoreApplicationの仮想関数でQ_WS_WINの時にのみ実装されます。今回は、この関数をオーバーライドし、この関数内で全てを処理します。savedInactiveButtonText_は正しい非アクティブなメニューバーのテキストの色を覚えておくために使用するメンバ変数です。

 次に、myapp.cpp に以下のコードを追加して下さい。

#ifdef Q_WS_WIN
#include <QDebug>
#include <QFrame>
#include <QMainWindow>
#include <QMenuBar>
#include "Windows.h"

bool MyApp::winEventFilter(MSG *message, long *result)
{
    switch(message->message){

    case WM_ACTIVATEAPP:

        if (QWidget * widget = QWidget::find(message->hwnd)){

            // トレース表示
            wchar_t text[64];
            int size = GetWindowText(message->hwnd, text, 64);
            qDebug() << "WM_ACTIVATEAPP\tHWND =" << message->hwnd
                        // widget->windowText()だと旨くキャプションが取れない場合があるのでWin32API使用
                     << "\tCaption =" << QString::fromWCharArray(text, size)
                     << "\tActivate =" << static_cast<bool>(message->wParam);

            // メニューバーの色を変える
            QMainWindow * mainWindow = qobject_cast<QMainWindow *>(widget);
            if (mainWindow){
                QMenuBar * menuBar = mainWindow->menuBar();
                if (menuBar){
                    QPalette palette = menuBar->palette();

                    // QMenuBar の色を変更する
                    if (message->wParam == 1){ // アクティブになる
                        if (!savedInactiveButtonText_.isValid()){

                            // 本来の色を保存しておく
                            savedInactiveButtonText_ =
                                    palette.color(QPalette::Inactive,
                                                  QPalette::ButtonText);

                            // トレース表示
                            qDebug() << "Save MenuBar Inactive button text color"
                                     << savedInactiveButtonText_;

                            // パレットのボタンテキストを非アクティブ色==アクティブ色にする。
                            QColor newColor = palette.color(QPalette::Active,
                                                            QPalette::ButtonText);
                            palette.setColor(QPalette::Inactive,
                                             QPalette::ButtonText,
                                             newColor);
                            menuBar->setPalette(palette);

                            // トレース表示
                            qDebug() << "Change MenuBar Inactive button text color"
                                     << newColor;
                        }
                    } else { // 非アクティブになる
                        if (savedInactiveButtonText_.isValid()){
                            // 保存されている非アクティブ色を元に戻す。
                            palette.setColor(QPalette::Inactive,
                                             QPalette::ButtonText,
                                             savedInactiveButtonText_);
                            menuBar->setPalette(palette);

                            // トレース表示
                            qDebug() << "Restore MenuBar Inactive button text color"
                                     << savedInactiveButtonText_;

                            // 2重防止に初期化
                            savedInactiveButtonText_ = QColor();
                        }
                        // 最後にアクティブだったウィンドウをトレース表示
                        qDebug() << "Last ActiveWindow ="
                                 << QApplication::activeWindow();

                        // Qtの仕様で非アクティブになるとき 0 にする。アクティブになった時の処理は
                        // Qtの別のメッセージで処理されいるので、このソースでは何も処理してありません。
                        QApplication::setActiveWindow(0);
                    }
                }
            }

            // フレームのアクティブ状態を変える
            // lPalam はWM_NCACTIVATEの物とは違うので、-1にして送りません。
            *result = DefWindowProc(message->hwnd,
                                    WM_NCACTIVATE,
                                    message->wParam,
                                    -1);
            return true;
        }
        break;

    case WM_NCACTIVATE:

        if (QWidget * widget = QWidget::find(message->hwnd)){

            // トレース表示
            wchar_t text[64];
            int size = GetWindowText(message->hwnd, text, 64);
            qDebug() << "   WM_NCACTIVATE\tHWND =" << message->hwnd
                        // widget->windowText()だと旨くキャプションが取れない場合があるのでWin32API使用
                     << "\tCaption =" << QString::fromWCharArray(text, size)
                     << "\tActivate =" << static_cast<bool>(message->wParam);

            // フレームのアクティブ状態を変える
            if (widget->isModal()){
                // widgetがモーダルウィンドウだった場合は、wParamがそのままじゃなくてはいけません。
                *result = DefWindowProc(message->hwnd,
                                        WM_NCACTIVATE,
                                        message->wParam,
                                        message->lParam);
            } else {
                // フレームを常にアクティブ状態の色に保つ
                *result = DefWindowProc(message->hwnd,
                                        WM_NCACTIVATE,
                                        TRUE,
                                        message->lParam);
            }
            return true;
        }
    }
    return false;
}

#endif //Q_WS_WIN

 以上で全てです。qDebug()の部分はQtCreaterのアウトプット画面に表示して動作を確認するための物です。qDebug()はリリースビルド時にも機能するので、完成版のビルドに含めたくない場合はコメントアウトするなどしてください。

 簡単にコードの意味を説明します。

 winEventFilter仮想関数は、QtがWindowsのWMメッセージを受け取って処理をする前に、開発者にその処理をゆだねるための関数です。return true;で返せば処理済みの物としてQtでは無視されます。return false;で返せば、Qtがメッセージを処理します。

 MSG構造体はWindows開発者ならおなじみの物なので説明しません。ここでは、WM_ACTIVATEAPP メッセージと、WM_NCACTIVATE メッセージを自前で処理します。

 これらのメッセージは、アクティブになるウィンドウと非アクティブになるウィンドウの両方に送られます。QWidget::findを使い、このアプリケーションに属するウィンドウへ向けたメッセージのみ処理を行います。

★WM_ACTIVATEAPP メッセージの処理

 これからアクティブになる場合、本来のメニューバーのパレットのうち非アクティブ(QPalette::Inactive)なメニューバーのテキストの色(QPalette::ButtonText)をメンバ変数savedInactiveButtonText_に保存し、替わりに非アクティブなテキスト色もアクティブなテキスト色と同じにしてしまいます。これは、Windowsアプリケーションのお約束の「アプリケーション自体がアクティブな間はメニューバーが常にアクティブ状態の色で表示する」を実現します。

 これから非アクティブになる場合は、savedInactiveButtonText_に保存しておいたメニューバーの色を元に戻します。これで、メインウィンドウが非アクティブな時にちゃんと非アクティブなメニューバーの色になってくれます。

 次に、アクティブ・非アクティブどちらでも、DefWindowProc を使い、実際のフレームのアクティブ状態(色など)を変更します。WM_ACTIVATEAPPが指し示す message->hwnd に対して、WM_NCACTIVATE メッセージを送ります。message->wParam は、アプリがアクティブに成ったか否かと同義です。なので、そのまま送ります。

 なお、元々のQtのインプリメントを確認すると、非アクティブになるときにのみ setActiveWidget(0) をコールしています。それ以外は、DefWindowProc にそのままメッセージを送っているだけのようです。

★WM_NCACTIVATE メッセージの処理

 message->wParamにアクティブまたは非アクティブの情報が来ます。しかし、Windowsのメッセージを鵜呑みにすると、ツールウィンドウがアクティブになったときにメインウィンドウは非アクティブになってしまいます。つまり、QtはWindowsのメッセージを正直に受け取ってしまったために、Windows準拠な動作にならなかったのです。

 正直者は馬鹿を見る…のようであります。このへんはWindowsの歴史的なグチャグチャした経緯がありそうな…。

 message->wParamが非アクティブであっても見なかった事にしてください。常にアクティブで行きましょう!

 よって、今回の実装の核心部分です。

 DefWindowProcの第3引数は常にTRUEを送るww

 これで万事解決。

 ……かと思いきや。実は例外的な処理が必要です。

 モーダルダイアログを表示した時にのみ必要な処理です。Windows は、モーダルダイアログを表示しているとき、そのアプリのメインウィンドウをクリックすると、モーダルダイアログが点滅するという鬼畜な仕様があります。上記のように常にTRUEにしてしまうと、当然のことですが…点滅しなくなります。したがって、メッセージ送り先のウィンドウがモーダルの場合、wParamの値をそのままDefWindowProcに送らなければなりません。

 なお、モーダルダイアログが表示されているときに、フローティングしたQDockWidgetをクリックした場合は無反応です。これでいいのか?と思う方がいるかも知れませんが、これでいいのだ!MFCもそうなってるので。

●プロジェクト&ソースファイル一式

 実際に動作する物を提示します。QtCreaterで開いてビルド&実行してみてください。

 なお、proファイルを見ると判りますが、QT += core gui になっています。core と gui だけだと、User32.libはリンクされません。なので自分でリンクするように LIBS += User32.lib と指示します。

 もし、貴方のソフトが core gui 以外のものもインポートしていたら、その中の処理で自動的に User32.lib がインポートされる可能性があります。つまり、User32.lib を指定するかどうかは、リンカーがエラーを吐いたら仕方なく指定するという方向で。

※筆者のテスト環境

Windows 7 Pro (x64)

VC10 x86 + VC10 x86 で自分でコンパイルした Qt4.7.3ライブラリ
VC10 x64 + VC10 x64 で自分でコンパイルした Qt4.7.3ライブラリ
minGW4.4 x86 + minGW4.4 x86 用のNokiaが配布している Qt4.7.3ライブラリ

上記3つのビルドで確認済み。

※ライセンス

このアーカイブに含まれているファイルは全てパブリックドメイン・ライセンスです。

※免責事項

このプログラムを使用した事によって起こるかも知れないいかなる不具合&機器の破損
などの責任を筆者が一切負わなくても良いものと了承した上で使用してください。
その限りにおいて、本ソースファイルはパブリックドメインであり、自由に改変および
再配布および一部分の使用が可能です。

貴方のソフトの構成によっては巧く機能しない可能性もあります。その場合は、これらの
ソースを改変し、とある場合に於ける対処策などを勝手に自前のブログなどで発表してく
れるとQtユーザのみんなが喜ぶでしょう。

DockTest.zip (6.22 KB)

[Qt4] QDockWidgetのアクティブ動作をWindows標準にする」への4件のフィードバック

  1. 追記:モードレス・ダイアログの場合はMFCではどうだったのか……。
    ツールウィンドウとは違ったような気もします。その辺色々な処理は適材適所で必要だと思います。

  2. 追記2:最初からフロートだった場合にうまくいきません。また、ドッキング時にタイトルバーをダブルクリックしてフロートした場合もうまくいきません。

    対処法:
    1)myapp.h に次のメンバを追加

    QSet savedNcCreateHWnd_;
    

    2)myapp.cpp の MyApp::winEventFilter 内の switch 文に以下のコードを追加。

        case WM_NCCREATE:
            qDebug() << "   WM_NCCREATE\tHWND =" << message->hwnd
                     << "\twParam(not used)"
                     << "\tlParam(CREATESTRUCT pointer) =" << (void *)message->lParam;
    
            savedNcCreateHWnd_.insert(static_cast<WId>(message->hwnd));
            return false;
    
        case WM_SHOWWINDOW:
            if (!savedNcCreateHWnd_.isEmpty()){
                WId id = static_cast<WId>(message->hwnd);
                if (savedNcCreateHWnd_.contains(id)){
                    savedNcCreateHWnd_.remove(id);
                    DefWindowProc(message->hwnd, WM_NCACTIVATE, TRUE, -1);
                }
            }
            return false;
    
        case WM_NCDESTROY:
            if (!savedNcCreateHWnd_.isEmpty()){
                WId id = static_cast<WId>(message->hwnd);
                if (savedNcCreateHWnd_.contains(id))
                    savedNcCreateHWnd_.remove(id);
            }
            return false;
    

    これで解決できるはずです。

  3. おのぎん、いきてますか。
    わしは、なんとか。

    まあ、かなり労働法には強くなったよ。
    今年後半に開業。
    ソフト屋の管理に船出するよ。

    1. わたしは、半年はアニメの仕事をし、半年はプログラムの試作&研究です。
      プログラムの方はまだ研究段階で、気に入る完成度が出せないのですが…、
      今年中に売れなさそうなソフト(AniLaPaint)をなんとか販売にこぎつけたい。
      それが今年の目標です。

      書き込み有難う。
      法律関係のお仕事頑張ってください、マンガも。
      では。

コメントは受け付けていません。