はじめに
テキストエディタやペイントソフトでよくあるUndo/Redoを、自前で実装するのは大変そうですが、QtのUndo Frameworkを使って簡単に実装する方法を調べたのでまとめます。
実装の流れ
ざっくり示すと、以下のような感じです。
- ドキュメントに変更を加える操作をコマンド化する(QUndoCommand)
- コマンドをスタックに積む(QUndoStack::push())
- スタックからコマンドを取り出して、Undo/Redoを行う
例えば、「キャンバスに頂点を打つ」という操作によって、ドキュメントに頂点情報を追加されるわけですが、この「頂点情報を追加する」というのをQUndoCommand::redo()に書きます。
しかし、これだけではどうすれば元の状態に戻せるかわかりません。なので、戻す方法も書いておく必要があります。
上記の場合、「頂点を削除」することで、前の状態に戻せるので、これをQUndoCommand::undo()に書きます。
コマンドを使う時は、生成したコマンドをQUndoStackにプッシュしてあげるだけです。
実装方法
操作をコマンド化するときはQUndoCommandを継承したクラスを用意します。先の例だと、以下のような感じです。
まずは、ヘッダーファイルです。
#ifndef ADDPOINTCOMMAND_H
#define ADDPOINTCOMMAND_H
#include <QUndoCommand>
#include <QPoint>
class AddPointCommand : public QUndoCommand
{
public:
AddPointCommand(const QPoint &point, QList<QPoint> *pointList, QUndoCommand *parent = 0);
// QUndoCommand interface
public:
void undo() Q_DECL_OVERRIDE;
void redo() Q_DECL_OVERRIDE;
private:
QPoint m_point;
QList<QPoint> *m_pointList;
};
#endif // ADDPOINTCOMMAND_H
続いて、実装ファイルです。
#include "AddPointCommand.h"
AddPointCommand::AddPointCommand(const QPoint &point, QList<QPoint> *pointList, QUndoCommand *parent):
QUndoCommand(parent),
m_point(point),
m_pointList(pointList)
{
}
void AddPointCommand::undo()
{
if (m_pointList->isEmpty())
{
return;
}
// 頂点リストの最後に点が最後に追加した点なので、それを取り除く
auto point = m_pointList->takeLast();
// 実行した操作に説明をつける
setText(QString("Remove : (%1, %2)").arg(point.x()).arg(point.y()));
}
void AddPointCommand::redo()
{
// 頂点リストの最後に点を追加
m_pointList->push_back(m_point);
// 実行した操作に説明をつける
setText(QString("Add : (%1, %2)").arg(m_point.x()).arg(m_point.y()));
}
AddPointCommandのコンストラクで、追加したい点と変更を加えたい頂点リストを受け取とります。
そして、redo()に頂点を追加する処理、undo()に頂点を削除する処理を記述しています。
このAddPointCommandは、MainWindowのmouseReleaseEventから呼び出して使っています。
使い方は以下のような感じです。
#ifndef MAINWINDOW_H
#define MAINWINDOW_H
#include <QMainWindow>
#include <QUndoStack>
namespace Ui {
class MainWindow;
}
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = 0);
~MainWindow();
// QWidget interface
protected:
void mouseReleaseEvent(QMouseEvent *e) Q_DECL_OVERRIDE;
private:
Ui::MainWindow *ui;
QList<QPoint> m_pointList;
QUndoStack *m_undoStack;
};
#endif // MAINWINDOW_H
#include "MainWindow.h"
#include "ui_MainWindow.h"
#include <QMouseEvent>
#include "AddPointCommand.h"
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent),
ui(new Ui::MainWindow),
m_undoStack(new QUndoStack)
{
ui->setupUi(this);
connect(ui->btnUndo, &QPushButton::clicked, m_undoStack, &QUndoStack::undo);
connect(ui->btnRedo, &QPushButton::clicked, m_undoStack, &QUndoStack::redo);
}
MainWindow::~MainWindow()
{
delete ui;
}
void MainWindow::mouseReleaseEvent(QMouseEvent *e)
{
AddPointCommand *addPointCommand = new AddPointCommand(e->pos(), &m_pointList);
m_undoStack->push(addPointCommand);
}
QUndoStackのインスタンスm_undoStackを生成し、push()を使って、addPointCommandをプッシュしています。
プッシュすると、自動的にaddPointCommandのredo()が呼ばれて、頂点が追加されます。
「元に戻す」と「やり直し」は、QUndoStackのundo()、redo()で行えます。上記の例では、QPushButtonのclickedシグナルと接続することで、実現しています。
同じコマンドをまとめるmergeWith
上記の例と同じノリで、「頂点を動かす」という操作をコマンド化すると、少し面倒なことが起こります。
例えば、MovePointCommandを作り、ある点を(0, 0)から(0, 300)まで動かすと、数ピクセル動くたびにMovePointCommandが積まれてしまいます。そして、この状態で操作を戻そうとすると、ユーザは何回も「元に戻す」ボタンを押さないといけなくなります。
このように、同じコマンドが続く時は、mergeWithを使ってコマンドをまとめます。
コマンドが同じかどうか判定するにはid()を使うので、他のコマンドとかぶらないような整数を返すように、オーバーライドする必要があります。
mergeWithの中で、直前のコマンドと現在のコマンドのidを評価し、-1以外で、かつ等しい場合にtrueを返してやれば、直前のコマンドに現在のコマンドがまとめられます。
以下に、頂点を動かすMovePointCommandのidとmergeWithを示します。
#ifndef MOVEPOINTCOMMAND_H
#define MOVEPOINTCOMMAND_H
#include <QUndoCommand>
#include <QPoint>
class MovePointCommand : public QUndoCommand
{
public:
MovePointCommand(const QPoint &newPos, int index, QList<QPoint> *pointList, QUndoCommand *parent = 0);
QPoint m_newPosition;
// QUndoCommand interface
public:
enum { ID = 1234 };
void undo() Q_DECL_OVERRIDE;
void redo() Q_DECL_OVERRIDE;
int id() const Q_DECL_OVERRIDE;
bool mergeWith(const QUndoCommand *other) Q_DECL_OVERRIDE;
private:
int m_index;
QList<QPoint> *m_pointList;
};
#endif // MOVEPOINTCOMMAND_H
#include "MovePointCommand.h"
MovePointCommand::MovePointCommand(const QPoint &newPos, int index, QList<QPoint> *pointList, QUndoCommand *parent):
QUndoCommand(parent),
m_newPosition(newPos),
m_index(index),
m_pointList(pointList)
{
}
void MovePointCommand::undo()
{
...
}
void MovePointCommand::redo()
{
...
}
int MovePointCommand::id() const
{
return ID;
}
bool MovePointCommand::mergeWith(const QUndoCommand *other)
{
// 最後のコマンドがMovePointCommand以外ならマージしない
if (other->id() != id())
{
return false;
}
m_newPosition = static_cast<const MovePointCommand*>(other)->m_newPosition;
return true;
}
上記のMovePointCommandは、コンストラクで動かしたい頂点のインデックスと、新しい位置と頂点リストを受け取ります。
そして、redo()で指定された頂点を、指定された位置に動かしています。
MovePointCommandはMainWindowのmouseMoveEventから呼んでいます。
void MainWindow::mouseMoveEvent(QMouseEvent *e)
{
MovePointCommand *movePointCommand = new MovePointCommand(e->pos(), m_targetIndex, &m_pointList);
m_undoStack->push(movePointCommand);
}
マウスの移動に合わせて、連続で発生するMovePointCommandが一つにまとまるので、「元に戻す」を行えば、移動開始直前の位置に戻ってくれます。
QUndoViewで、QUndoStackに積まれたコマンドをみる
最後に、QUndoViewを紹介します。
これは、QListView派生のウィジェットで、QUndoStackのインスタンスを渡しておくと、そこに積まれるコマンドをリストで表示してくれます。
さらに、マウス操作でUndo/Redoを行き来できて便利です。
使い方は簡単で、以下のような感じです。
QUndoView *undoView = new QUndoView(m_undoStack);
undoView->show();
リストには、各コマンドのUndo/redo時に、setTextで設定したテキストが表示されます。
おわりに
例によって、サンプルをGitHubにあげておきます。
クリックで点を追加、ドラッグで点を移動するプログラムです。
GitHub - QtUndoFrameworkSample
今回は、無いと困るけど、自分で仕組みを作るには面倒臭いUndo/Redoについてまとめてみました。
ただ、もっと複雑なデータを扱う大規模なプログラムでは、さらに工夫が必要かもしれません。
参考
Qt Documentation - Undo Framework Example
Qt Documentation - Overview of Qt's Undo Framework