Undo/Redo realisieren


Zu einem professionellen Programm gehört es dazu, dass der Benutzer seine Eingabe zurücknehmen kann. War früher nur ein einmaliges "Rückgängig" möglich, so lassen sich bei modernen Programmen fast beliebig viele Schritte rückgängig machen.
Leider bieten die wenigsten Controls in Windows eine vernünftige Rückgängig-Funktion an. Für komplette Formulare muss man sich so eine Funktionalität sowieso selbst schreiben. Wie das geht, wird in diesem Artikel anhand einer Undo/Redo-Klasse vorgestellt.

Vorüberlegungen

Wie müssen wir vorgehen, um eine Rückgängig-Funktionalität in ein Programm einzubauen? Offensichtlich müssen wir uns merken, welche Veränderungen der Benutzer vornimmt. Da wir sowohl eine Rückgängig- als auch eine Wiederherstellen-Funktionalität implementieren wollen, merken wir uns zuerst den Zustand vor der Änderung, und danach den Zustand nach der Änderung. So können wir zwischen diesen Zuständen hin- und herspringen.
Diese Zustände speichern wir einfach in zwei Listen; so können wir Schritt für Schritt alle Änderungen entlang der Liste zurücknehmen bzw. wiederherstellen.

Ein Hinweis noch:
Man sollte niemals nur einen Teil der möglichen Änderungen in die Listen aufnehmen. Warum? Nehmen wir an, wir schreiben einen Editor. Wir können schon eine Texteingabe rückgängig machen, aber noch nicht das Löschen von Text. Wir geben also irgendwo im Text etwas ein:
Steht schon. Kommt dazu. Steht auch schon.
Es werden also zwölf Zeichen an Position 14 eingefügt. Nun löschen wir am Anfang des Textes einen Buchstaben:
teht schon. Kommt dazu. Steht auch schon.
Wenn wir jetzt die Texteingabe rückgängig machen, werden zwölf Zeichen ab Position 14 gelöscht:
teht schon. KSteht auch schon.
Das war doch nicht unser Text vor der Eingabe, oder? Es ist also wichtig, alle Änderungen zu speichern, da diese aufeinander aufbauen können.

Classisch

Um unsere Listen zu verwalten, benötigen wir einige Strukturen. Wir müssen ja z. B. wissen, an welcher Position wir uns in der Liste befinden.
Deshalb habe ich einige Klassen geschrieben, die uns diese Verwaltung abnehmen. Um die Funktionen der Klassen auch in Plugins verwenden zu können, sind die Klassen mit Interfaces definiert. Wer sich jetzt fragt, was ein Interface ist, möge bitte den Artikel Einführung in Interfaces lesen.
Sehen wir uns das erste Interface einmal an:
type
  TActionType = (atUndo, atRedo);

  IUndoRedo = interface(IInterface)
  ['{0C7CD8C2-ED32-4044-9564-EC04B54C3BE3}']
    function GetUndoText: PWideChar; stdcall;
    function GetRedoText: PWideChar; stdcall;
    function GetUndoAction: Integer; stdcall;
    function GetRedoAction: Integer; stdcall;
    function GetPosition: Integer; stdcall;
    function GetLanguage: Integer; stdcall;
    procedure SetLanguage(const Value: Integer); stdcall;

    procedure Clear; stdcall;

    function IsFirst: Boolean; stdcall;
    function IsLast: Boolean; stdcall;

    procedure Undo; stdcall;
    procedure Redo; stdcall;

    procedure AddActionToUndo(const Action: Integer;
      const ActionType: TActionType; const P1, P2: Integer); stdcall;
    procedure CancelUndo; stdcall;

    function RegisterAction(GetName: TGetName; AddAction: TAddAction;
      UndoAction: TUndoAction): Integer; stdcall;

    property Position: Integer read GetPosition;
    property UndoText: PWideChar read GetUndoText;
    property RedoText: PWideChar read GetRedoText;
    property UndoAction: Integer read GetUndoAction;
    property RedoAction: Integer read GetRedoAction;
    property Language: Integer read GetLanguage write SetLanguage;
  end;
Für uns sind erst einmal nur vier Methoden interessant:
  procedure Undo; stdcall;
  procedure Redo; stdcall;

  procedure AddActionToUndo(const Action: Integer;
    const ActionType: TActionType; const P1, P2: Integer); stdcall;

  function RegisterAction(GetName: TGetName; AddAction: TAddAction;
    UndoAction: TUndoAction): Integer; stdcall;
Die Methoden Undo und Redo dürften klar sein. Wozu aber sind die anderen beiden nötig?
Nun, AddActionToUndo ist die Methode, mit der der aktuelle Zustand gesichert werden kann. Wie das geht, sehen wir weiter unten.

Beschäftigen wir uns zunächst mit RegisterAction. Damit wird eine Aktion registriert, die die eigentliche Rückgängig-Funktion bereitstellt. Dieser Methode werden drei Funktionen übergeben, die folgendermaßen definiert sein müssen:
type
  TGetName = function(const Action, Language: Integer): PWideChar; stdcall;
  TAddAction = procedure(const Action: IUndoRedoAction;
    const ActionType: TActionType; const P1, P2: Integer); stdcall;
  TUndoAction = procedure(const Action: IUndoRedoAction;
    const ActionType: TActionType); stdcall;
GetName liefert einen String zurück, der die Aktion beschreibt. Dieser Text kann z. B. als Hint des Rückgängig-Knopfes verwendet werden.
AddAction speichert den aktuellen Zustand. Dazu wird diese Funktion jeweils vor und nach der Änderung aufgerufen. Mit den Variablen P1 und P2 können dabei beliebige Werte übergeben werden.
UndoAction nimmt die gespeicherten Änderungen wieder zurück.

Achtung:
Diese Funktionen müssen immer mit stdcall definiert werden!

Wie wir sehen, gibt es hier noch ein Interface: IUndoRedoAction. Sehen wir uns das näher an:
type
  IUndoRedoAction = interface(IInterface)
  ['{EF26318F-E6BA-4463-AA84-5CBDE9698596}']
    function GetAction: Integer; stdcall;
    function GetData: Pointer; stdcall;
    function GetInterfaceList: IIntfList; stdcall;
    function GetObjectList: IInterfacedList; stdcall;
    procedure SetData(const Value: Pointer); stdcall;

    property Action: Integer read GetAction;
    property Data: Pointer read GetData write SetData;
    property InterfaceList: IIntfList read GetInterfaceList;
    property ObjectList: IInterfacedList read GetObjectList;
  end;
Dieses Objekt ist nur ein Container, der die Änderungen aufnehmen kann. Dazu besitzt es verschiedene Listen:
ObjectList kann Objekte aufnehmen; diese werden automatisch zerstört, wenn sie nicht mehr benötigt werden.
InterfaceList kann Interfaces aufnehmen.
Data kann einen beliebigen Pointer aufnehmen; Wird ein Objekt übergeben, so wird dieses nicht automatisch zerstört.
Dieses Objekt wird beim Speichern der Änderungen befüllt und hält die Änderungen vor, damit wir sie beim Rückgängigmachen zurücknehmen zu können.

Wozu brauchen wir nun das alles und wie bauen wir es zusammen? Das lässt sich wahrscheinlich am Besten an Hand eines Beispiels vermitteln.

Beispielhaft

Unser Beispielprogramm wird ein Editor.
Für unser Beispiel benötigen wir zwei SpeedButtons (Rückgängig und Wiederherstellen), ein Memo und einen Timer. Das Timer-Intervall stellen wir auf 300 und deaktivieren den Timer.
Beispielprogramm
Beispielprogramm

Jetzt wird's ernst. Wir müssen unsere Rückgängig-Funktionen definieren.
Wir wollen nur eine Aktion rückgängig machen, nämlich die Texteingabe. Deshalb benötigen wir auch nur drei Funktionen.
Als erstes definieren wir eine Variable:
var
  UEDITMEMO: Integer;
Diese Variable wird die Nummer aufnehmen, unter der unsere Aktion registriert wird.

Beginnen wir nun mit dem Einfachsten: Dem Namen.
function GetName(const Action, Language: Integer): PWideChar; stdcall;
begin
  Result := '';
  if Action = UEDITMEMO then
    Result := 'Texteingabe';
end;
Diese Funktion gibt uns den richtigen Text zu unserer Aktion.

Bevor wir die weiteren Funktionen definieren, müssen wir uns überlegen: Was wollen wir eigentlich sichern?
Um unser Beispiel einfach zu halten, sichern wir den gesamten Text unseres Memos.
Zusätzlich speichern wir die aktuelle Cursorposition und welcher Text markiert ist.
Wir bauen uns also eine Klasse, die diese Eigenschaften aufnehmen kann:
type
  TUndoRedoMemo = class(TObject)
    Text: AnsiString;
    SelStart: Integer;
    SelLength: Integer;
  end;
Nachdem wir unseren Container definiert haben, können wir uns ans Sichern machen:
procedure AddMemoAction(const Action: IUndoRedoAction;
  const ActionType: TActionType; const P1, P2: Integer); stdcall;
var
  UndoRedoMemo: TUndoRedoMemo;
  Memo: TMemo;
begin
  // Memo sichern
  Memo := TMemo(P1);
  Action.Data := Memo;
  // Container erstellen und füllen
  UndoRedoMemo := TUndoRedoMemo.Create;
  UndoRedoMemo.Text := Memo.Text;
  UndoRedoMemo.SelStart := Memo.SelStart;
  UndoRedoMemo.SelLength := Memo.SelLength;
  // Container speichern
  Action.ObjectList.Add(UndoRedoMemo);
end;
Wir übergeben also unser Memo mit P1. Dann erstellen wir uns einen Container und legen darin die Eigenschaften ab, die wir uns merken wollen. Schließlich fügen wir den Container der ObjectList der Action hinzu und sind fertig.
Eine solche Funktion muss für jede Aktion geschrieben werden, die wir rückgängig machen sollen. Dabei kann es vorkommen, dass beim Sichern vor dem Ändern (ActionType = atUndo) andere Eigenschaften gespeichern werden als danach (ActionType = atRedo).

Das Rückgängigmachen ist jetzt eine leichte Aufgabe:
procedure UndoMemoAction(const Action: IUndoRedoAction;
  const ActionType: TActionType); stdcall;
var
  UndoRedoMemo: TUndoRedoMemo;
  Memo: TMemo;
begin
  UndoRedoMemo := Action.ObjectList[0];
  // Memo auslesen
  Memo := Action.Data;
  // Eigenschaften setzen
  Memo.Text := UndoRedoMemo.Text;
  Memo.SelStart := UndoRedoMemo.SelStart;
  Memo.SelLength := UndoRedoMemo.SelLength;
end;
Wir holen uns den Container aus der Liste, unser Memo aus Data und setzen einfach die Eigenschaften zurück.

Damit haben wir alles zusammen, was wir zum Rückgängigmachen von Eingaben in einem Memo benötigen.
Nun müssen wir diese Funktionen natürlich in unser Programm einbauen:
Zuerst definieren wir uns ein UndoRedo-Interface (Units "UndoRedoIntf" und "CreateUndoRedoObject" nicht vergessen!):
type
  TForm1 = class(TForm)
    Memo1: TMemo;
    UndoButton: TSpeedButton;
    RedoButton: TSpeedButton;
    Timer1: TTimer;
  private
    FUndoRedo: IUndoRedo;
  public
  end;
Das Interface müssen wir uns natürlich erst holen. Das erledigen wir in FormCreate. Hier registrieren wir gleich noch unsere Funktionen. Dabei merken wir uns die Nummer, unter der die Funktionen registriert werden:
procedure TForm1.FormCreate(Sender: TObject);
begin
  // UndoRedo-Interface erzeugen
  FUndoRedo := CreateUndoRedo;
  //Handler registrieren
  UEDITMEMO := FUndoRedo.RegisterAction(GetName, AddMemoAction, UndoMemoAction);
end;
In FormDestroy löschen wir noch unser Interface:
procedure TForm1.FormDestroy(Sender: TObject);
begin
  // UndoRedo-Interface zerstören
  FUndoRedo := nil;
end;
Damit wir unsere Buttons komfortabel steuern können, bauen wir uns noch eine Hilfs-Methode:
procedure TForm1.SetButtons;
begin
  // Buttons einstellen
  UndoButton.Enabled := not FUndoRedo.IsFirst; // Sind wir am Anfang unserer Liste?
  RedoButton.Enabled := not FUndoRedo.IsLast;  // Oder vielleicht am Ende?
  // Namen der Aktion holen
  UndoButton.Hint := 'Rückgängug: ' + FUndoRedo.UndoText;
  RedoButton.Hint := 'Wiederherstellen: ' + FUndoRedo.RedoText;
end;
Bevor wir jetzt zum wichtigesten Teil kommen, definieren wir erstmal die Ereignismethoden unserer Buttons:
procedure TForm1.UndoButtonClick(Sender: TObject);
begin
  // Rückgängig
  FUndoRedo.Undo;
  SetButtons;
end;

procedure TForm1.RedoButtonClick(Sender: TObject);
begin
  // Wiederherstellen
  FUndoRedo.Redo;
  SetButtons;
end;
Jetzt müssen wir aber endlich mal was sichern. Aber wann? Natürlich sichern wir den Zustand des Memos, bevor wir etwas einfügen. Da hilft uns die Eigenschaft OnKeyDown.
Aber wozu brauchen wir den Timer? Mit dem Timer warten wir, bis in einem bestimmten Intervall keine Taste mehr gedrückt wurde, bevor wir den geänderten Zustand sichern. Ansonsten würden wir jeden Tastendruck einzeln rückgängig machen. Um das zu verhindern, starten wir bei jedem Tastendruck den Timer neu.

Hier also unsere Methode:
procedure TForm1.Memo1KeyDown(Sender: TObject; var Key: Word;
  Shift: TShiftState);
begin
  if Timer1.Enabled then // Haben wir den Zustand schon gesichert?
  begin
    Timer1.Enabled := False; // Timer zurücksetzen...
    Timer1.Enabled := True;
    Exit;                    // ...und raus
  end;

  // Originalzustand sichern, dabei Memo übergeben
  FUndoRedo.AddActionToUndo(UEDITMEMO, atUndo, Integer(Memo1), 0);

  // Rückgängig einschalten
  UndoButton.Enabled := True;
  UndoButton.Hint := 'Rückgängug: ' + FUndoRedo.UndoText;

  // Timer aktivieren
  Timer1.Enabled := True;
end;
Die wichtigste Zeile ist
FUndoRedo.AddActionToUndo(UEDITMEMO, atUndo, Integer(Memo1), 0);
Damit sichern wir den Zustand vor der Änderung (atUndo) und übergeben dabei unser Memo in P1; außerdem natürlich, dass wir dafür die Funktion verwenden wollen, die unter der Nummer UEDITMEMO registriert wurde (AddMemoAction).

Um unsere Aktion abzuschließen, müssen wir noch den Zustand nach der Änderung speichern. Das machen wir über den Timer:
procedure TForm1.Timer1Timer(Sender: TObject);
begin
  // Timer deaktivieren
  Timer1.Enabled := False;

  // Veränderten Zustand sichern, dabei Memo übergeben
  FUndoRedo.AddActionToUndo(UEDITMEMO, atRedo, Integer(Memo1), 0);
  SetButtons;
end;
Wieder übergeben wir unser Memo und die Nummer unserer Aktion, diesmal aber mit dem Argument atRedo. Damit ist unsere Aktion abgeschlossen und wir können die Buttons anpassen, um den Zustand zu dokumentieren.

Damit ist unser kleines Demo-Programm fertig. Am Ende des Artikels ist der gesamte Quelltext als Archiv verlinkt.

Zusammenfassung

Gehen wir nochmal die Schritte durch, die wir unternehmen müssen:

Vorbereitungen Im Programm Ein komplexes Beispiel, bei dem verschiedene Aktionen definiert und registriert werden, ist MWKEdit.

Downloads

Undo-/Redo-Klasse mit Beispielprogrammen (232 KB)

Kommentare

Kommentar erstellen

© Martin Walter Computerservice
Alle Rechte vorbehalten
Vervielfältigung nur mit Genehmigung von Martin Walter