Overview
We've come a long way in the past few years. Program features that were considered niceties only a short while ago, are now expected on nearly every application. Undo/Redo is one of those features that has made the transition from nicety to requirement.
There are many techniques that can be used to implement an Undo/Redo strategy. Most are complicated, and require elaborate planning and thought to add them to your application. These complicated techniques have many advantages like require a small amount of storage to hold many previous states, and infinite undo/redo capabilities. You can find articles and code for an Undo/Redo manager of this scale.
This article describes a much simpler snapshot method. It has the advantage nearly trivial to any MFC Document/View application. The down side is that it uses memory equal to the size of the serialized file to store each previous state.
For many applications, these serialized files are small enough to allow several previous states to be held with little problem.
Adding Undo to an Existing Application
For me, the utility of a new class often depends on how easy it is to add to an existing application. I really like the Scribble tutorial that is found on the Visual C++ CD-ROM, so I'm going to use the Scribble application as an example application for adding undo support.
There are four steps necessary to add undo to an application.
- Add
CUndo
to the project by adding the undo.h header to stdafx.h and by adding a reference to this class in the CDocument
derived classes that you wish to support undo.
- Add new code to save the current state of the project whenever the user makes a change worthy of noting.
- Wire Undo/Redo into the menuing system.
Adding CUndo to Scribble
Setup
The first step is to move the undo.h include
into your project directory. The next step is to make undo.h available to the files that need it. You could do this by adding an include
command at the top of each file that refers to undo.h. I tend to be a bit lazy and just add the line to the stdafx.h file. This causes undo.h to be included into most of the files in the project. I would add the following line to the stdafx.h file:
#include "undo.h"
Now the project knows about the CUndo
class. To make the functionality of CUndo
available to the Scribble application, we must add a reference to this class in the definition of the CScribbleDoc
class. To do this, we must edit one line in the file scribdoc.h. That line is changed from:
class CScribbleDoc : public COleServerDoc
to
class CScribbleDoc : public COleServerDoc, public CUndo
At this point, the CScribbleDoc
class contains support for undo/redo, all we need to do is take advantage of it.
Add Code to Save the Undo State
The key to making undo useful is deciding when to save the state of the application. I won't kid you, in many applications this can be a difficult decision. However, in the Scribble application, we will save the state whenever a stroke is completed and in a couple of other special places.
In the Scribble application, the stroke is saved in the OnLButtonUp()
member function of the CScribView
class. The logical place to save the state would be to save it after the stroke has been added to the CScribbleDoc
. The OnLButtonUp()
member function looks like this (minus a bunch of comments):
void CScribbleView::OnLButtonUp(UINT, CPoint point)
{
if (GetCapture() != this)
return;
CScribbleDoc* pDoc = GetDocument();
CClientDC dc(this);
OnPrepareDC(&dc);
dc.DPtoLP(&point);
CPen* pOldPen = dc.SelectObject(pDoc->GetCurrentPen());
dc.MoveTo(m_ptPrev);
dc.LineTo(point);
dc.SelectObject(pOldPen);
m_pStrokeCur->m_pointArray.Add(point);
m_pStrokeCur->FinishStroke();
pDoc->UpdateAllViews(this, 0L, m_pStrokeCur);
ReleaseCapture();
pDoc->NotifyChanged();
return;
}
The natural place to save the state would be after the m_pStrokeCur->FinishStroke()
statement. To save the state, add the following line of code after that statement:
pDoc->CheckPoint();
The CheckPoint()
member function saves the current state. Placing a CheckPoint()
command here saves the state after every stroke is completed.
At first glance, this may seem like all the states that need to be saved for Undo/Redo support. However, it turns out there are a couple of other cases that are important. When the CScribbleDoc
class is first instantiated or when the file is newed or opened, it is necessary to save the state, otherwise you won't be able to undo to the initial document state.
To do this, we need to execute the CheckPoint()
member function in both the CScribbleDoc::OnNewDocument()
member function and the CScribbleDoc::OnOpenDocument()
member function. Both of these member functions' implementation are similar. The CheckPoint()
member function should be added after the statement containing the reference to InitDocument()
. The following code shows the updated functions:
BOOL CScribbleDoc::OnNewDocument()
{
if (!COleServerDoc::OnNewDocument())
return FALSE;
InitDocument();
CheckPoint();
return TRUE;
}
BOOL CScribbleDoc::OnOpenDocument(LPCTSTR lpszPathName)
{
if (!COleServerDoc::OnOpenDocument(lpszPathName))
return FALSE;
InitDocument();
CheckPoint();
return TRUE;
}
After this change, the undo/redo commands are implemented. All that is necessary to make Undo/Redo work is to add menu support so that the user can access this functionality.
Add Undo/Redo Menu Support
Most applications created using the AppWizard in Visual C++ have a menu selection for undo. However, they don't provide a menu selection for redo. To make redo available, we need to add a redo menu item following the undo item in all of the edit menus defined in the application (and there are several of them). I used the identifier ID_EDIT_REDO
and defined the caption as "&Redo\tCtrl+Y". As the caption suggests, I also defined the accelerator Ctrl+Y for each of the redo menu entries.
Using the ClassWizard, we can now add the skeleton code that implements undo and redo. To do this, select the Message Maps tab and CScribbleView
class. Add functions for both the COMMAND and UPDATE_COMMAND_UI
message to the ID_EDIT_UNDO
and ID_EDIT_REDO
Object identifier's. This will create skeleton functions for OnEditUndo()
, OnEditRedo()
, OnUpdateEditRedo()
, and OnUpdateEditUndo()
. The implementation for each of these functions follows:
void CScribbleView::OnEditRedo()
{
CScribbleDoc* pDoc = GetDocument();
pDoc->Redo();
pDoc->UpdateAllViews(NULL);
}
void CScribbleView::OnUpdateEditRedo(CCmdUI* pCmdUI)
{
CScribbleDoc* pDoc = GetDocument();
pCmdUI->Enable(pDoc->CanRedo());
}
void CScribbleView::OnEditUndo()
{
CScribbleDoc* pDoc = GetDocument();
pDoc->Undo();
pDoc->UpdateAllViews(NULL);
}
void CScribbleView::OnUpdateEditUndo(CCmdUI* pCmdUI)
{
CScribbleDoc* pDoc = GetDocument();
pCmdUI->Enable(pDoc->CanUndo());
}
Undo/Redo is now fully implemented. All that is necessary is to test the application.
How CUndo works
The code is straightforward. It serializes the CDocument
into a CMemFile
and saves that state on the undo list. When an undo is requested, the second item on the list is serialized into the CDocument
and the first item placed on the Redo list (the first item isn't serialized because it contains the current state). There is only one trick used in the code. It is the concept of a Mix-in class.
A Mix-in is a class that is intended to add behavior to a derived class through multiple inheritance. The CUndo
class knows that and derived class will include an implementation for the virtual functions Serialize()
and DeleteContents()
. All it has to do to access these functions in the derived class is declare them as abstract virtual functions. Now, when the class is added to the inheritance list of CDocument
(or any class that supplies a Serialize()
and DeleteContents()
function) it is able to access these functions in the derived class.
Yes, Mix-in's aren't very intuitive, but they work well.
#ifndef _UNDO_H_
#define _UNDO_H_
class CUndo {
private:
CObList m_undolist;
CObList m_redolist;
long m_growsize;
long m_undoLevels;
long m_chkpt;
void AddUndo(CMemFile*);
void AddRedo(CMemFile *pFile);
void Load(CMemFile*);
void Store(CMemFile*);
void ClearRedoList();
public:
virtual void Serialize(CArchive& ar) = 0;
virtual void DeleteContents() = 0;
CUndo(long undolevels = 4, long = 32768);
~CUndo();
BOOL CanUndo();
BOOL CanRedo();
void Undo();
void Redo();
void CheckPoint();
void EnableCheckPoint();
void DisableCheckPoint();
};
inline CUndo::
CUndo(long undolevels, long growsize) :
m_growsize(growsize), m_undoLevels(undolevels),
m_chkpt(0)
{
;
}
inline void CUndo::
ClearRedoList()
{
POSITION pos = m_redolist.GetHeadPosition();
CMemFile* nextFile = NULL;
while(pos) {
nextFile = (CMemFile *) m_redolist.GetNext(pos);
delete nextFile;
}
m_redolist.RemoveAll();
}
inline CUndo::
~CUndo()
{
POSITION pos = m_undolist.GetHeadPosition();
CMemFile *nextFile = NULL;
while(pos) {
nextFile = (CMemFile *) m_undolist.GetNext(pos);
delete nextFile;
}
m_undolist.RemoveAll();
ClearRedoList();
}
inline BOOL CUndo::
CanUndo()
{
return (m_undolist.GetCount() > 1);
}
inline BOOL CUndo::
CanRedo()
{
return (m_redolist.GetCount() > 0);
}
inline void CUndo::
AddUndo(CMemFile* file)
{
if (m_undolist.GetCount() > m_undoLevels) {
CMemFile* pFile = (CMemFile *) m_undolist.RemoveTail();
delete pFile;
}
m_undolist.AddHead(file);
}
inline void CUndo::
Store(CMemFile* file)
{
file->SeekToBegin();
CArchive ar(file, CArchive::store);
Serialize(ar);
ar.Close();
}
inline void CUndo::
Load(CMemFile* file)
{
DeleteContents();
file->SeekToBegin();
CArchive ar(file, CArchive::load);
Serialize(ar);
ar.Close();
}
inline void CUndo::
CheckPoint()
{
if (m_chkpt <= 0) {
CMemFile* file = new CMemFile(m_growsize);
Store(file);
AddUndo(file);
ClearRedoList();
}
}
inline void CUndo::
EnableCheckPoint()
{
if (m_chkpt > 0) {
m_chkpt--;
}
}
inline void CUndo::
DisableCheckPoint()
{
m_chkpt++;
}
inline void CUndo::
AddRedo(CMemFile *file)
{
m_redolist.AddHead(file);
}
inline void CUndo::
Undo()
{
if (CanUndo()) {
CMemFile *pFile = (CMemFile *) m_undolist.GetHead();
m_undolist.RemoveHead();
AddRedo(pFile);
pFile = (CMemFile *)m_undolist.GetHead();
Load(pFile);
}
}
inline void CUndo::
Redo()
{
if (CanRedo()) {
CMemFile *pFile = (CMemFile *) m_redolist.GetHead() ;
m_redolist.RemoveHead();
AddUndo(pFile);
Load(pFile);
}
}
#endif