Undo & Redo


Among the many standard features of an editor program, the ability to undo and redo any changes is crucial for a stress-free user experience. As I had some users bug-testing the first version, this was one of the first suggestions that came up. It had been in the back of my mind but I also knew it would be a big undertaking, especially with the way my code was currently structured. Luckily, after my huge overhaul of the object classes, the project became much easier to visualize and implement.

I knew I needed to log each action before it was performed so that I could grab and store the old value, then revert to it if necessary. This list would need to function as a stack, since the last action is the first to be removed. For each entry on the stack, I would need to know the action type, the object it was performed on, and the old data attached to the action, if applicable. Then if the user pressed CTRL+Z, I would just need to grab the last action from the list and apply the old data to the object pointer based on the action type.

With this plan in mind, I began by writing out a struct to store actions:

// Structure used to store each action's data
struct MapAction
{
	// The object this action applies to
	BaseObject::Ptr mObject;
	// The type of action performed
	int mType;
	// Extra data this action may need
	String mData;

	MapAction(BaseObject::Ptr object = 0, const int& type = ACTION_NONE, String data = "")
	{
		mObject = object;
		mType = type;
		mData = data;
	}

	// Is this a valid action?
	const bool isValid() const
	{
		return (mType != ACTION_NONE);
	}
};

alongside an enum for the action types:

// Action types for ActionLogger
enum ACTIONTYPE
{
	ACTION_NONE,

	// Universal
	ACTION_POSITION,
	ACTION_DELETE,
	ACTION_ADD,

	// Object
	ACTION_OBJECTSCALE,
	ACTION_OBJECTROTATION,
	ACTION_OBJECTMATERIAL,
	ACTION_OBJECTSOUND,
	ACTION_OBJECTMESH,

	// WaterPlane
	ACTION_WATERPLANESCALE,
	ACTION_WATERPLANEMATERIAL,
	ACTION_WATERPLANESOUND,

	// Portal
	ACTION_PORTALSCALE,
	ACTION_PORTALDESTINATION,

	// Gate
	ACTION_GATEMATERIAL,
	ACTION_GATEDESTINATION,
	ACTION_GATEDESTINATIONPOSITION,

	// Particle
	ACTION_PARTICLENAME,
	ACTION_PARTICLESOUND,

	// Light
	ACTION_LIGHTCOLOR,

	// Billboard
	ACTION_BILLBOARDSCALE,
	ACTION_BILLBOARDMATERIAL,

	// CollBox
	ACTION_COLLBOXSCALE,

	// CollSphere
	ACTION_COLLSPHERESCALE
};

With these in place, I was able to create a simple stack algorithm for adding actions to the list:

void ActionLogger::logAction(BaseObject::Ptr object, const int& type, const String& data)
{
	// Don't exceed max actions
	if (mActionHistory.size() >= MAX_ACTION_HISTORY)
	{
		// Shift actions up to delete oldest one
		for (unsigned int i = 0; i < (MAX_ACTION_HISTORY - 1); i++)
		{
			mActionHistory[i] = mActionHistory[i + 1];
		}

		// Remove last action
		mActionHistory.pop_back();
	}

	// Add new action
	mActionHistory.emplace_back(object, type, data);
}

Now all I needed to do was start logging actions. My MapManager class already has function calls to set attributes on the selected object by type, so it was easy enough to call the action logger in each of them:

void MapManager::setSelectedObjectScale(const Real& val, const bool& isLocked, const int& axis)
{
	Object::Ptr tSelectedObject = getSelectedObject();

	// Log previous scale
	mUndoLogger->logAction(mSelectedObject, ACTION_OBJECTSCALE,
		StringConverter::toString(tSelectedObject->getScale()));

	// Scale ratio locked, scale on all axes
	if (isLocked)
	{
		tSelectedObject->setLockedScale(val);
	}
	// Only scale on one axis
	else
	{
		tSelectedObject->setScale(val, axis);
	}

	// Store changes
	mTempStorageManager->logObjectChange(mSelectedObject, "MapManager::setSelectedObjectScale");
}

void MapManager::setSelectedObjectRotation(const Real& val, const int& axis)
{
	Object::Ptr tSelectedObject = getSelectedObject();

	// Log previous rotation
	mUndoLogger->logAction(mSelectedObject, ACTION_OBJECTROTATION,
		StringConverter::toString(tSelectedObject->getRotation()));

	tSelectedObject->setRotation(val, axis);

	// Store changes
	mTempStorageManager->logObjectChange(mSelectedObject, "MapManager::setSelectedObjectRotation");
}

// ...etc

Finally, I needed a function that would apply the provided data to the object based on action type (ie the function called on CTRL+Z):

void MapManager::applyAction(const MapAction& action)
{
	BaseObject::Ptr tActionObject = action.mObject;

	// Apply action
	switch (action.mType)
	{
		// Universal object position
	case ACTION_POSITION:
		tActionObject->setPosition(action.mData);
		break;

		// Object scale
	case ACTION_OBJECTSCALE:
		getObject(tActionObject)->setScale(action.mData);
		break;

		// Object rotation
	case ACTION_OBJECTROTATION:
		getObject(tActionObject)->setRotation(action.mData);
		break;
		
// ...etc

This system worked well for Undo, which was originally all I had done and released in the first 2.0 alpha. However, at the time I had not logged object creations for undo, as I was worried about users accidentally deleting objects in the undo chain. This was the reason I decided to try adding Redo as well - initially it seemed too complicated to have both of them, especially with some of my other systems, but I decided that even if I could only store and redo one action, it would be better than nothing.

However, I realized very quickly as I began writing a Redo logger that it has the exact same functionality as Undo. Since the logger was already encapsulated in its own class, I decided to scrap the new class and create another instance of my existing class:

ActionLogger* mUndoLogger;
ActionLogger* mRedoLogger;

...

mUndoLogger = new ActionLogger();
mRedoLogger = new ActionLogger();

As I began to try to call the logger on CTRL+Y, I realized that all I needed to do was remember the object’s previous data before calling Undo, that way I could restore it on Redo. All the loggers need to do is swap the object’s current and stored attribute data then pass it to the opposite logger. With this in mind, I could create an extremely simple function in ActionLogger that would swap the data:

void ActionLogger::receiveAction(MapAction actionData)
{
	/* Since the opposite logger will revert the object to its *current* state, we need 
	to replace the data string with its current value */
	actionData.mData = actionData.mObject->getActionAttributeString(actionData.mType);

	// Call base function with modified data
	ActionLogger::logAction(actionData.mObject, actionData.mType, actionData.mData);
}

Now I could simply call my existing applyAction() function on the RedoLogger and it works as intended.

This system was perfect for basic attribute edits, but the goal of Redo in the first place was to undo/redo object creations, which would be a bit more complicated. At the time of writing my first ActionLogger I was still using raw pointers to store my objects and freeing the memory when the object was deleted by the user. When I added Undo, in order to log deletions I was grabbing the object’s attributes as a data string and re-creating it when Undo was triggered. This started to become hard to track when passing data between the loggers, and in addition, I have another temporary storage system to retrieve progress on crash that needed access to the object data.

With all these issues in mind, I switched to shared_ptrs to store my objects. Now I am able to keep objects in memory after they are “deleted” by the user in order to access them in the loggers and temporary storage, then they are freed when they are removed from temporary storage. To support this system, I added an IsCreated variable to my objects to track if they are actually created in the scene or not, and a recreate() function to restore them visually. Now to undo a deletion or redo a creation, I just need to call recreate() on the object’s preexisting pointer:

void BaseObject::recreate(SceneManager* sceneMgr)
{
	// Calls derived function
	create(sceneMgr, mID, mPosition);
}

I also realized that undoing a deletion is the same as redoing a creation, and vice versa. Because of this, I could simply swap the action type when the actions move between the loggers:

void MapManager::transferAction(const bool& isRedo)
{
	// Assign loggers
	ActionLogger* tSender = 0;
	ActionLogger* tReceiver = 0;
	if (isRedo)
	{
		tSender = mRedoLogger;
		tReceiver = mUndoLogger;
	}
	else
	{
		tSender = mUndoLogger;
		tReceiver = mRedoLogger;
	}

	// Grab action data from sender
	MapAction tAction = tSender->popLastAction();

	// Validate
	if (tAction.isValid())
	{
		// Grab action object
		BaseObject::Ptr tObject = tAction.mObject;

		// Special case: deletions become creations when we swap loggers
		if (tAction.mType == ACTION_DELETE)
		{
			// Pass to receiver
			tReceiver->receiveAction(
				MapAction(tObject, ACTION_ADD));
		}
		// Special case: creations become deletions when we swap loggers
		else if (tAction.mType == ACTION_ADD)
		{
			// Pass to receiver
			tReceiver->receiveAction(
				MapAction(tObject, ACTION_DELETE));
		}
		else
		{
			// Pass to receiver
			tReceiver->receiveAction(
				tAction);
		}

		// Apply action
		applyAction(tAction);
	}
}

and my add/delete functions in applyAction simply recreate or delete the stored object pointer:

case ACTION_ADD:
	// Recreate object
	tActionObject->recreate(mSceneMgr);

	// Readd to list
	mPlacedObjects.push_back(tActionObject);

	// Select
	setSelectedObject(tActionObject);

	// Pick up if necessary
	mIsPlacingObject = tActionObject->getIsHeld();

	break;

	// Universal object delete
case ACTION_DELETE:
	// Clear selection without resetting held state
	if (mSelectedObject)
	{
		resetSelectedObject(false);
	}

	// Destroy object
	deleteObject(tActionObject);

	break;

Now users can undo/redo creations and deletions without issue. The last step was to reset the Redo logger whenever the Undo chain is broken. Since they use the same class the setup for this is a bit odd, but it does work. I added an ActionLogger pointer to the class itself so I could pass the RedoLogger to the UndoLogger:

// Store a pointer to the Redo logger in the Undo logger since
// it needs to be cleared when a new action is logged
ActionLogger* mCallbackLogger;

...

// Pass Redo to Undo because Undo needs to clear it when new actions are logged
mUndoLogger->initialize(mRedoLogger);

Then any time an action is logged via anything BUT the receiveAction() function, I can call reset() on the redo logger:

ActionLogger::logAction()

// Action was done by the user, reset the Redo logger
if (!isFromLogger && mCallbackLogger)
{
	mCallbackLogger->reset();
}

In the end, my initial modular ActionLogger allowed me to add Redo functionality with minor edits to the existing system. In fact, it was only three steps: creating a new instance of the logger, editing the action type or data when swapping between loggers, and clearing the redo logger when the action chain is broken.