[Qt][C++]QLineEdit編集中に常にPopupした状態になるWidgetを作る。

開発中のSlack過去ログビューワについての備忘録。アプリケーション本体は近日中に公開する。
あのアプリケーション中では、検索ボックスがクリック、文字入力等されている時は常にすぐ下に検索オプションを表示させるようにしている。が、この実装がそれはもうQt開発陣を皆○しにしたいくらい難しかったので、その方法をメモしておく。
この方法は5日間くらいコードを弄り倒す中でようやく見つけた方法なので、これが最善手であるかどうかは分からない。

f:id:thayakawa:20200921180754p:plain
検索ボックスとオプション

eventFilterについて

今回の実装で特に重要なのはeventFilterの振る舞いだ。eventFilterはQWigetを含むQObjectについて何らかの処理(マウス操作やキー入力だけでなく、WindowがActivateされたときなどあらゆる処理)を検知し、mousePressEventなどの関数が呼ばれるより先んじて呼び出される。もしここでfalseを返すと、イベントはまだ消費されていないと判断されmousePressEventなど個別の処理が呼ばれるようである。trueを返すとこのイベントは処理されたものと見做され、個別処理は呼び出されない。つまり、ユーザー自身がイベントの細かな振る舞いを上書きしてしまいたい場合に使う機能である。個別処理の仮想関数よりも自由度が高い上にタイミングが早いので、これを使うと非常に細かな実装が可能になる。

PopupWidget実装時のeventの取り扱い

検索ボックスの下に一時的にPopupWidget(検索オプションのWidgetのこと)を表示するというのは、つまり表示のトリガーとなる何らかのWidget(親Widgetと呼ぶことにする。検索ボックスのこと)が操作されたときにPopupWidgetをshow()し、然るべきときにhide()するということである。ただこのshow、hideのタイミングがブチ切れたくなるほど難しい。

通常、Widgetは自分自身に対する操作や描画などのイベントしか検知しないが、PopupWidgetのWindowFlagをQt::Popupにしているときは自分自身以外のクリックなども検知する。一般的にPopupWidetは別の何かを操作したときに消失するものなので、ここはQt::Popupを選択する必要がある1。 ただし、今回は親Widgetを操作している間はPopupWidgetを表示したままにしたいという要求があった。デフォルトの振る舞いでは親Widgetを操作した時点でPopupWidgetは消失してしまうので、これを何とかオーバーライドする必要がある。つまり例えば、eventFilterをオーバーライドし、親Widgetの操作を検知した場合は消失をキャンセルするためにtrueを返す、というような振る舞いにしなければならない。

だがここで問題が発生する。eventFilterによってtrueが返った場合、そのイベントは消費されたものと見做される。先程の「自分自身以外のクリックなども検知する」という説明を思い出してほしい。たとえ親Widgetをクリックしたとしても、そのイベントはPopupWidgetに検知され、しかもtrueが返るためイベントが消費された扱いとなり、親Widgetにはイベントが渡らない。分かるだろうか。つまりPopup中はそのままだと親Widgetが一切操作できなくなるのである。 (ただよく分からないことに、キー入力についてはfalseを返したとしても親Widgetにイベントが渡らなかったため、全てPopupWidgetで処理しなければならなかった)。 よって、Popup中の親イベントの操作は必ずPopupWidgetのeventFilterから自力で親Widgetへと渡さなければならないのだ。これが今回の肝である。

実装例

以下が数日間の奮闘の末に辿り着いたコードである。カーソルの表や点滅なども完全にコントロールしようとすると恐ろしく大変だった。どうやらQLineEditはフォーカスが外れた状態からQt::MouseFocusReasionによってフォーカスされることでようやくカーソルが表示されるらしい。既にフォーカスされた状態からMouseFocusReasonで再フォーカスしてもカーソルが表示されないのだと気づくまでに3日くらい要した(それ以前の問題が膨大に複合していたので気付きようがなかった)。まだわずかに気に食わない動作があるといえばあるが、振る舞いとしてはある程度納得できた。

//SearchBoxTest.h
#ifndef SEARCH_BOX_H
#define SEARCH_BOX_H

#include <QMainWindow>
#include <QLineEdit>
#include "SearchBoxTest.h"

class QLabel;
class QTreeWidget;
class QMenu;
class QComboBox;
class QPushButton;
class QLineEdit;

class SearchBox;

class SearchBoxPopup : public QWidget
{
    Q_OBJECT
public:

    SearchBoxPopup(SearchBox* parent = nullptr);
    virtual ~SearchBoxPopup();

    void ShowPopup();
    void HidePopup();

    virtual bool eventFilter(QObject* obj, QEvent* ev) override;

signals:

    void SearchRequested();

private:

    QComboBox* mMatch;
    QPushButton* mCase;
    QPushButton* mRegex;
    SearchBox* mParent;
};

class SearchBox : public QLineEdit
{
    Q_OBJECT
public:

    SearchBox(QWidget* parent = nullptr);

    virtual bool eventFilter(QObject* obj, QEvent* event) override;

signals:

    void enterPressed();
    void downPressed();
    void focused();
    void unfocused();

public slots:
    void ExecuteSearch();

private:
    SearchBoxPopup* mPopup;
};

class MainWindow : public QMainWindow
{
public:
    MainWindow();

private:
    SearchBox* mSearchBox;
};

#endif
//SearchBoxTest.cpp
#include "SearchBoxTest.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QAction>
#include <QEvent>
#include <QKeyEvent>
#include <QPushButton>
#include <QComboBox>
#include <QStandardItemModel>

SearchBoxPopup::SearchBoxPopup(SearchBox* parent)
    : QWidget(nullptr), mParent(parent)
{
    this->setFixedWidth(parent->width());
    this->setWindowFlags(Qt::Popup | Qt::WindowStaysOnTopHint);
    this->installEventFilter(this);
    this->setAttribute(Qt::WA_ShowWithoutActivating);

    QHBoxLayout* layout = new QHBoxLayout();
    layout->setContentsMargins(2, 2, 2, 2);
    mMatch = new QComboBox();
    {
        mMatch->addItem("Exact phrase");
        mMatch->addItem("All words");
        mMatch->addItem("Any words");
        mMatch->setFocusPolicy(Qt::NoFocus);
        layout->addWidget(mMatch);
    }
    mCase = new QPushButton();
    {
        mCase->setCheckable(true);
        mCase->setText("Case sensitive");
        mCase->setFocusPolicy(Qt::NoFocus);
        layout->addWidget(mCase);
    }
    mRegex = new QPushButton();
    {
        mRegex->setCheckable(true);
        mRegex->setText("Regular expression");
        mRegex->setFocusPolicy(Qt::NoFocus);
        layout->addWidget(mRegex);

        auto disabler = [this](bool b)
        {
            mMatch->setDisabled(b);
            auto* model = qobject_cast<QStandardItemModel*>(mMatch->model());
            int nrow = model->rowCount();
            for (int i = 0; i < nrow; ++i)
            {
                model->item(i)->setEnabled(!b);
            }
            mCase->setDisabled(b);
        };
        connect(mRegex, &QPushButton::toggled, disabler);
    }
    layout->addStretch();
    this->setLayout(layout);
}
SearchBoxPopup::~SearchBoxPopup()
{}
void SearchBoxPopup::ShowPopup()
{
    this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);
    this->move(mParent->mapToGlobal(QPoint(0, mParent->height())));
    this->show();
    this->setFocus();//一時的にFocusする。一度こちらをFocusしておかないと、クリック時のparentのFocusでカーソルが表示されない。
}
void SearchBoxPopup::HidePopup()
{
    this->hide();
}
bool SearchBoxPopup::eventFilter(QObject* obj, QEvent* ev)
{
    if (obj != this)
        return false;
    if (ev->type() == QEvent::MouseButtonDblClick ||
        ev->type() == QEvent::MouseButtonPress ||
        ev->type() == QEvent::MouseButtonRelease ||
        ev->type() == QEvent::MouseMove)
    {
        QMouseEvent* mouse = static_cast<QMouseEvent*>(ev);
        QPoint local = mParent->mapFromGlobal(mouse->globalPos());
        if (mParent->rect().contains(local))
        {
            mouse->setLocalPos(local);
            mParent->setFocus(Qt::MouseFocusReason);//これをMouseFocusReasonにしないとカーソルが表示されないらしい。
            mParent->event(ev);
            return true;
        }
        else if (!rect().contains(mouse->pos()))
        {
            hide();
            return true;
        }
    }
    else if (ev->type() == QEvent::KeyPress)
    {
        int key = static_cast<QKeyEvent*>(ev)->key();
        bool consumed = false;
        switch (key)
        {
        case Qt::Key_Enter:
        case Qt::Key_Return:
            emit SearchRequested();
            consumed = true;
            break;
        default:
            mParent->event(ev);
            break;
        }
        return consumed;
    }
    return false;
}

SearchBox::SearchBox(QWidget* parent)
    : QLineEdit(parent)
{
    setClearButtonEnabled(true);
    installEventFilter(this);
    setPlaceholderText("Search");
    setFixedSize(400, 24);
    mPopup = new SearchBoxPopup(this);
    connect(mPopup, &SearchBoxPopup::SearchRequested, this, &SearchBox::ExecuteSearch);
}

bool SearchBox::eventFilter(QObject* object, QEvent* event)
{
    if (object != this)
        return false;
    auto t = event->type();
    if (t == QEvent::MouseButtonPress)
    {
        event->ignore();
        QMouseEvent* mouse = static_cast<QMouseEvent*>(event);
        if (mPopup->isHidden())
        {
            mPopup->ShowPopup();
        }
        setFocus(Qt::MouseFocusReason);
    }
    else if (t == QEvent::KeyPress)
    {
        if (mPopup->isHidden())
        {
            mPopup->ShowPopup();
            setFocus();
        }
    }
    return false;
}

void SearchBox::ExecuteSearch()
{
    /*
   do something
   */
}


MainWindow::MainWindow()
{
    QWidget* w = new QWidget();
    setCentralWidget(w);
    QHBoxLayout* l = new QHBoxLayout();
    w->setLayout(l);
    mSearchBox = new SearchBox();
    l->addWidget(mSearchBox);
}

  1. 自分以外をクリックした、という情報をFocusInやFocusOutなどのイベントで代用しようとしても無駄である。なぜならこれらは、FocusされないWidgetやアプリケーションウィンドウの外をクリックしたときなどは発生しないからだ。したがって、Qt::Popup以外の選択肢はない。多分。