[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以外の選択肢はない。多分。

jsbookをjsreportっぽいレイアウトにするには。

私は学位論文用としてはjsreportのデフォルトのレイアウトが気に入っていたのに、jsreportでは一点だけ満足できなかった。ヘッダーである。偶数ページには章題、奇数ページには節題を表示したかったのに、それがどうにも上手くいかなかったのだ。一般にはfancyhdrを使って設定するようだが、これが駄目なのである。実際のページ数に関わらず全て奇数ページと判定されてしまって、偶数ページであっても問答無用で奇数ページと同じ表示になってしまうのだ。しかもその原因は調べても分からなかった。解決する方法はきっと何かあるのだろうが、私のようなLaTeX初級者には見つけることもできなかった。jsreportのオプションにtwosideを追加するだけで解決するらしいことを知ったので追記しておく。

そこで、jsbookを弄ってjsreportのようなレイアウトにすることにした。jsbookのヘッダーはfancyhdrも正常に動作するようである。が、あれこれ調整しなくても\pagestyle{headings}で十分格好良い。尤もそれ以外が非常に面倒なのだが。
用紙サイズA4、文字サイズ10pt、他はjsreportデフォルトを想定している。文字サイズや余白に関する細かい指定は何もないようなので、字が小さい方が何となく好みな私はこのようにした。老眼が進んできた教授らには文句を言われるかも知らんが。

\documentclass[a4j, titlepage, openany, 10pt]{jsbook}

%上下幅設定
\setlength{\textheight}{\paperheight}   % 本文領域を一旦紙面の高さにする
\setlength{\topmargin}{13.6truemm}      % 上の余白を39mm(=1inch+13.6mm)に
\addtolength{\topmargin}{-\headheight}  % 
\addtolength{\topmargin}{-\headsep}     % ヘッダの分だけ上の余白を小さくする
\addtolength{\textheight}{-74truemm}    % 下の余白35mmと合わせて、上下余白分だけ本文領域の高さを小さくする。

%左右幅設定
\setlength{\fullwidth}{\paperwidth}     % 全体の幅(テキスト領域の幅ではない)を一旦紙面幅にする
\setlength{\evensidemargin}{-0.4truemm} % 左の余白を25mm(=1inch-0.4mm)に
\setlength{\oddsidemargin}{-0.4truemm}  % 上に同じ
\addtolength{\fullwidth}{-50truemm}     % 左右余白分だけ本文領域の幅を小さくする。
\setlength{\textwidth}{\fullwidth}      % テキスト幅を全体の幅に一致させる

%概要ページの調整
\makeatletter
\renewenvironment{abstract}{
  \titlepage
  \null\vfil
  \@beginparpenalty\@lowpenalty
  \begin{center}
    {\bfseries\abstractname}
    \@endparpenalty\@M
  \end{center}}
  {\par\vfil\null\endtitlepage}
\newcommand{\abstractname}{概要}
\makeatother

jsbookのややこしさはfullwidthとtextwidthの違いにあるらしい(これらが独立していることが左右の偏りの原因のようである)。詳細は参考ページに詳しく書かれている。\setlength{\textwidth}{\fullwidth}とすることで、この問題は吸収できる。
jsreportはだいたい左右25mmずつの余白を確保しているようなので、こちらもそれに習い余白は25mmにした。上下の余白はそれぞれ39mm、35mmくらいに見えたのでそのようにしたが、正確な所は分からない。geometryパッケージを使うと楽だとよく説明されているが、jsbookの仕様と共存させると理解しがたい挙動をしたため今回は使わなかった。

概要の所は、\begin{abstract}~\end{abstract}のような書き方をせず章番号のない\chapter*{概要}を使う手もあるけれども、私は上の方法のほうが好みだったのでそちらを採用した。

もちろんjsreportとは厳密には一致しないし、私以外の環境で上手くいく保証もしかねる。

参考

jsbook や bxjsbook の左右マージンを理解する - マクロツイーター
TeXのjsarticleで余白設定 - joker8phoenix's diary

[C++]SFINAEとC++20のコンセプトを比較してみる。

C++のテンプレートが大好物な私は以前から話題になっていたコンセプトとやらにもそこそこ興味を持っている。コンセプトとはざっくり言えば、今までSFINAEを使って実現していたテンプレートパラメータに対する制限やオーバーロードを、もっと分かりやすく実現するための方法である。そこ、SFINAEで十分だとか言っちゃだめ。
Visual Studio 2019が16.8 Preview1でIntelliSenseがコンセプトに対応したという話を聞き、折角なのでプレビュー版をインストールして遊んでみた。

コンセプトの基本は至るところで解説されているのでそれらを参照されたい。ここでは主に具体例をだらだらと書き並べてみる。何か思いついたらそのうち書き加えていくかも。

整数型、浮動小数点型、それ以外を呼び分ける。

template <class Type, std::enable_if_t<std::is_integral_v<Type>, std::nullptr_t> = nullptr>
void func_sfinae(Type x)
{
    std::cout << "x is integer " << x << std::endl;
}
template <class Type, std::enable_if_t<std::is_floating_point_v<Type>, std::nullptr_t> = nullptr>
void func_sfinae(Type x)
{
    std::cout << "x is float " << x << std::endl;
}
template <class Type, std::enable_if_t<(!std::is_integral_v<Type> && !std::is_floating_point_v<Type>), std::nullptr_t> = nullptr>
auto func_sfinae(Type x) -> decltype(std::cout << x, void())
{
    std::cout << "x is neither integral nor float " << x << std::endl;
}

template <class Type>
concept Integral = std::is_integral_v<Type>;//std::integralと全く同じ。
template <class Type>
concept Float = std::is_floating_point_v<Type>;//std::floating_pointと全く同じ。
template <class Type>//複数の条件を組み合わせることも出来る。
concept Other = !std::is_integral_v<Type> && !std::is_floating_point_v<Type> &&
requires (const Type& v)
{
    std::cout << v;
};

template <Integral Type>
void func_concept(Type x)
{
    std::cout << "x is integer " << x << std::endl;
}
template <Float Type>
void func_concept(Type x)
{
    std::cout << "x is float " << x << std::endl;
}
template <Other Type>
void func_concept(Type x)
{
    std::cout << "x is neither integral nor float " << x << std::endl;
}

int main()
{
    func_sfinae(3);
    func_sfinae(3.141592);
    func_sfinae("3.141592");

    func_concept(3);
    func_concept(3.141592);
    func_concept("3.141592");
    return 0;
}

Forward Iteratorを持つコンテナのみ許す。

Foward Iteratorを持つ、ということを、ここでは以下の条件で判定することにした。

  • begin()、end()という名前のメンバ関数を持つ。
  • begin()の戻り値は++演算子を適用できる。
  • begin()とend()の戻り値を比較可能である。またその戻り値はboolへ変換することが出来る。

上でもちょっとだけ使っていたが、クラスが何らかのメンバ関数を持つことを要求する場合、SFINAEでは戻り値を定義するdecltypeを駆使すれば可能である。が、初心者にはstd::enable_if以上に分かりにくい気がする。decltypeの中のコンマが実は演算子で末尾の値が戻り値になるとか、分かる人はどのくらいいるんだろう。コンマが演算子として扱われること、実はオーバーロードできることとかはC++の黒魔術っぷりに拍車をかけている。

template <class Type>
auto func2_sfinae(Type& x) -> decltype(++x.begin(), x.end(), bool(x.begin() != x.end()), void())
{
    for (auto& v : x)
    {
        std::cout << v << " ";
    }
    std::cout << std::endl;
}

template <class Type>
concept STLContainer = requires(Type & t)
{
    t.begin()++;
    { t.begin() != t.end() } -> std::convertible_to<bool>;
};

template <STLContainer Type>
void func2_concept(Type& x)
{
    for (auto& v : x)
    {
        std::cout << v << " ";
    }
    std::cout << std::endl;
}

int main()
{
    std::set<int> v{ 1, 3, 5, 7 };
    func2_sfinae(v);
    func2_concept(v);
    return 0;
}

可変長引数テンプレートに条件を設ける。

一つコンセプトのありがたいところは、可変長引数テンプレートで型の制限を設けやすくなったことだ。C++で可変個引数関数を実現したい場合、一般に可変長引数テンプレートを使うのだが、例えばこの可変個の引数が全てint型であってほしい場合、SFINAEでは記述が長たらしくなる。今回はstd::is_sameのみで表現できるためまあそこまで汚くはないが、複雑な条件を要求するときは酷いものである。C++17の畳み込み式がなかった頃はさらに面倒だった。

template <class ...Types,
          std::enable_if_t<(std::is_same_v<Types, int> && ...), std::nullptr_t> = nullptr>//長い!汚い!
void func3_sfinae(Types ...x)
{
    (std::cout << ... << x) << std::endl;
}

//requires節でも多少は楽になる。
template <class ...Types>
requires (std::same_as<Types, int> && ...)
void func3_concept(Types ...x)
{
    (std::cout << ... << x) << std::endl;
}

template <class Type>
concept Int = std::same_as<Type, int>;

//事前に定義されたコンセプトを使えばもっとすっきりする。
template <Int ...Types>
void func3_concept2(Types ...x)
{
    (std::cout << ... << x) << std::endl;
}

int main()
{
    func3_sfinae(1, 2, 3, 4);
    func3_concept(1, 2, 3, 4);
    func3_concept2(1, 2, 3, 4);
    return 0;
}

実のところ、バージョンが新しくなったからと言って出来ることが増えるわけではない。いや、もちろん多数の機能は追加されるのだが、それは“昔よりも大幅にパフォーマンスが改善する”とか“今まで不可能だった設計が実現する”というような革新的なものではない。テンプレートやC++11のムーブセマンティクスは設計方法そのものを根幹から覆すような画期的なものだったと思うが、それ以外はさほど重大ではない。今まで苦労して実現していたことが、幾分楽になるだけのことである。知識さえあればそれなりに手間ひまかけて実現させられたことを、知識さえあればちょっと簡単に実現できるようにするだけである。いやプログラミング言語ってそんなものなのかもしれないけど。
そういう意味で、コンセプトは面白い機能だが、今までSFINAEや特殊化で実現していたところを置き換えるだけで、新しい動作が可能になるわけでなさそうである。……正直に言うとちょっとがっかりしている。
まあでも、格段にコーディングしやすくなることは分かった。何年か経って世間的にC++20が浸透したらぜひ導入したい機能には違いない。