Posted at

QtのUndo Frameworkで、Undo/Redoを実装する方法

More than 1 year has passed since last update.


はじめに

テキストエディタやペイントソフトでよくあるUndo/Redoを、自前で実装するのは大変そうですが、QtのUndo Frameworkを使って簡単に実装する方法を調べたのでまとめます。


実装の流れ

ざっくり示すと、以下のような感じです。


  1. ドキュメントに変更を加える操作をコマンド化する(QUndoCommand)

  2. コマンドをスタックに積む(QUndoStack::push())

  3. スタックからコマンドを取り出して、Undo/Redoを行う

例えば、「キャンバスに頂点を打つ」という操作によって、ドキュメントに頂点情報を追加されるわけですが、この「頂点情報を追加する」というのをQUndoCommand::redo()に書きます。

しかし、これだけではどうすれば元の状態に戻せるかわかりません。なので、戻す方法も書いておく必要があります。

上記の場合、「頂点を削除」することで、前の状態に戻せるので、これをQUndoCommand::undo()に書きます。

コマンドを使う時は、生成したコマンドをQUndoStackにプッシュしてあげるだけです。


実装方法

操作をコマンド化するときはQUndoCommandを継承したクラスを用意します。先の例だと、以下のような感じです。

まずは、ヘッダーファイルです。


AddPointCommand.h

#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


続いて、実装ファイルです。


AddPointCommand.cpp


#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から呼び出して使っています。

使い方は以下のような感じです。


MainWindow.h

#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



MainWindow.cpp

#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を示します。


MovePointCommand.h

#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



MovePointCommand.cpp

#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から呼んでいます。


MainWindow.cpp

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