2007. november 25., vasárnap

Undo Redo using Commands


Problem/Question/Abstract:

There are 2 ways to do undo - redo, one is with state, the other is using commands. This artical explains using commands and provides full source code implementation of a TUndoRedoManager

Answer:

This article will cover

Command
Requirements of a command
Command Stack
Undo redo manager
Command grouping
Full source code implementation

A command is simply an object that implements an action in the system, for example in a paint program a command may be a line command, or a circle command, or a rectangle command, and so on.  In order to implement command based undo redo you must design your editing to use command objects.

Because we want to undo and redo the effects of commands, the commands themselves must be able to undo and redo their own action as well as execute the initial action.
The primary methods of a command is

Execute
Undo
Redo

You may wonder why there is a seprate Redo instead of simply reusing the Execute method.  This is because the redo implementation may be different than the Execute.  For example, if this were a paint command.  The Execute may choose the brush and follow some algorithm to draw some sort of gradual transparent circle.  The redo could simply copy a image of the results of the paint rather than painting again.  In any case, if this functionality is not needed then simply call the Execute method from within your Redo method.

Ok, so now we have one command.  We need to remember the sequence of commands so we can have multilevel undo and redo.  This is the command stack.

When you undo, you take the last command and call its undo method.  The next time you undo, you call the undo method of the 2nd  command from the top and so on.  When you  redo, you call the redo method of the last command that you called undo on.  To simplify this we create 2 lists, an undo list and a redo list and encapsulate these with an undo manager.

For the undoredo manager, we give it 3 methods.
ExecuteCommand(Command)
Undo
Redo
Internally the UndoRedoManager will maintain 2 lists of commands, Undo and Redo

Here is the full sequence:

Execute a command by passing it to the ExecuteCommand method, internally the UndoRedoManager will call the Execute method of the command and then add the command to the top of the Undo list.
Calling undo, the manager will take the last command in the undo list, call its undo method and then remove the command from the undo list and add it to the redo list.
Calling redo will do the reverse of undo, it will take the last command from the redo list, call its redo method, then remove it from the redo list and add it to the top of the undo list
Now, the next time ExecuteCommand is called, we must prune the redo list... delete all commands in it.

Sometimes, or most of the time, you will execute a bunch of commands as a single group.  Calling undo and redo should undo and redo this entire group and not the individual commands within it one at a time.  An example might be some wizard that did a lot of things, you would want to undo and redo this as one group.

I'll add 2 methods to the UndoRedoManager
BeginTransaction
EndTransaction

All commands executed between calls to BeginTransaction and EndTransaction will be stored as one group. You should be allowed to make nested calls to BeginTransaction and EndTransaction.

Using inheritence, this can be easy to implement.  We make a command group class that inherits from the Command, that way the manager acts as if it is working with single commands.

Below is the Full source code of a working UndoRedoManager along with interfaces for IUndoRedoCommand and  IUndoRedoCommandGroup.  Note: I think a lot of people associate delphi interfaces with ActiveX or COM and then think that interfaces ARE ActiveX or COM.  This is not true, you can create classes that implement interfaces and those classes do not have any implementation of ActiveX or COM.  They do not require registering and all the things that go with COM or ActiveX.  You should keep in mind that interfaces are reference counted, they are freed when there are not more references.

unit UndoRedoCommand;

interface
uses
  Classes, SysUtils;

type
  IUndoRedoCommand = interface(IUnknown)
    ['{D84BFD00-8396-11D6-B4FA-000021D960D4}']
    procedure Execute;
    procedure Redo;
    procedure Undo;
  end;

  IUndoRedoCommandGroup = interface(IUndoRedoCommand)
    ['{9169AE00-839B-11D6-B4FA-000021D960D4}']
    function GetUndoRedoCommands: TInterfaceList;
    property UndoRedoCommands: TInterfaceList read GetUndoRedoCommands;
  end;

  TUndoRedoCommandGroup = class(TInterfacedObject, IUndoRedoCommandGroup,
      IUndoRedoCommand)
  private
    FList: TInterfaceList;
    FCanRedo: Boolean;
  public
    constructor Create;
    destructor Destroy; override;
    procedure Execute;
    function GetUndoRedoCommands: TInterfaceList;
    procedure Redo;
    procedure Undo;
    property UndoRedoCommands: TInterfaceList read GetUndoRedoCommands;
  end;

  TUndoRedoManager = class(TObject)
  private
    FRedoList: TInterfaceList;
    FUndoList: TInterfaceList;
    FTransactLevel: Integer;
    FTransaction: IUndoRedoCommandGroup;
    function GetCanRedo: Integer;
    function GetCanUndo: Integer;
  public
    constructor Create;
    destructor Destroy; override;
    procedure BeginTransaction;
    procedure EndTransaction;
    procedure ExecCommand(const AUndoRedoCommand: IUndoRedoCommand);
    procedure Redo(RedoCount: Integer = 1);
    procedure Undo(UndoCount: Integer = 1);
    property CanRedo: Integer read GetCanRedo;
    property CanUndo: Integer read GetCanUndo;
  end;

implementation

{
**************************** TUndoRedoCommandGroup *****************************
}

constructor TUndoRedoCommandGroup.Create;
begin
  inherited Create;
  FList := TInterfaceList.Create;
end;

destructor TUndoRedoCommandGroup.Destroy;
begin
  FList.Free;
  inherited Destroy;
end;

procedure TUndoRedoCommandGroup.Execute;
var
  I: Integer;
begin
  for I := 0 to FList.Count - 1 do
    (FList[I] as IUndoRedoCommand).Execute;
end;

function TUndoRedoCommandGroup.GetUndoRedoCommands: TInterfaceList;
begin
  Result := FList;
end;

procedure TUndoRedoCommandGroup.Redo;
var
  I: Integer;
begin
  if FCanRedo then
  begin
    for I := 0 to FList.Count - 1 do
      (FList[I] as IUndoRedoCommand).Redo;

    FCanRedo := False;
  end
  else
    raise
      Exception.Create('Must call TUndoRedoCommandGroup.Undo before calling Redo.');
end;

procedure TUndoRedoCommandGroup.Undo;
var
  I: Integer;
begin
  if FCanRedo then
    raise Exception.Create('TUndoRedoCommandGroup.Undo already called');

  for I := FList.Count - 1 downto 0 do
    (FList[I] as IUndoRedoCommand).Undo;

  FCanRedo := True;
end;

{
******************************* TUndoRedoManager *******************************
}

constructor TUndoRedoManager.Create;
begin
  inherited Create;
  FRedoList := TInterfaceList.Create;
  FUndoList := TInterfaceList.Create;
end;

destructor TUndoRedoManager.Destroy;
begin
  FRedoList.Free;
  FUndoList.Free;
  inherited Destroy;
end;

procedure TUndoRedoManager.BeginTransaction;
begin
  Inc(FTransactLevel);
  if FTransactLevel = 1 then
    FTransaction := TUndoRedoCommandGroup.Create;
end;

procedure TUndoRedoManager.EndTransaction;
begin
  Dec(FTransactLevel);
  if (FTransactLevel = 0) then
  begin
    if FTransaction.UndoRedoCommands.Count > 0 then
    begin
      FRedoList.Clear;
      FUndoList.Add(FTransaction);
    end;
    FTransaction := nil;
  end
  else if FTransactLevel < 0 then
    raise
      Exception.Create('Unmatched TUndoRedoManager.BeginTransaction and EndTransaction');
end;

procedure TUndoRedoManager.ExecCommand(const AUndoRedoCommand:
  IUndoRedoCommand);
begin
  BeginTransaction;
  try
    FTransaction.UndoRedoCommands.Add(AUndoRedoCommand);
    AUndoRedoCommand.Execute;
  finally
    EndTransaction;
  end;
end;

function TUndoRedoManager.GetCanRedo: Integer;
begin
  Result := FRedoList.Count;
end;

function TUndoRedoManager.GetCanUndo: Integer;
begin
  Result := FUndoList.Count;
end;

procedure TUndoRedoManager.Redo(RedoCount: Integer = 1);
var
  I: Integer;
  Item: IUndoRedoCommand;
  RedoLast: Integer;
begin
  if FTransactLevel <> 0 then
    raise Exception.Create('Cannot Redo while in Transaction');

  // Index of last redo item
  RedoLast := FRedoList.Count - RedoCount;
  if RedoLast < 0 then
    RedoLast := 0;

  for I := FRedoList.Count - 1 downto RedoLast do
  begin
    Item := FRedoList[I] as IUndoRedoCommand;
    FRedoList.Delete(I);
    FUndoList.Add(Item);
    Item.Redo;
  end;
end;

procedure TUndoRedoManager.Undo(UndoCount: Integer = 1);
var
  I: Integer;
  Item: IUndoRedoCommand;
  UndoLast: Integer;
begin
  if FTransactLevel <> 0 then
    raise Exception.Create('Cannot undo while in Transaction');

  // Index of last undo item
  UndoLast := FUndoList.Count - UndoCount;
  if UndoLast < 0 then
    UndoLast := 0;

  for I := FUndoList.Count - 1 downto UndoLast do
  begin
    Item := FUndoList[I] as IUndoRedoCommand;
    FUndoList.Delete(I);
    FRedoList.Add(Item);
    Item.Undo;
  end;
end;

end.

Nincsenek megjegyzések:

Megjegyzés küldése