シングル/ダブルクリック判定

[Qt Advent Calendar 2017](https://qiita.com/advent-calendar/2017/qt) 2日目です。

# はじめに
Qt Widgetsでクリックあるいはダブルクリックのハンドリングをするには、普通はclickedシグナルやMouseButtonDblClickといったイベントを使うと思います。しかし、世の中には**シングルクリックとダブルクリックで異なる動作を排他的にしたい**局面というものがあります。

例えば、Windowsのエクスプローラーは次のように振る舞います[^explorer]。

| |シングルクリック|ダブルクリック|
|--:|:-:|:-:|
|選択|名前の変更|開く|
|非選択|選択|開く|

[^explorer]: 正確には、選択はマウスプレスで起きるのでシングルクリックではありません。

これをやろうとすると、既存のシグナル/イベントでは足りません。ダブルクリックしたときはclickedのemitに続いてMouseButtonDblClickイベントが起きるためです。

# シングル/ダブルクリック判定

## シンプルな方法

まず考えられるのは、連続でemitされたclickedシグナルを数えることです。

* クリックしたら連続クリック数を1増やしてタイマーリセット
* タイムアウトしたら連続クリック数が確定

```cpp
#include <QApplication>
#include <QWidget>
#include <QVBoxLayout>
#include <QPushButton>
#include <QLabel>
#include <QTimer>

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);

    QWidget widget;
    auto layout = new QVBoxLayout;
    widget.setLayout(layout);

    auto label = new QLabel;

    auto button = new QPushButton("Click me");

    auto timer = new QTimer;
    timer->setParent(&widget);
    timer->setSingleShot(true);

    layout->addWidget(button);
    layout->addWidget(label);

    int count = 0;

    QObject::connect(button, &QPushButton::clicked, [&](){
        if(count == 0) label->setText("");
        count++;
        timer->start(QApplication::doubleClickInterval());
    });

    QObject::connect(timer, &QTimer::timeout, [&](){
        label->setText(QString("%1連続クリック").arg(count));
        count = 0;
    });

    widget.show();
    return app.exec();
}
```

この方法はn連続クリックを判定できますが、クリックしてから回数が確定するまで必ず待ち時間が発生するという問題があります。

普通はダブルクリックまでで十分なので、次のようになるでしょう。

* クリックしたら連続クリック数を1増やす
    * 連続クリック数が2なら確定
    * 違えばタイマーリセット
* タイムアウトしたら連続クリック数が確定

```cpp
    QObject::connect(button, &QPushButton::clicked, [&](){
        if(count == 0) label->setText("");
        count++;
        timer->stop();
        if(count == 2)
        {
            label->setText("ダブルクリック");
            count = 0;
            return;
        }
        timer->start(QApplication::doubleClickInterval());
    });

    QObject::connect(timer, &QTimer::timeout, [&](){
        label->setText("シングルクリック");
        count = 0;
    });
```

このようにするとレスポンスが良くなります。

## 厳密な方法

シンプルな方法では、ダブルクリックが確定するのはボタンを離したときです。

しかし、MouseButtonDblClickイベントの説明に"Mouse press again"とある通り、このイベントは実は2度目にボタンを押した瞬間に起きます。ボタンを離す前に確定するので僅かですがレスポンスが良く見えます。

次のようにclickedの他にpressedを併用して実装します。

```cpp
    QObject::connect(button, &QPushButton::clicked, [&](){
        if(count == 1)
        {
            timer->start(QApplication::doubleClickInterval());
        }
    });

    QObject::connect(button, &QPushButton::pressed, [&](){
        if(count == 0) label->setText("");
        count++;
        timer->stop();

        if(count == 2)
        {
            label->setText("ダブルクリック");
            count = 0;
        }
    });

    QObject::connect(timer, &QTimer::timeout, [&](){
        label->setText("シングルクリック");
        count = 0;
    });
```

## さらに厳密な方法

待ち時間は厳密にはボタンを離してから次に押すまでではなく、**最初に押してから次に押すまで**です。例えば、1回目のクリックを非常にゆっくりと行った場合、2回目のクリックを素早く行ってもシングルクリック2回と判定されます。

(注: プラットフォーム依存かもしれない。少なくともWindowsのExplorerはこのような動作をする)

また、厳密に言えば、PushButtonのpressed/releasedシグナルは同名のマウスイベントとは若干異なるタイミングでemitされるので[^event]、マウスイベントでハンドリングする必要があります。

[^event]: 具体的には、シグナルの方はマウスボタンを押したままボタンを出入りするだけでもemitされます。イベントの方はボタンを離すまで次のイベントが発生しません。

MouseReleaseイベントはボタンの外でも発生するので、範囲外で発生した場合はクリックではないことに注意して、状態遷移表で書くと次のようになります。

|状態|Press|Release(中)|Release(外)|Timer|
|:--:|:--:|:--:|:--:|:--:|
|S0|タイマー開始/<br>S1|-|-|-|
|S1|-|S3|S0|S2|
|S2|-|シングル確定/<br>S0|S0|-|
|S3|ダブル確定/<br>S0|-|-|シングル確定/<br>S0|

S0は開始状態、S1はボタンを押している状態、S2は押したままタイムアウトした状態、S3はボタンを離した状態です。

これをイベントフィルターで実装すると次のようになります。

```cpp
class DoubleClickEventFilter : public QObject {
    Q_OBJECT
public:
    explicit DoubleClickEventFilter(QWidget* target)
        :target(target)
        ,timer(new QTimer(this))
    {
        timer->setSingleShot(true);
        timer->installEventFilter(this);

        setParent(target);
    }

    bool eventFilter(QObject* watched, QEvent* event) override
    {
        if(event->type() == QEvent::MouseButtonPress || event->type() == QEvent::MouseButtonDblClick)
        {
            QMouseEvent* mouseEvent = static_cast<QMouseEvent*>(event);
            if(mouseEvent->button() == Qt::LeftButton)
            {
                if(state == 0)
                {
                    setState(1);
                    timer->start(QApplication::doubleClickInterval());
                }
                else if(state == 3)
                {
                    emit doubleClicked();
                    setState(0);
                }
            }
        }
        else if(event->type() == QEvent::MouseButtonRelease)
        {
            QMouseEvent* mouseEvent = static_cast<QMouseEvent*>(event);
            if(mouseEvent->button() == Qt::LeftButton)
            {
                if(target->rect().contains(mouseEvent->pos()))
                {
                    if(state == 1)
                    {
                        setState(3);
                    }
                    else if(state == 2)
                    {
                        emit singleClicked();
                        setState(0);
                    }
                }
                else
                {
                    setState(0);
                }
            }
        }
        else if(event->type() == QEvent::Timer)
        {
            if(state == 1)
            {
                setState(2);
            }
            else if(state == 3)
            {
                setState(0);
                emit singleClicked();
            }
        }
        return QObject::eventFilter(watched, event);
    }

signals:
    void singleClicked();
    void doubleClicked();

private:
    void setState(int s)
    {
        state = s;
    }

    int state = 0;
    QTimer* timer = nullptr;
    QWidget* target = nullptr;
};
```

## Rxを用いる方法

[RxCpp](https://github.com/Reactive-Extensions/RxCpp)と[RxQt](https://github.com/tetsurom/rxqt)を用いると、QtでRx(Reactive Extensions)が使えるようになります。

Rxは時間に関するオペレーターを豊富に備えています。
クリックの連打判定には、一定時間イベントが起きなくなるまで待つ[debounce](http://reactivex.io/documentation/operators/debounce.html)が使えます。

n連続クリックの判定をRxで実装すると次のようになります。

```cpp
#include <QApplication>
#include <QWidget>
#include <QVBoxLayout>
#include <QPushButton>
#include <QLabel>
#include <rxqt.hpp>

using namespace std::chrono;

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);

    QWidget widget;
    auto layout = new QVBoxLayout;
    widget.setLayout(layout);

    auto label = new QLabel;

    auto button = new QPushButton("Click me");

    layout->addWidget(button);
    layout->addWidget(label);

    int count = 0;

    rxqt::from_signal(button, &QPushButton::clicked)
        .map([&](const auto&){ return count += 1; })
        .debounce(milliseconds(QApplication::doubleClickInterval()))
        .tap([&](int){ count = 0; })
        .subscribe([&](int x){ label->setText(QString("%1連続クリック").arg(x)); });

    widget.show();
    return app.exec();
}
```

# 最後に
本稿では簡単のためシグナルを使いましたが、このような特別な処理を実装するウィジェットはボタン系ではないかもしれません。
clickedやpressedなどのシグナルがないウィジェットでは、最後の例のようにマウスイベントでハンドリングします。