Einleitung
Top Next

In diesem Tutorial soll gezeigt werden, wie man mit Borland Delphi ein Undo/Redo realisiert.

Fast jedem von uns ist es wahrscheinlich schon einmal passiert, dass er in einem Programm aus versehen etwas gelöscht hat und die Arbeit verloren war, da das Programm den Menüpunkt "Bearbeiten/Rückgängig" nicht kannte. Einige Programme unterstützen zumindest die unmittelbar letzte Aktion rückgängig zu machen. Ein professionelles Programm sollte aber ein mehrfaches "Rückgängig machen" und auch ein mehrfaches "Widerrufen" unterstützen.
Da die englischen Ausdrücke "Undo" und "Redo" deutlich kürzer sind und vor allem das "Widerrufen" nach Meinung des Autors eine sehr missverständliche Formulierung ist, werden ab jetzt die englischen Ausdrücke in dem Tutorial verwendet.

Zuerst werden wir uns klar machen, was prinzipiell gemacht werden muss, damit man ein Undo/Redo programmieren kann.
Danach werden wir an einem konkreten Beispiel sehen, wie man das in der Praxis umsetzt. Wir werden einem Formular beibringen, über mehrere Eingabefelder hinweg ein Undo/Redo zu ermöglichen. TEdit unterstützt bereits standardmäßig ein Undo beim Drücken der Tasten Strg+Z. Ein erneutes Drücken dieser Tasten wirkt sich aber wieder nur auf das fokussierte Editfeld aus und nicht auf das Editfeld in dem man evtl. unmittelbar vorher etwas geändert hat. Dies werden wir ändern.

Wir werden allerdings nicht alles von Grund auf selbst programmieren. Wir werden auf Klassen aufsetzen, die bereits ein allgemeines Framework für Undo/Redo zur Verfügung stellen. Diese Klassen stehen kostenlos zur Verfügung und man kann sie sich von der Internetseite http://www.kasparsoft.de/UndoRedo/Download.htm herunterladen. Es stehen Versionen für Delphi 5 und Delphi 6 zur Verfügung.
Diese Klassen sind nicht nicht ganz trivial, da sie nicht nur einfache Undo/Redo-Fälle, sondern auch hoch komplexe unterstützen sollen.

Damit wir ein erstes Gefühl für den Umgang mit diesen Klassen bekommen, werden wir in unserem ersten Beispiel ein Undo/Redo über zwei TEditfelder hinweg programmieren.
Danach geht es ins Eingemachte. Dann werden wir sehen, wie man sich eigene Komponenten programmiert, die Undo/Redo unterstützen. Wir werden uns von TEdit, TCheckBox und TStringGrid eigene Komponenten ableiten. Wenn man diese dann in Zukunft auf seinen Formularen platziert, dann muss man nichts mehr machen und es wird automatisch ein Undo/Redo über diese Eingabefelder hinweg unterstützt.

Zum Schluss werden wir noch ein Tool kennen lernen, dass uns beim Debuggen unter die Arme greift.

Jetzt wollen wir uns aber erst einmal klar machen, was man bei Undo/Redo überhaupt machen muss.


Die trockene Theorie
Previous Top Next

Hier führen wir einige theoretische Überlegungen zu Undo/Redo durch, damit uns später bewusst ist, was wir hier eigentlich treiben.

Undo/Redo verstehen
Der Aufbau einer Änderung
Hierarchische Teiländerungen
Undo/Redo durchführen


Undo/Redo verstehen

Ein Undo/Redo kann man in den unterschiedlichsten Programmen finden. Uns allen ist es aus der Borland Delphi Entwicklungsumgebung vertraut. Dort kann man damit seine letzen Codeeingaben rückgängig machen und dieses Undo wiederum Rückgängig machen, was dann einem Redo entspricht. Entsprechendes findet man in jedem besseren Textverarbeitungsprogramm wie z.B. Microsoft Word oder dem Star Writer. Auch in Grafikprogrammen, wie z.B. Corel Draw findet man ein Undo/Redo. Dort wird dann in der Regel keine Eingabe von Text, sondern das Bearbeiten von grafischen Elementen rückgängig gemacht.
Egal wo ein Undo/Redo eingesetzt wird, es folgt immer einem bestimmtem Ablauf:

1. Sobald eine Änderung vorgenommen wurde, steht der Menüpunkt "Bearbeiten/Rückgängig" zur Verfügung.
2. Wenn ein Undo ausgelöst wird, dann wird die zuletzt durchgeführte Änderung rückgängig gemacht.
3. Ein erneutes Auslösen von Undo macht die letzte Änderung, die noch nicht rückgängig gemacht wurde, rückgängig.
4. Wenn das erste mal ein Undo durchgeführt wurde, steht auch der Menüpunkt "Bearbeiten/Widerrufen" zur Verfügung.
5. Das Auslösen eines Redo, führt die zuletzt rückgängig gemachte Änderung erneut durch.
6. Wenn wieder eine normale Änderung gemacht wird, dann wird der Menüpunkt "Bearbeiten/Widerrufen" disabled und alle bis dahin rückgängig gemachten Änderungen können nicht mehr erneut durchgeführt werden.

Intern kann man sich das als Liste von Änderungen vorstellen. Dazu kommt ein Zeiger, an dem man sehen kann, welche Änderung von einem Undo und welche Änderung von einem Redo betroffen wäre.

Wenn die erste Änderung durchgeführt wird, dann wird diese der bis dahin leeren Liste hinzugefügt.

Unser Zeiger zeigt auf diese Änderung. Damit wissen wir, dass bei einem Undo diese Änderung rückgängig gemacht werden würde. Da in der Liste sich hinter dieser Änderung keine weitere Änderung befindet, wissen wir, dass kein Redo möglich ist.

Wenn eine zweite Änderung durchgeführt wird, dann wird diese hinten an die Liste angehängt.

Gleichzeitig wird der Zeiger so umgesetzt, dass er jetzt auf die zweite Änderung zeigt.

Mit allen weiteren Änderungen wird genauso verfahren.

Wenn ein Undo ausgelöst wird, dann wird die Änderung, auf die der Zeiger zeigt, rückgängig gemacht und der Zeiger zeigt jetzt auf die Änderung davor.

Da es jetzt eine Änderung hinter der gibt, auf die der Zeiger zeigt, ist jetzt auch ein Redo möglich. Wir führen aber erst noch ein Undo aus.

Änderung 4 wird rückgängig gemacht und der Zeiger zeigt auf die Änderung 3. Was bei unserem ersten Redo passiert dürfte klar sein.

Die Änderung 4 wird erneut ausgeführt und der Zeiger zeigt auch auf die Änderung 4. Jetzt wäre es noch möglich ein weiteres Redo durchzuführen. Statt dessen machen wir jetzt aber wieder eine ganz normale Änderung.

Alle Änderungen hinter der Änderung 4, was bei uns nur die Änderung 5 war, wurden aus der Liste entfernt. Die gerade gemachte Änderung wurde hinten an die Liste angehängt und der Zeiger zeigt auf diese. Ein Redo ist jetzt somit nicht möglich.

Man hätte jetzt die Änderung 5 nicht entfernen müssen. Man könnte aus der Liste auch eine Baumstruktur machen. Dann würde es jetzt nach der Änderung 4 einen Zweig geben, der die Änderung 5 enthält und einen, der die Änderung 6 enthält. Nach einem Undo der Änderung 6 würden jetzt zwei Redo-Möglichkeiten zur Verfügung stehen.
Eine derartige Lösung wird man aber in der Praxis hoffentlich nie zu sehen bekommen. Die dafür benötigte Benutzeroberfläche würde deutlich komplexer werden, wodurch die meisten Anwender sicherlich überfordert wären. Bereits das einfache Standardverhalten wird von einigen Anwender nicht verstanden, deshalb vergessen wir die Baumlösung sofort wieder.

Im nächsten Abschnitt überlegen wir uns, was wir genau in unserer Liste speichern müssen.


Der Aufbau einer Änderung

Im vorherigen Abschnitt haben wir gesehen, dass man alle Änderung in einer Liste mit einem Zeiger organisieren kann. Jetzt überlegen wir uns, was genau für eine Änderung abgespeichert werden muss, damit man diese rückgängig machen und erneut durchführen kann.

Eigentlich ist es ja ganz einfach. Damit eine Änderung rückgängig gemacht werden kann, muss man wissen, was geändert wurde und wie der Zustand vor der Änderung war.

Nehmen wir an, wir haben mit Corel Draw eine Zeichnung angefertigt. Jetzt ändern wir die Position einer Linie. Corel Draw muss sich jetzt merken, dass sich bei dieser Linie etwas geändert hat, dass sich die Position der Linie geändert hat und wie die Position der Linie vorher war. Damit auch ein Redo klappt, wird sich zusätzlich noch gemerkt, welche Position die Linie nach der Änderung hat.

Etwas allgemeiner ausgedrückt, muss man sich bei einer Änderung folgendes merken:

· Das Objekt, das geändert wurde
· Welcher Zustand des Objektes sich geändert hat
· Wie der Zustand vor der Änderung war
· Wie der Zustand nach der Änderung war

Anstelle des Zustands vor und nach der Änderung könnte man sich auch das Delta zwischen den beiden Zuständen merken.

Es scheint also ganz einfach zu sein.
Leider ist es viel komplizierter als es auf den ersten Blick aussieht. Nehmen wir an, dass bei unserem Corel Draw Beispiel nicht nur eine Linie, sondern mehrere Objekte selektiert waren, die auf einmal verschoben wurden. Jetzt muss sich Corel Draw bereits eine Liste von Objekten und Zuständen merken. Noch aufwendiger wird das bei einer Löschoperation. Dann müssen sich alle Zustände der betroffenen Objekte gemerkt werden. So richtig ekelhaft wird es, wenn es Abhängigkeiten zwischen Objekten gibt.

Wenn wir versuchen würden für eine Änderung ein Änderungsobjekt zu designen, das in der Lage ist sich alle notwendigen Daten auf einmal zu merken, dann hätten wir es bald mit einem Moloch zu tun, den wir selbst nicht mehr verstehen würden.
Anstatt eine hoch komplexe Änderung als Ganzes zu erfassen, sollten wir die Änderung in viele kleine Änderungen aufbrechen. Jede Teiländerung speichert wieder nur das Objekt und dessen Zustand, der sich geändert hat und die Zustände vor und nach der Änderung.
Mit diesen kleinen übersichtlichen Teiländerungen lassen sich dann beliebig komplexe Änderungen zusammen bauen. Bereits das Verschieben der Linie kann man in zwei kleinere Änderungen aufbrechen. Zum einen die Änderung der X-Koordinate und zum anderen die Änderung der Y-Koordinate.

Im nächsten Abschnitt sehen wir, wie wir die Teiländerungen zu einer großen Änderung zusammen bauen.


Hierarchische Teiländerungen

Im vorherigen Abschnitt haben wir uns entschlossen komplexe Änderungen in "atomare" Teiländerungen aufzuteilen.
Die Teiländerungen müssen zueinander in der richtigen Reihenfolge angeordnet werden, damit ein Undo und Redo korrekt ausgeführt werden kann.
Nehmen wir an, eine Änderung besteht aus den Teiländerungen "Erzeugen einer Linie" und "Die Farbe der Linie auf rot ändern". Bei einem Redo muss natürlich erst die Linie erzeugt werden, bevor dessen Farbe geändert werden kann.

Jetzt könnten wir auf die Idee kommen, dass wir alle Teiländerung mit einer normalen Liste zu einer Änderung zusammen fassen könnten. Leider gibt es auch hier Fälle, die sehr aufwendig zu lösen wären, wenn wir die Teiländerungen nur in Listenform zusammenfassen könnten.
Nehmen wir, dass wir ein Formular mit einem Edit-Feld haben. Der Benutzer tippt etwas ein. Jeder Tastendruck löst das OnChange-Ereignis des Edit-Feldes aus. Nehmen wir weiterhin an, dass in der zugehörigen Ereignisbehandlungsroutine zwei Checkboxen markiert werden. Die Markierung der ersten CheckBox löst ein OnClick-Ereignis aus. Daraufhin, wird vielleicht in einem weiteren Edit-Feld der Inhalt gelöscht.
Unsere Änderung besteht jetzt aus vier Teiländerungen:
1. Der neue Buchstabe in dem ersten Edit-Feld
2. Das Markieren der ersten CheckBox
3. Das Löschen des Inhalts des zweiten Edit-Feldes
4. Das Markieren der zweiten CheckBox

Jetzt stellen aber die 2., 3. und 4. Teiländerung jeweils für sich eine vollwertige Änderung dar. In Wirklichkeit haben wir es nicht mit einer Liste, sondern mit einer Hierarchie, sprich Baum, zu tun.

-Änderung von Edit-Feld 1
  -Teiländerung: Text wurde um einen Buchstaben erweitert
  - Änderung der ersten Checkbox
    -Teiländerung: Checkbox wurde markiert
    - Änderung von Edit-Feld 2
      -Teiländerung: Text wurde gelöscht
  - Änderung der zweiten Checkbox
    -Teiländerung: Checkbox wurde markiert

Eine Änderung ist also nicht nur eine Gruppierung von Teiländerungen, sondern kann selbst eine Teiländerung sein. Somit werden wir uns später beim Programmieren nicht darum kümmern müssen, ob eine Änderung selbständig ist oder nur eine Teiländerung darstellt.

Jetzt wissen wir, welche Daten und in welcher Form wir diese Speichern müssen, damit wir ein Undo/Redo durchführen können.

Im nächsten Abschnitt überlegen wir uns, wie und wer das Undo/Redo durchführt.


Undo/Redo durchführen

In den vorherigen Abschnitten haben wir herausgefunden welche Daten wir wie speichern müssen.

Wer soll aber letztendlich das Undo bzw. Redo durchführen?

Da wir objektorientiert programmieren, könnten wir sagen, dass das Objekt, das geändert wurde, diese Änderung auch wieder rückgängig machen soll. Nehmen wir an, dass ein Objekt gelöscht wurde. Wie soll dieses Objekt die Löschung rückgängig machen, wenn es gar nicht mehr existiert? Die Idee müssen wir wohl wieder fallen lassen.
Es gibt aber ein Objekt, das immer existiert, zumindest solange eine Änderung rückgängig gemacht werden kann. Das ist das Objekt, das die Änderungsdaten speichert. Der entsprechenden Klasse verpassen wir eine Undo- und eine Redo-Methode, die dann diese Aufgaben erledigen. Jede Teiländerung ist dann in der Lage sich rückgängig zu machen, bzw. erneut durchzuführen. Jetzt muss das Ganze nur noch in der richtigen Reihenfolge angestoßen werden.

Wir haben bis jetzt Undo/Redo nur theoretisch behandelt. Es sollte aber klar geworden sein, dass dahinter ein allgemeines Verfahren steckt, das immer gleich ist, egal was konkret durch ein Undo/Redo betroffen sein soll. Wir könnten uns jetzt also allgemeine Klassen schreiben, die wir bei jeder Undo/Redo-Anwendung verwenden könnten. Natürlich müssten wir die Klassen so schreiben, dass man durch Ableiten spezielle Klassen bekommt, die dann die für spezielle Undo/Redo-Fälle durchzuführenden Arbeiten erledigen.

Wie in der Einleitung bereits erwähnt, gibt es diese allgemeinen Klassen bereits. Auf diesen Klassen werden wir in den nächsten Abschnitten aufbauen.


Ein einfaches Beispiel

Bis jetzt haben wir uns nur mit der Theorie abgequält. Nun wollen wir endlich einmal etwas programmieren.

Das Formular

Hier werden wir nur ein Formular mit zwei Editfeldern bestücken, über die wir ein Undo/Redo ermöglichen.

Natürlich bekommt das Formular auch einen Undo- und einen Redo-Button. Wir müssen es ja irgendwie auslösen können.


Die Verwaltungsklasse TRakHistory

Zuerst legen wir das Objekt an, das für die Verwaltung des Undo/Redo zustädig ist.

TFrmMain = class(TForm)
  Edit1: TEdit;
  Edit2: TEdit;
  BtnUndo: TButton;
  BtnRedo: TButton;
  procedure FormCreate(Sender: TObject);
private
  FHistory: TRakHistory;
end;

procedure TFrmMain.FormCreate(Sender: TObject);
begin
  FHistory := TRakHistory.Create(self);
end;

Die Klasse TRakHistory findet man in der Unit RakUndoRedo.
TRakHistory verwaltet alle Änderungen. TRakHistory ist die Klasse, welche die Liste von Änderungen kapselt. Sie führt auch den Zeiger, der angibt, welche Änderung bei dem nächsten Undo rückgängig gemacht werden muss.
Die Klasse hat etliche Methoden, Properties und Ereignisse. In diesem einfachen Beispiel werden wir es nur mit drei Methoden von TRakHistory zu tun haben.

Methode

Erklärung

AddCommand
Mit AddCommand kann man eine weitere Änderung hinzufügen.
Undo
Mit Undo kann man eine Änderung rückgängig machen.
Redo
Mit Redo kann man eine Änderung erneut durchführen.


Die Änderungsklasse TRakCommand

Wir wollen die Änderungen in den Editfeldern rückgängig machen können. Wir brauchen also ein Objekt, das sich merkt, welches Editfeld sich geändert hat, wie der Text vor und wie der Text nach der Änderung war. Dafür schreiben wir uns eine Klasse.

THcEditText = class(TRakCommand)
private
  FEdit: TEdit;
  FOldValue: string;
  FNewValue: string;
protected
  function UndoNow: boolean; override;
  function RedoNow: boolean; override;
public
  constructor Init(Edit: TEdit; OldValue: string);
end;

Unsere Klasse THcEditText ist von TRakCommand abgeleitet. TRakCommand findet man in der Unit RakUndoRedo. Alle einzelnen Änderungen, die von TRakHistory verwaltet werden, müssen von TRakCommand direkt oder indirekt abgeleitet sein.

Der Konstruktor

Wir führen noch einen neuen Konstruktor ein, der nicht Create sondern Init heißt. Diese ungewöhnliche Namensgebung rührt daher, dass TRakCommand indirekt von TRakCollectionItem abgeleitet ist, bei der der Konstruktor Create virtuell ist. Damit wir den virtuellen Konstruktor nicht verdecken, geben wir unserem einen anderen Namen.

In dem Konstruktor machen wir nicht mehr als unsere privaten Felder zu versorgen.

constructor THcEditText.Init(Edit: TEdit; OldValue: string);
begin
  Create;

  FEdit := Edit;
  FOldValue := OldValue;
  FNewValue := Edit.Text;
end;

Zuerst müssen wir natürlich den Konstruktor der Vaterklasse aufrufen.
Edit ist eine Referenz auf das Editfeld, das sich geändert hat.
OldValue ist der Text des Editfeldes vor der Änderung.
NewValue ist der Text des Editfeldes nach der Änderung.

Das Rückgängigmachen der Änderung

Die für uns wichtigsten Methoden sind UndoNow und RedoNow. Diese sind abstrakt und müssen von unserer Klasse überschrieben werden. Diese Methoden werden aufgerufen, wenn die Änderung rückgängig gemacht, bzw. erneut durchgeführt werden soll.

function THcEditText.UndoNow: boolean;
begin
  FEdit.Text := FOldValue;
  FEdit.SetFocus;

  Result := true;
end;

Wir verpassen hier dem Editfeld einfach den Text vor der Änderung und geben ihm den Focus.
Der Rückgabewert true zeigt nur an, dass die Änderung erfolgreich rückgängig gemacht werden konnte.

Die Methode RedoNow ist das Gleiche in grün.

function THcEditText.RedoNow: boolean;
begin
  FEdit.Text := FNewValue;
  FEdit.SetFocus;

  Result := true;
end;


Eine Änderung hinzufügen

Wir wollen nicht jeden einzelnen Buchstaben, der in ein Editfeld eingegeben wird, als Änderung abspeichern. Wir werden erst, wenn ein Editfeld den Fokus verliert prüfen, ob sich etwas geändert hat und diese Änderung dann abspeichern.
Dazu müssen wir uns merken, welcher Text in dem Editfeld stand, als es den Fokus erhalten hat.
Wir legen also ein Feld dafür an.

    FEnterText: string;

Natürlich müssen wir dann auf das OnEnter-Ereignis der Editfelder reagieren. Wir werden bei beiden Editfeldern die selbe Ereignisbehandlungsroutine verwenden.

procedure TFrmMain.EditEnter(Sender: TObject);
begin
  FEnterText := TEdit(Sender).Text;
end;

Im OnExit-Ereignis der Editfelder passiert das eigentlich Interessante.

procedure TFrmMain.EditExit(Sender: TObject);
var
  Edt: TEdit;
begin
  Edt := TEdit(Sender);
  if FEnterText <> Edt.Text then
    FHistory.AddCommand(THcEditText.Init(Edt, FEnterText));
end;

Nur wenn sich der Text des Editfeldes überhaupt geändert hat, wird eine Änderung abgespeichert.
THcEditText.Init(Edt, FEnterText) erzeugt eine Instanz unserer Änderungsklasse. Dem Konstruktor wird das Editfeld, das sich geändert hat, und der Text vor der Änderung übergeben.
Mit FHistory.AddCommand wird die Instanz an FHistory übergeben. FHistory ist jetzt der Besitzer des Änderungsobjektes und kümmert sich auch um die Freigabe.
Ab jetzt steht uns eine Änderung zur Verfügung, die rückgängig gemacht werden kann.


Undo/Redo durchführen

Das Durchführen von Undo und Redo ist jetzt total einfach. Dies machen wir in den OnClick-Ereignissen der beiden Buttons.

procedure TFrmMain.BtnUndoClick(Sender: TObject);
begin
  FHistory.Undo;
end;

procedure TFrmMain.BtnRedoClick(Sender: TObject);
begin
  FHistory.Redo;
end;

Wir müssen nur noch die entsprechenden Methoden von FHistory aufrufen. FHistory kümmert sich darum, dass die richtige Änderung angesprochen wird.

Das war schon alles. Jetzt können Sie das Programm starten, in die Editfelder etwas eingeben und mit dem Undo- und Redo-Button herumspielen.

Dieses Beispiel war sehr einfach und sollte nur ein erstes Gefühl für den Umgang mit den Klassen aus der Unit RakUndoRedo vermitteln. Wir haben hier noch nicht einmal eine Änderung in Teiländerungen aufgebrochen. Dies und noch etliches mehr werden wir in dem nächsten Beispiel zu sehen bekommen.


Der komplette Code des Beispiels

unit Mainform;

interface

uses
  RakUndoRedo,
  Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs,
  StdCtrls;

type
  TFrmMain = class(TForm)
    Edit1: TEdit;
    Edit2: TEdit;
    BtnUndo: TButton;
    BtnRedo: TButton;
    procedure EditEnter(Sender: TObject);
    procedure EditExit(Sender: TObject);
    procedure FormCreate(Sender: TObject);
    procedure BtnUndoClick(Sender: TObject);
    procedure BtnRedoClick(Sender: TObject);
  private
    { Private-Deklarationen }
    FHistory: TRakHistory;
    FEnterText: string;
  public
    { Public-Deklarationen }
  end;

var
  FrmMain: TFrmMain;

implementation

{$R *.DFM}

type
  THcEditText = class(TRakCommand)
  private
    FEdit: TEdit;
    FOldValue: string;
    FNewValue: string;
  protected
    function UndoNow: boolean; override;
    function RedoNow: boolean; override;
  public
    constructor Init(Edit: TEdit; OldValue: string);
  end;

{ THcEditText }

constructor THcEditText.Init(Edit: TEdit; OldValue: string);
begin
  Create;

  FEdit := Edit;
  FOldValue := OldValue;
  FNewValue := Edit.Text;
end;

function THcEditText.RedoNow: boolean;
begin
  FEdit.Text := FNewValue;
  FEdit.SetFocus;

  Result := true;
end;

function THcEditText.UndoNow: boolean;
begin
  FEdit.Text := FOldValue;
  FEdit.SetFocus;

  Result := true;
end;

{ TFrmMain }

procedure TFrmMain.FormCreate(Sender: TObject);
begin
  FHistory := TRakHistory.Create(self);
end;

procedure TFrmMain.EditEnter(Sender: TObject);
begin
  FEnterText := TEdit(Sender).Text;
end;

procedure TFrmMain.EditExit(Sender: TObject);
var
  Edt: TEdit;
begin
  Edt := TEdit(Sender);
  if FEnterText <> Edt.Text then
    FHistory.AddCommand(THcEditText.Init(Edt, FEnterText));
end;

procedure TFrmMain.BtnUndoClick(Sender: TObject);
begin
  FHistory.Undo;
end;

procedure TFrmMain.BtnRedoClick(Sender: TObject);
begin
  FHistory.Redo;
end;

end.


Ein komplexes Beispiel

In dem ersten Beispiel haben wir die Möglichkeiten der Klassen aus der Unit RakUndoRedo nur angekratzt. Jetzt werden wir deutlich tiefer in die Thematik eintauchen.
In dem vorherigen Beispiel haben wir auf die OnEnter- und OnExit-Ereignisse der Editfelder reagiert. Wenn wir auch auf einem anderen Formular ein Undo/Redo über mehrere Editfelder unterstützen möchten, dann müssten wir dort wieder das Gleiche programmieren. Früher oder später würden wir feststellen, dass es Fälle gibt, mit denen unser einfacher Code nicht zurecht kommt. Die nötigen Änderungen müssten dann bei allen Formularen nachgezogen werden. Das ist zum einen mit viel Arbeit verbunden und zum anderen sehr fehleranfällig.
Deshalb werden wir uns jetzt eigene Edit-Komponenten programmieren, die ein Undo/Redo von selbst beherschen. Zusammen mit einer speziellen Formularklasse werden wir dann in Zukunft nichts weiter machen müssen, als diese Komponenten auf dem Formular zu platzieren und es wird dann automatisch das Undo/Redo unterstützt.
Wir werden in dem Beispiel nicht nur eine eigene Edit-Komponente, sondern auch eine eigene CheckBox- und StringGrid-Komponente mit Undo/Redo ausstatten. Am Ende des Beispiels sollten Sie dann in der Lage sein auch andere Komponenten mit einer Undo/Redo-Unterstützung zu schreiben.

Unsere neuen Komponenten werden wir alle in einer Unit sammeln. Diese soll UndoRedoControls heißen.


Grundkonzept

Damit das Undo/Redo funktionieren kann, benötigen wir eine Instanz der Klasse TRakHistory aus der Unit RakUndoRedo. Wir könnten davon eine globale Instanz erzeugen, auf die dann alle Controls zugreifen werden. Dann hätten wir aber den Effekt, dass das Undo/Redo immer über Formulargrenzen hinweg wirksam wäre. Vermutlich will man in den meisten Fällen, dass jedes Formular seinen eignen Undo/Redo-Kreislauf hat. Außerdem würden dann auch Dialoge Probleme bereiten. Diese würden ihre Änderungen auch in das globale History einhängen. Wie soll darauf aber ein Undo erfolgen, wenn der Dialog bereits geschlossen ist?
Auf Grund dieser Probleme gehen wir folgenden Weg.

Jedes Formular kann selbst bestimmen welche Instanz von TRakHistory es verwendet.

Das wird in der Regel bedeuten, dass ein Formular eine eigene Instanz von TRakHistory erzeugt und verwendet. Es steht aber auch die Möglichkeit offen, dass mehrere Formulare eine solche Instanz gemeinsam verwenden, damit das Undo/Redo über Formulargrenzen hinweg funktioniert.

Es ist schön und gut, dass ein Formular bestimmt, welche Instanz von TRakHistory zu verwenden ist, aber wie kommen unsere Komponenten an diese Instanz?
Diese müssen die Instanz natürlich von dem Formular, auf dem sie sich befinden, anfordern.
Wir können und wollen nicht verhindern, dass ein Programmierer unsere neuen Komponenten auf einem ganz normalen Formular platziert, das überhaupt nichts von Undo/Redo weiß. Deshalb müssen wir einen Weg finden unser History-Objekt anzufordern, der auch mit diesen Fall zurecht kommt.

Controls bekommen ein History-Objekt durch ein SendMessage an ihr Formular.

Ein Formular reagiert auf die Message indem es die Referenz auf das History-Objekt zurückgibt. Formulare, die diese Message nicht kennen, reagieren nicht darauf und es wird automatisch "0" zurückgegeben. Daran können unsere Komponenten erkennen, dass Undo/Redo nicht unterstützt wird.

Als erstes werden wir jetzt ein Formular erstellen, das Undo/Redo unterstützt.


Ein spezielles Formular

Hier werden wir das Formular entwickeln, von dem alle Formulare abgeleitet werden können, die Undo/Redo automatisch unterstützen.

TUndoRedoForm
Undo/Redo-Actions
Undo/Redo Enablen
Der komplette Code des Formulars


TUndoRedoForm

Hier werden wir ein Formular erstellen, das Undo/Redo unterstützt, so dass unsere speziellen Komponenten nur darauf platziert werden müssen, damit alles funktioniert.
Wir werden unser Formular einfach von TForm ableiten und es in die Objektablage mit aufnehmen. Dann können in Zukunft alle Formulare, die Undo/Redo unterstützen sollen, einfach von diesem abgeleitet werden.

Wir legen also ein neues Formular an und geben ihm den Namen FrmUndoRedo. Sie können natürlich auch irgendeinen anderen Namen vergeben. Wenn Sie sich z.B. nach dem Style-Guide von JEDI richten, dann werden sie wohl eher den Namen UndoRedoForm bevorzugen.
Außerdem ändern wir noch das Property BordeStyle auf bsDialog, da die meisten Formulare Dialoge sind.

Wir brauchen natürlich als erstes das History-Objekt, das von den Controls angefordert wird.

const URM_GET_HISTORY_CONST = WM_USER + 874;

type
  TFrmUndoRedo = class(TForm)
  private
    { Private-Deklarationen }
    FHistory: TRakHistory;
    procedure UrmGetHistory(var Msg: TMessage); message URM_GET_HISTORY_CONST;
  public
    { Public-Deklarationen }
    constructor Create(AOwner: TComponent); override;
  end;

implementation

constructor TFrmUndoRedo.Create(AOwner: TComponent);
begin
  inherited;
  FHistory := TRakHistory.Create(self);
end;

procedure TFrmUndoRedo.UrmGetHistory(var Msg: TMessage);
begin
  Msg.Result := Longint(FHistory);
end;

URM_GET_HISTORY_CONST ist die Message, die von den Controls gesendet wird, wenn sie das History-Objekt benötigen. Alles andere dürfte selbsterklärend sein.


Undo/Redo-Actions

Der Standard-Shortcut für Undo ist Strg+Z und der für Redo Umsch-Strg+Z. Diese müssen wir abfangen. Dazu platzieren wir eine TActionList auf dem Formular und legen die beiden Actions ActUndo und ActRedo an.

Dann implementieren wir die OnExecute-Ereignisse der Actions.

procedure TFrmUndoRedo.ActUndoExecute(Sender: TObject);
var
  CanUndo: boolean;
begin
  CanUndo := true;
  BeforeUndo(CanUndo);
  if CanUndo then
  begin
    FHistory.Undo;
    AfterUndo;
  end;
end;

Eigentlich hätte es gereicht, wenn wir nur die Zeile

 FHistory.Undo;

geschrieben hätten. Wir wollen aber abgeleiteten Formularen die Möglichkeit zum Eingreifen geben.

BeforeUndo wird leer implementiert. In AfterUndo werden wir später noch etwas reinschreiben.

procedure TFrmUndoRedo.BeforeUndo(var CanUndo: boolean);
begin
end;

procedure TFrmUndoRedo.AfterUndo;
begin
end;

Der Code für Redo ist das Gleiche in Grün. Den sparen wir uns hier.


Undo/Redo Enablen

Undo/Redo aktivieren und deaktivieren

Ein Undo sollte natürlich nur dann ausgelöst werden, wenn es tatsächlich etwas zum Rückgängig machen gibt.
Deswegen werden wir eine zentrale Methode anlegen, in der festgelegt wird, ob das Property Enabled bei den Actions an ist.

procedure TFrmUndoRedo.CheckEnabled;
begin
  if not (csDestroying in ComponentState) then
  begin
    if not FHistory.IsUnRedoing and not FHistory.IsAddingCommands then
    begin
      ActUndo.Enabled := FHistory.UndoPtr >= 0;
      ActRedo.Enabled := FHistory.UndoPtr < FHistory.Count - 1;

      if ActUndo.Enabled then
        ActUndo.Hint := FHistory[FHistory.UndoPtr].MenueText
      else
        ActUndo.Hint := '';

      if ActRedo.Enabled then
        ActRedo.Hint := FHistory[FHistory.UndoPtr + 1].MenueText
      else
        ActRedo.Hint := '';
    end;
  end;
end;

Während sich das Formular zerstört machen wir gar nichts, denn wir wollen nicht riskieren, dass wir mit ungültigen Referenzen arbeiten.

TRakHistory hat das Property IsUnRedoing, das anzeigt, ob gerade ein Undo oder Redo durchgeführt wird. Das Property IsAddingCommands zeigt an, ob gerade neue Änderungen hinzugefügt werden.
Während so etwas passiert, machen wir auch nichts, denn sonst kann es vorkommen, dass Undo/Redo-Buttons, die mit den Actions verbunden sind, flackern.

ActUndo.Enabled := FHistory.UndoPtr >= 0;

UndoPtr ist der Zeiger, der angibt, welche Änderung von dem nächsten Undo betroffen wäre. Wenn es nichts zum Rückgängig machen gibt, dann hat er den Wert -1. Ansonsten ist er größer gleich Null.

ActRedo.Enabled := FHistory.UndoPtr < FHistory.Count - 1;

UndoPtr + 1 zeigt auf die Änderung, die von einem Redo betroffen wäre. Das Property Count gibt an, wie viele Änderungen gespeichert sind.

Für den Anwender wäre es vielleicht interessant zu wissen, was genau durch den nächsten Klick auf einen Undo-Button rückgängig gemacht wird. Dafür versorgen wir den Hint mit einem entsprechenden Text.

if ActUndo.Enabled then
  ActUndo.Hint := FHistory[FHistory.UndoPtr].MenueText
else
  ActUndo.Hint := '';

TRakHistory hat das Array-Property CommandGroup, welches das Default-Propety ist. Damit kann man auf die einzelnen Änderungen zugreifen. CommandGroup hat den Typ TRakCommandGroup. Diese Klasse hat u.a. das Property MenueText. Darüber können von TRakCommandGroup abgeleitete Klassen vernünftige Texte für den Anwender zurückgeben. Wie das genau geht, werden wir später noch sehen. Damit die Hints überhaupt angezeigt werden, müssen bei evtl. vorhanden Undo/Redo-Buttons natürlich das Property ShowHint auf true gesetzt werden.

CheckEnabled aufrufen

Unsere neue Methode CheckEnabled muss natürlich auch irgendwo aufgerufen werden. Dafür gibt es mehrere Stellen. Die interessanteste hängt mit dem Ereignis OnCommandGroupAdded von TRakHistory zusammen. Dieses Ereignis wird ausgelöst, wenn eine neue Änderung dazugekommen ist. Das Property IsAddingCommands, das wir schon kennen gelernt haben, wird unmittelbar vor dem Ereignis von true auf false geändert. Somit wird der Code in CheckEnabled auch wirklich ausgeführt, wenn wir auf das Ereignis reagieren.
Das Ereignis versorgen wir im Konstruktor.

constructor TFrmUndoRedo.Create(AOwner: TComponent);
begin
  inherited;
  FHistory := TRakHistory.Create(self);
  FHistory.OnCommandGroupAdded := OnCheckEnabled;
  FHistory.OnCommandGroupDismissed := OnCheckEnabled;
end;

Außerdem reagieren wir noch auf das Ereignis OnCommandGroupDismissed. Dieses Ereignis wird ausgelöst, wenn eine begonnene Änderung verworfen wurde.
Die restlichen Stellen, an denen CheckEnabled aufgerufen wird, können Sie sich im kompletten Code der Unit ansehen.

Undo vorzeitig aktivieren

Jetzt kann es aber auch vorkommen, dass eine Änderung noch gar nicht abgeschlossen ist, aber trotzdem Undo aktiviert werden soll. Wenn z.B. der Anwender in ein Editfeld etwas eingibt, dann wird die Änderung erst abgeschlossen, wenn das Editfeld den Fokus verliert. Aber bereits nach dem ersten Tastendruck hat sich im Editfeld etwas verändert, was rückgängig gemacht werden kann. Controls senden in so einem Fall die Nachricht URM_ACTIVATE_UNDO_CONST an das Formular. Darauf wollen wir reagieren.

    procedure UrmActivateUndo(var Msg: TMessage); message URM_ACTIVATE_UNDO_CONST;

procedure TFrmUndoRedo.UrmActivateUndo(var Msg: TMessage);
begin
  ActUndo.Enabled := true;
end;


Der komplette Code des Formulars

unit UndoRedoForm;

interface

uses
  UndoRedoControls, RakUndoRedo,
  Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs,
  ActnList;

const URM_GET_HISTORY_CONST = WM_USER + 874;
const URM_ACTIVATE_UNDO_CONST = WM_USER + 875;

type
  TFrmUndoRedo = class(TForm)
    AclMain: TActionList;
    ActUndo: TAction;
    ActRedo: TAction;
    procedure ActUndoExecute(Sender: TObject);
    procedure ActRedoExecute(Sender: TObject);
  private
    { Private-Deklarationen }
    FHistory: TRakHistory;

    procedure UrmGetHistory(var Msg: TMessage); message URM_GET_HISTORY_CONST;
    procedure UrmActivateUndo(var Msg: TMessage); message URM_ACTIVATE_UNDO_CONST;
  protected
    procedure CheckEnabled; virtual;

    procedure BeforeUndo(var CanUndo: boolean); virtual;
    procedure AfterUndo; virtual;
    procedure BeforeRedo(var CanRedo: boolean); virtual;
    procedure AfterRedo; virtual;

    property History: TRakHistory read FHistory;
  public
    { Public-Deklarationen }
    constructor Create(AOwner: TComponent); override;
  published
    procedure OnCheckEnabled(Sender: TObject);
  end;

var
  FrmUndoRedo: TFrmUndoRedo;

implementation

{$R *.DFM}

{ TFrmUndoRedo }

constructor TFrmUndoRedo.Create(AOwner: TComponent);
begin
  inherited;
  FHistory := TRakHistory.Create(self);
  FHistory.OnCommandGroupAdded := OnCheckEnabled;
  FHistory.OnCommandGroupDismissed := OnCheckEnabled;
end;

procedure TFrmUndoRedo.UrmGetHistory(var Msg: TMessage);
begin
  Msg.Result := Longint(FHistory);
end;

procedure TFrmUndoRedo.UrmActivateUndo(var Msg: TMessage);
begin
  ActUndo.Enabled := true;
end;

procedure TFrmUndoRedo.ActUndoExecute(Sender: TObject);
var
  CanUndo: boolean;
begin
  CanUndo := true;
  BeforeUndo(CanUndo);
  if CanUndo then
  begin
    FHistory.Undo;
    AfterUndo;
  end;
end;

procedure TFrmUndoRedo.ActRedoExecute(Sender: TObject);
var
  CanRedo: boolean;
begin
  CanRedo := true;
  BeforeRedo(CanRedo);
  if CanRedo then
  begin
    FHistory.Redo;
    AfterRedo;
  end;
end;

procedure TFrmUndoRedo.AfterRedo;
begin
  CheckEnabled;
end;

procedure TFrmUndoRedo.AfterUndo;
begin
  CheckEnabled;
end;

procedure TFrmUndoRedo.BeforeRedo(var CanRedo: boolean);
begin
end;

procedure TFrmUndoRedo.BeforeUndo(var CanUndo: boolean);
begin
end;

procedure TFrmUndoRedo.CheckEnabled;
begin
  if not (csDestroying in ComponentState) then
  begin
    if not FHistory.IsUnRedoing and not FHistory.IsAddingCommands then
    begin
      ActUndo.Enabled := FHistory.UndoPtr >= 0;
      ActRedo.Enabled := FHistory.UndoPtr < FHistory.Count - 1;

      if ActUndo.Enabled then
        ActUndo.Hint := FHistory[FHistory.UndoPtr].MenueText
      else
        ActUndo.Hint := '';

      if ActRedo.Enabled then
        ActRedo.Hint := FHistory[FHistory.UndoPtr + 1].MenueText
      else
        ActRedo.Hint := '';
    end;
  end;
end;

procedure TFrmUndoRedo.OnCheckEnabled(Sender: TObject);
begin
  CheckEnabled;
end;

end.


History-Objekt anfordern

In den vorherigen Abschnitten haben wir das Formular entwickelt, das benötigt wird, damit Undo/Redo funktioniert.
Dieses Formular stellt das History-Objekt zur Verfügung. Da dies von allen unseren noch zu schreibenden Komponenten benötigt wird, schreiben wir zuerst eine Funktion, die das History-Objekt besorgt.

unit UndoRedoControls;

interface

uses
  RakUndoRedo,
  Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs,
  StdCtrls, Grids;

var
  URM_GET_HISTORY: integer = WM_USER + 874;

implementation

function GetHistory(Ctrl: TControl): TRakHistory;
var
  Frm: TCustomForm;
  Obj: TObject;
begin
  Result := nil;

  // Zuerst das Formular besorgen, auf dem sich das Control befindet
  Frm := GetParentForm(Ctrl);
  if Frm <> nil then
  begin
    // Dann von dem Formular die Referenz auf das History-Objekt anfordern.
    // Wenn das Formular URM_GET_HISTORY nicht kennt, dann wird 0 zurückgegeben, was einem
    // nil entspricht
    Obj := TObject(Frm.Perform(URM_GET_HISTORY, 0, 0));
    if Obj is TRakHistory then
    begin // Wenn wir tatsächlich eine Referenz auf das History-Objekt bekommen haben
      Result := TRakHistory(Obj);
    end;
  end;
end;

end.

Der Kommentar in dem Code sollte das Meiste verständlich machen.

URM_GET_HISTORY ist die Message, die an das Formular geschickt wird, damit dieses die Referenz auf das History-Objekt zurückschickt. URM_GET_HISTORY ist eine Variable. Wenn eine Applikation bereits WM_USER + 874 für irgendetwas anderes verwenden muss, dann kann diese hier die Message umbiegen, um Konflikte zu vermeiden.

Wenn das Control auf einem Formular sitzt, das Undo/Redo nicht unterstützt, dann wird einfach nil zurückgegeben. Darauf müssen die Komponenten natürlich Rücksicht nehmen.


Checkbox-Komponente

Hier werden wir die Checkbox-Komponente entwickeln, die Undo/Redo unterstützt.

TUndoRedoCheckBox
TCgButtonControlChecked
THcButtonControlChecked


TUndoRedoCheckBox

Jetzt werden wir unsere erste Komponente mit Undo/Redo-Unterstützung schreiben. Da bei einer CheckBox vom Anwender nur Checked hin und her getoggelt werden kann, fangen wir damit an.

Wie auch alle anderen Komponenten werden wir diese in der Unit UndoRedoControls unterbringen.

  TUndoRedoCheckBox = class(TCheckBox)
  protected
    procedure Toggle; override;
  end;

Das einzige was wir machen müssen ist die Methode Toggle zu überschreiben.

procedure TUndoRedoCheckBox.Toggle;
var
  History: TRakHistory;
begin
  // Zuerst besorgen wir uns das Objekt, das alle Änderungen speichert
  History := GetHistory(self);
  if History <> nil then
  begin
    // Wir starten eine Gruppe, die alle Änderungen gruppiert
    History.NewGroupStart(TCgButtonControlChecked.Create);
    // und fügen die Änderung der Checkbox hinzu
    History.AddCommand(THcButtonControlChecked.Init(self, not Checked));
  end;
  try
    // Jetzt wird Checked tatsächlich geändert
    inherited;
  finally
    if History <> nil then
    begin
      if History.IsAddingCommands then
      begin // Wenn das Ändern nicht von außen abgebrochen wurde
        if not (History.ActiveCommandGroup is TCgButtonControlChecked) then
          raise ERakUndoRedoSynchronisationLost.Create('TUndoRedoCheckBox: Synchronisation lost');

        // Die Änderung abschließen
        History.ActiveCommandGroup.Abort;
      end;
    end;
  end;
end;

Der Kommentar im Code gibt schon einmal einen ersten Überblick. Auf einige Zeilen müssen wir aber noch etwas genauer eingehen, damit wir sie verstehen.

    History.NewGroupStart(TCgButtonControlChecked.Init(self));

Die Methode NewGroupStart haben wir bis jetzt noch nicht kennen gelernt. In dem ersten sehr einfachen Beispiel haben wir unsere Änderungen mit der Methode AddCommand hinzugefügt. Mit AddCommand kann man genau eine Änderung hinzufügen. Wenn man aber mehrere Änderungen gruppieren will, dann muss man dafür die Methoden NewGroupStart und NewGroupStop verwenden. Alle AddCommand-Aufrufe, die zwischen NewGroupStart und NewGroupStop gemacht werden, werden in einer Gruppe zusammen gefasst. Man kann NewGroupStart und NewGroupStop auch schachteln. Damit dürfte klar sein, dass jedes NewGroupStart mit einem NewGroupStop abgeschlossen werden muss. Erst wenn das letzte NewGroupStop aufgerufen wird, löst das History-Objekt das Ereignis OnCommandGroupAdded aus.
In dem obigen Code werden Sie aber vergeblich nach einem NewGroupStop suchen. Das liegt daran, dass NewGroupStop implizit von der Methode Abort

        History.ActiveCommandGroup.Abort;

aufgerufen wird.

Jetzt aber erst einmal der Reihe nach.
NewGroupStart erwartet eine Instanz der Klasse TRakCommandGroup. Diese Instanz ist für die Verwaltung der gruppierten Änderungen zuständig. Die Klasse TRakCommandGroup haben wir schon bei der Methode CheckEnabled unseres Formulars kennen gelernt.
Wir erzeugen hier aber keine Instanz von TRakCommandGroup, sondern von TCgButtonControlChecked. Damit dürfte klar sein, dass TCgButtonControlChecked von TRakCommandGroup abgeleitet sein muss. Die Klasse TCgButtonControlChecked müssen wir selbst schreiben, was wir später noch machen werden.

    History.AddCommand(THcButtonControlChecked.Init(self, not Checked));

Hier wird das Objekt hinzugefügt, das sich merkt was geändert wurde und das Undo und Redo durchführen wird. Die Klasse THcButtonControlChecked werden wir später noch schreiben.

      if History.IsAddingCommands then

Das ist eigentlich nur eine Sicherheitsüberprüfung, die meistens erfüllt sein dürfte. Bei dem vorherigen Aufruf von inherited wird u.a. das OnClick-Ereignis der CheckBox ausgelöst. Hier könnte jemand Code programmieren, der die Methode AbortAdding von dem History-Objekt aufruft. Damit wäre die komplette Änderung abgeschlossen und es sollte nichts weiter gemacht werden.

        if not (History.ActiveCommandGroup is TCgButtonControlChecked) then

Das ist eine weitere Sicherheitsüberprüfung, die nur feststellen soll, ob durch einen Bug die NewGroupStart/NewGroupStop-Aufrufe nicht sauber paarweise erfolgt sind.

        History.ActiveCommandGroup.Abort;

Damit wird die Gruppierung abgeschlossen.
Das Property ActiveCommandGroup hat den Typ TRakCommandGroup und gibt das Gruppierungsobjekt zurück, das jetzt die Änderung aufnehmen würde, die mit einem AddCommand-Aufruf hinzugefügt würde.

Warum arbeiten wir hier überhaupt mit einer Gruppe?

Das hat einen ziemlich einfachen Grund. Wenn der Anwender eine Checkbox mit einen Klick geändert hat, dann will er nur einmal das Undo auslösen müssen, um diese Änderung rückgängig zu machen.
Jetzt werden Sie sich wahrscheinlich fragen, wieso es nötig sein sollte, mehrmals Undo durchzuführen, nur um eine Checkbox-Änderung rückgängig zu machen.
Das liegt an den bereits erwähnten OnClick-Ereignis der CheckBox. Dort könnte jemand Code schreiben, der ein Editfeld löscht. Das Löschen des Editfeldes ist natürlich auch eine Änderung, die im History-Objekt abgespeichert wird. Der Anwender will aber, dass beide Änderungen mit einem Undo rückgängig gemacht werden. Deshalb starten wir eine Gruppe, bevor das OnClick-Ereignis auftritt und schließen nach dem OnClick-Ereignis die Gruppe ab.


TCgButtonControlChecked

Bei der CheckBox verwenden wir die Klasse TCgButtonControlChecked, um Änderung zu gruppieren. Diese Klasse werden wir jetzt implementieren.

Diese Klasse soll die Änderung der CheckBox und auch evtl. Änderungen während des OnClick-Ereignisses gruppieren. Klassen, die Änderungen gruppieren sollen, müssen direkt oder indirekt von TRakCommandGroup abgeleitet sein.

  TCgButtonControlChecked = class(TRakCommandGroup)

TRakCommandGroup findet man in der Unit RakUndoRedo. TRakCommandGroup ist von der Klasse TRakCustomCommand abgeleitet. TRakCustomCommand stellt alle Methoden zur Verfügung, die ein Änderungsobjekt braucht, das gruppiert werden kann. TRakCommandGroup kann also nicht nur gruppieren, sondern selbst in einer Gruppe aufgenommen werden. Deshalb kann man auch die Methoden NewGroupStart und NewGroupStop von TRakHistory schachteln.
TRakCommandGroup hat keine abstrakten Methoden und ist eine vollwertige Klasse. Man könnte also auch mit Instanzen von dieser Klasse arbeiten, was man aber wahrscheinlich nie machen wird. TRakCommandGroup stellt einige virtuelle Methoden zur Verfügung, von der wir nur eine überschreiben werden.

  TCgButtonControlChecked = class(TRakCommandGroup)
  protected
    function GetMenueText: string; override;
  end;

function TCgButtonControlChecked.GetMenueText: string;
begin
  Result := 'CheckBox ändern';
end;

Wir überschreiben nur die read-Methode des Properties MenueText und geben einen Text zurück, der z.B. als Hint bei einem Undo-Button verwendet werden könnte.

Wir hätten uns nicht die Mühe machen müssen eine eigene Klasse zu schreiben. Da wir nur den Menütext ändern, der vielleicht nirgends angezeigt wird, hätten wir auch mit der Klasse TRakCGCommon aus der Unit RakUndoRedo arbeiten können.

Dann hätte in der Toggle-Methode der CheckBox die Zeile

    History.NewGroupStart(TCgButtonControlChecked.Create);

einfach nur in

    History.NewGroupStart(TRakCGCommon.Init('CheckBox ändern'));

geändert werden müssen.

Der einzige Vorteil von der Verwendung von TCgButtonControlChecked ist, dass so etwas weniger Speicher benötigt wird.


THcButtonControlChecked

Bei der CheckBox verwenden wir die Klasse THcButtonControlChecked, um die Änderung in dem History-Objekt abzulegen. Diese Klasse werden wir jetzt implementieren.

"Atomare" Änderungen, also Änderungen, die nicht andere Änderungen gruppieren sollen, sondern tatsächlich eine Änderung rückgängig machen können, müssen direkt oder indirekt von TRakCommand abgeleitet werden. Von TRakCommand abgeleitet Klassen müssen alle Daten speichern können, die benötigt werden, um ein Undo bzw. Redo durchführen zu können. Diese Klassen haben weiterhin die Aufgabe das Undo/Redo durchzuführen. Dafür müssen sie die abstrakten Methoden UndoNow und RedoNow überschreiben.

Wir werden die Klasse THcButtonControlChecked aber nicht direkt von TRakCommand ableiten.

  THcButtonControlChecked = class(TRakHcBoolean)
  private
    FObj: TButtonControl;
  protected
    function GetChangedObject: TObject; override;
    procedure SetValue(Obj: TObject; Value: boolean; var Done: boolean); override;
  public
    constructor Init(Obj: TButtonControl; NewValue: boolean);
  end;

TRakHcBoolean finden wir in der Unit RakUndoRedo.
TRakHcBoolean implementiert bereits die Methoden UndoNow und RedoNow. Dort führt TRakHcBoolean einige Überprüfungen durch und ruft dann die abstrakte Methode SetValue auf. Diese Methode überschreiben wir. Hier führen wir dann die Änderung durch.

Aber kommen wir zuerst zum Konstruktor.

constructor THcButtonControlChecked.Init(Obj: TButtonControl;
  NewValue: boolean);
begin
  inherited Init(NewValue);

  FObj := Obj;
end;

TRakHcBoolean speichert für uns den geänderten Wert. Deshalb übergeben wir diesen dem Konstruktor von TRakHcBoolean.
Die Referenz auf die CheckBox, die geändert wurde, müssen wir selbst speichern. Falls Sie sich über die Klasse TButtonControl wundern sollten - TCheckBox ist indirekt von TButtonControl abgeleitet. Man kann diese Änderungsklasse also nicht nur für eine CheckBox, sondern auch für andere Controls verwenden, die von TButtonControl abgeleitet sind.

function THcButtonControlChecked.GetChangedObject: TObject;
begin
  Result := FObj;
end;

GetChangedObject überschreiben wir nur, da die Standardimplementation ein nil zurückliefern würde und dann nie SetValue aufgerufen würde.

procedure THcButtonControlChecked.SetValue(Obj: TObject; Value: boolean;
  var Done: boolean);
begin
  TProtectedButtonControl(Obj).Checked := Value;
  ShowAndFocus(TWinControl(Obj));
end;

SetValue ist die eigentlich interessante Methode. Sie wird sowohl aufgerufen, wenn ein Undo, als auch wenn ein Redo durchgeführt werden soll. Mit den Parameter Value bekommen wir den Wert, den wir zu setzen haben. Dass wir hier den richtigen Wert bekommen, darum kümmert sich die Klasse TRakHcBoolean. Der Parameter Obj enthält die Referenz, die wir mit GetChangedObject zurück gegeben haben. Der Parameter Done hat den Wert true. Diesen sollten wir auf false ändern, falls wir aus irgendeinem Grund die Änderung nicht durchführen konnten. Da das bei uns immer klappt, fassen wir ihn nicht an.

  TProtectedButtonControl(Obj).Checked := Value;

Die Klasse TButtonControl hat das Property Checked. Leider ist es protected. Deshalb müssen wir einen kleinen Trick anwenden, damit wir auf das Property zugreifen können.

  TProtectedButtonControl = class(TButtonControl);

Wir machen uns eine Dummy-Klasse. Wenn wir Obj in diese Klasse umwandeln, dann bekommen wir Zugriff auf das Property.

Zum Schluß geben wir der Checkbox noch den Fokus, damit der Anwender sieht, welches Control durch das Undo/Redo betroffen war.

  ShowAndFocus(TWinControl(Obj));

Die Methode ShowAndFocus müssen wir erst noch schreiben. Das machen wir im nächsten Abschnitt.


Controls sichtbar machen

Damit der Anwender sieht, welches Control durch ein Undo/Redo betroffen war, soll es dann immer den Fokus erhalten. Damit es aber den Fokus erhalten kann, muss es sichtbar sein. Wenn auf dem Formular ein TPageControl verwendet wird, dann kann es sein, dass das Control, das durch das nächste Undo betroffen wäre auf einer Seite liegt, die gerade nicht angezeigt wird. Wir müssen also evtl. bei dem PageControl die sichtbare Seite ändern. Wir unterstützen auch noch TNotebook. Wenn Sie noch andere "Seiten"-Controls verwenden, dann müssen Sie evtl. den Code hier erweitern.
Worum wir uns nicht kümmern, ist das Property Visible. Wir setzen einfach voraus, dass dieses true ist.

procedure ShowAndFocus(Ctrl: TWinControl);
begin
  if ShowCtrl(Ctrl) then
  try
    Ctrl.SetFocus;
  except;
  end;
end;

Wir rufen erst die Funktion ShowCtrl auf, die dafür sorgen muss, dass unser Control angezeigt wird. Danach geben wir dem Control den Fokus. Evtl. Exceptions, weil z.B. das Property Visible auf false steht, ignorieren wir einfach.

Auf die Methode ShowCtrl werden wir nicht weiter eingehen, da es in dem Tutorial eigentlich um Undo/Redo geht.
Vielleicht kennen Sie Funktionen, die diese Aufgabe besser erledigen als der folgende Code. Dann ist es Ihnen überlassen diesen zu verwenden.

function ShowCtrl(Ctrl: TWinControl): boolean;
var
  Tbs: TTabSheet;
  Pgc: TProtectedPageControl;
begin
  Result := true;
  if Ctrl <> nil then
  begin
    // Zuerst sorgen wir dafür, dass der Parent angezeigt wird
    Result := ShowCtrl(Ctrl.Parent);
    if Result then
    begin
      if Ctrl is TTabSheet then
      begin
        // Damit ein Tabsheet sauber erscheint muss einiges getan werden
        Tbs := TTabSheet(Ctrl);
        Pgc := TProtectedPageControl(Tbs.PageControl);
        if Pgc.ActivePage <> Tbs then
        begin // Wenn das TabSheet noch nicht das sichtbare ist
          // Dann lösen wir zuerst das OnChanging-Ereignis des PageControls aus
          Result := Pgc.CanChange;
          if Result then
          begin // Wenn die Seite gewechselt werden darf
            // Dann machen wir das
            Tbs.PageControl.ActivePage := Tbs;
            // und lösen zum Schluss noch das OnChange-Ereignis aus
            Pgc.Change;
          end;
        end;
      end
      else if Ctrl.Parent is TNotebook then
        TProtectedNotebook(Ctrl.Parent).ShowControl(Ctrl);
    end;
  end;
end;

Damit haben wir alles implementiert, damit das Undo/Redo mit unserer CheckBox funktioniert.

In den nächsten Abschnitten kümmern wir uns um eine Edit-Komponente.


Edit-Komponente

Hier werden wir die Edit-Komponente entwickeln, die Undo/Redo unterstützt.

TUndoRedoEdit
TUndoRedoEdit.DoEnter
TUndoRedoEdit.Change
TUndoRedoEdit.DoExit
TCgCustomEditText


TUndoRedoEdit

Nachdem wir mit unserer CheckBox-Komponente bereits einiges aus der Unit RakUndoRedo kennen gelernt haben, sollte eine eigene Edit-Komponente kein großes Problem mehr darstellen.
Unserer Edit-Komponente verpassen wir den Namen TUndoRedoEdit und bringen den Code wieder in der Unit UndoRedoControls unter.

  TUndoRedoEdit = class(TEdit)
  private
    FGroupIsOpen: boolean;
    FOldText: string;
  protected
    procedure DoEnter; override;
    procedure Change; override;
    procedure DoExit; override;
  end;


TUndoRedoEdit.DoEnter

Bereits bei unseren ersten einfachen Beispiel haben wir auf das OnEnter und OnExit-Ereignis reagiert. Diese Ereignisse werden von den Methoden DoEnter und DoExit ausgelöst. Deshalb überschreiben wir diese.

procedure TUndoRedoEdit.DoEnter;
begin
  // Wir merken uns den Text, damit wir später feststellen können,
  // ob sich tatsächlich etwas geändert hat
  FOldText := Text;

  inherited;
end;

Wir wollen alle Änderungen, die zwischen dem Betreten und Verlassen des Editfeldes auftreten gruppieren. Dann liegt eigentlich die Vermutung nahe, dass wir in OnEnter die Gruppierung öffnen sollten. Dort merken wir uns aber nur den Text. Das hat den Grund, dass evtl. die Gruppierung abgeschlossen werden muss, bevor das Editfeld den Fokus verliert. Da der Benutzer danach Text eingeben kann, muss dann eine neue Gruppierung geöffnet werden. Das Editfeld hatte aber schon den Fokus. Das heißt, dass OnEnter nicht durchlaufen wird und dass dann dort das Öffnen einer neuen Gruppierung keinen Sinn hätte.
Nehmen wir an, das Editfeld hat den Fokus. Der Anwender hat etwas eingetippt und löst dann ein Undo mit dem Shortcut Strg+Z aus. Als erstes werden dann alle offenen Gruppierungen geschlossen. Damit ist der vom Anwender eingetippte Text die letzte Änderung. Diese wird dann rückgängig gemacht. Während dieser ganzen Aktion hat das Editfeld nie den Fokus verloren. Es ist also auch keine Gruppierung offen. Wenn der Benutzer dann wieder etwas eingibt, dann muss natürlich erst eine Gruppierung geöffnet werden. OnEnter wird nicht durchlaufen und ist somit der falsche Platz dafür. Das werden wir später in der Change-Methode machen.

Es gibt noch einen anderen Grund, warum das Öffnen einer Gruppierung in OnEnter versagen würde.
Während ein Undo oder ein Redo durchgeführt werden, nimmt das History-Objekt keine Änderungen entgegen. Die Aufrufe von NewGroupStart, NewGroupStop und AddCommand werden ignoriert. Die übergebenen Gruppierungs- und Änderungsobjekte werden einfach sofort wieder gelöscht. Somit braucht man nicht darauf zu achten, dass das Rückgängig machen einer Änderung selbst Änderungsobjekte erzeugt, welche das Undo/Redo durcheinander bringen würden. Man muss sich dann aber im Klaren darüber sein, dass es zu Problemen führen kann, wenn das Öffnen einer Gruppierung nicht während des selben Ereignisses gemacht wird, wie das Schließen der Gruppierung.
Nehmen wir an, der Anwender hat in das Editfeld etwas eingegeben und klickt dann auf den Undo-Button. Beim Verlassen des Editfeldes wurde die Gruppierung abgeschlossen. Danach wird die Änderung rückgängig gemacht. Beim Rückgängig machen, wird in das Editfeld wieder der alte Text eingesetzt und dem Editfeld wird der Fokus gegeben. Es wird dann also die DoEnter-Methode durchlaufen. Wenn dann hier eine Gruppierung mit NewGroupStart geöffnet wird, dann wird dies vom History-Objekt ignoriert, da das Undo noch nicht abgeschlossen ist. Alle folgenden Eingaben vom Benutzer wären dann nicht gruppiert. Beim nächsten Verlassen des Editfeldes würden wir dann auch noch versuchen eine Gruppierung zu schließen, die gar nicht offen ist.


TUndoRedoEdit.Change

Die meiste Arbeit fällt in der Methode Change an. Change wird aufgerufen, wenn sich der Text des Editfeldes ändert. Dies ist der Fall, wenn der Benutzer etwas eintippt oder wenn Code dem Property Text des Editfeldes einen Wert zuweist.

procedure TUndoRedoEdit.Change;
var
  History: TRakHistory;
  CloseGroup: boolean;
begin
  CloseGroup := false;

  // Zuerst besorgen wir uns das Objekt, das alle Änderungen speichert
  History := GetHistory(self);
  if History <> nil then
  begin
    if not History.IsUnRedoing then
    begin
      if Focused then
      begin // Wenn durch den Anwender der Text geändert wird
        // Dann gruppieren wir alle Änderungen bis das Editfeld den Fokus verliert
        if not FGroupIsOpen then
        begin // Wenn noch keine Gruppierung gestartet wurde
          History.NewGroupStart(TCgCustomEditText.Init(self));
          FGroupIsOpen := true;
        end;
      end
      else
      begin // Wenn durch Code der Text geändert wird
        // Dann Gruppieren wir nur die Änderungen, die jetzt anfallen
        if History <> nil then
        begin
          History.NewGroupStart(TCgCustomEditText.Init(self));
          // Merken, dass wir die Gruppe schließen müssen
          CloseGroup := true;
        end;
      end;

      if History.UndoPtr < 0 then
      begin // Wenn das Formular noch glaubt, dass sich noch nichts geändert hätte
         // Dann teilen wir dem Formular mit, dass sich doch schon etwas geändert hat
        ActivateUndo(self);
      end;
    end;
  end;

  inherited;

  if CloseGroup then
  begin
    if History.IsAddingCommands then
    begin // Wenn das Ändern nicht von außen abgebrochen wurde
      if not (History.ActiveCommandGroup is TCgCustomEditText)
        or (TCgCustomEditText(History.ActiveCommandGroup).FEdit <> self) then
      begin
        raise ERakUndoRedoSynchronisationLost.Create('TUndoRedoEdit: Synchronisation lost');
      end;
      // Die Änderung abschließen
      History.ActiveCommandGroup.Abort;
    end;
  end;
end;

Wow, die Methode ist etwas umfangreicher. Man kann sie aber grob in drei Abschnitte aufteilen.

· Der Code vor dem inherited.
· Der inherited-Aufruf
· Der Code nach dem inherited.

Der Code vor dem inherited.

Der Code vor dem inherited soll sicherstellen, dass ein Gruppierungsobjekt für die Änderungen geöffnet ist. Es wird prinzipiell unterschieden, ob das Editfeld den Fokus hat oder nicht.

Wenn das Editfeld den Fokus hat, dann soll das Gruppierungsobjekt so lange offen bleiben, bis das Editfeld den Fokus verliert. Da die Methode Change bei jedem Tastendruck durchlaufen wird, müssen wir sicher stellen, dass das Gruppierungsoobjekt nur einmal geöffnet wird. Dafür wird das Flag FGroupIsOpen verwendet.

Wenn das Editfeld den Fokus nicht hat, dann wird ein Gruppierungsobjekt geöffnet, das nur diese Änderung zusammen mit allen Änderungen während des OnChange-Ereignisses gruppiert. Es wird also am Ende der Methode Change wieder geschlossen.

Zum Schluss wird dem Formular noch mitgeteilt, dass ab jetzt ein Undo durchgeführt werden kann. Solange UndoPtr noch auf -1 steht, glaubt das Formular, dass es nichts gibt, das rückgängig gemacht werden könnte. Wir haben zwar ein Gruppierungsobjekt geöffnet, aber erst, wenn dieses geschlossen wird, hat das Auswirkungen auf UndoPtr. Deshalb müssen wir dem Formular extra mitteilen, dass sich etwas geändert hat, auch wenn diese Änderung noch nicht vollständig abgeschlossen ist.
Jetzt fragen sie sich vielleicht, warum nicht immer beim Öffnen einer Gruppierung die Action für das Undo scharf geschalten wird. Das liegt daran, dass beim Öffnen eines Gruppierung noch nicht sicher feststeht, dass sich wirklich etwas ändern wird. Wenn die Gruppierung geschlossen wird und sie kein Änderungsobjekt enthält, dann wird das Gruppierungsobjekt einfach gelöscht. Es gibt also keine Änderung und dann wäre es blödsinnig, wenn die entspr. Action aktiviert wäre.

    if not History.IsUnRedoing then

Die Gruppierung wird allerdings nur dann geöffnet, wenn nicht gerade ein Undo oder Redo durchgeführt wird. In diesem Fall würde das Öffnen eines Gruppierungsobjektes von dem History-Objekt sowieso ignoriert. Anhand des Flags FGroupIsOpen werden wir später in der DoExit-Methode feststellen, ob es überhaupt eine Gruppierung gibt, die geschlossen werden muss.

Der inherited-Aufruf

Der inherited-Aufruf löst das OnChange-Ereignis. Falls es dafür Code gibt, der Änderungen an anderen Controls durchführt, dann sind diese Änderungen durch unser Gruppierungsobjekt gruppiert. Man muss sich darum also keine weiteren Gedanken machen.

Der Code nach dem inherited.

Der Code nach dem inherited sorgt einfach nur dafür, dass ein Gruppierungsobjekt geschlossen wird, wenn das Editfeld nicht den Fokus hat. Beim Schließen des Gruppierungsobjektes kümmert sich das Gruppierungsobjekt darum, ein Änderungsobjekt anzulegen. Das werden wir später noch sehen, wenn wir die Klasse TCgCustomEditText implementieren.


TUndoRedoEdit.DoExit

Wenn das Editfeld den Fokus verliert, dann müssen wir noch unser Gruppierungsobjekt schließen.

procedure TUndoRedoEdit.DoExit;
var
  History: TRakHistory;
begin
  inherited;

  if FGroupIsOpen then
  begin // Wenn eine Gruppierung für Änderungen in der Methode Change geöffnet wurde
    // Dann müssen wir diese jetzt schließen
    // Dazu besorgen wir uns das Objekt, das alle Änderungen speichert
    History := GetHistory(self);
    if History <> nil then
    begin
      if History.IsAddingCommands then
      begin // Wenn das Ändern nicht von außen abgebrochen wurde
        if not (History.ActiveCommandGroup is TCgCustomEditText)
          or (TCgCustomEditText(History.ActiveCommandGroup).FEdit <> self) then
        begin
          raise ERakUndoRedoSynchronisationLost.Create('TUndoRedoEdit: Synchronisation lost');
        end;
        // Die Änderung abschließen
        History.ActiveCommandGroup.Abort;
      end;
    end;
  end;
end;

Der Code bietet nichts was wir nicht schon gesehen hätten. Das Flag FGroupIsOpen wird vom Gruppierungsobjekt zurückgesetzt. Dessen Klasse werden wir als nächstes implementieren.


TCgCustomEditText

Bei dem Editfeld verwenden wir die Klasse TCgCustomEditText, um Änderung zu gruppieren. Im Gegensatz zu der Gruppierungsklasse der CheckBox, werden wir hier mehr als nur den Menütext ändern.

  TCgCustomEditText = class(TRakCommandGroup)
  private
    FEdit: TUndoRedoEdit;
  protected
    procedure AbortNow; override;
    function GetMenueText: string; override;
  public
    constructor Init(Obj: TUndoRedoEdit);
  end;

Zuerst sehen wir uns wieder den Konstruktor an.

constructor TCgCustomEditText.Init(Obj: TUndoRedoEdit);
begin
  Create;
  FEdit := Obj;
end;

Wir merken uns hier nur das Editfeld, dass von der Änderung betroffen ist.

GetMenueText hat nichts Neues zu bieten.

function TCgCustomEditText.GetMenueText: string;
begin
  Result := 'Text ändern';
end;

Die interessanteste Methode ist AbortNow.

procedure TCgCustomEditText.AbortNow;
begin
  if FEdit.FOldText <> FEdit.Text then
  begin // Wenn sich der Text wirklich geändert hat
    // Dann speichern wir das in dem History-Objekt ab
    History.AddCommand(THcCustomEditText.Init(FEdit, FEdit.FOldText, FEdit.Text));
    // und merken uns den neuen Text
    FEdit.FOldText := FEdit.Text;
    // und setzen das Flag zurück, dass noch eine Gruppierung zu schließen wäre.
    FEdit.FGroupIsOpen := false;
  end;
end;

AbortNow wird von der Klasse TRakCommandGroup eingeführt. AbortNow wird aufgerufen, wenn die Änderung abgebrochen werden soll. Dies kann z.B. durch einen Aufruf der Methode AbortAdding der Klasse TRakHistory erzwungen werden. In der Methdoe DoExit unserer Edit-Klasse haben wir das selbst durch den Aufruf von

History.ActiveCommandGroup.Abort;

gemacht.
AbortNow ist also genau die richtige Methode, die man überschreiben sollte, wenn man eine Änderung sauber abschließen muss.

  if FEdit.FOldText <> FEdit.Text then

Wir prüfen hier, ob sich der Text des Editfeldes wirklich geändert hat. Wenn sich nichts geändert hat, dann fügen wir auch keine Änderungs-Objekt hinzu. Sollte eine Gruppierung leer sein, wenn sie mit NewGroupStop abgeschlossen wird, dann wird sie von dem History-Objekt einfach wieder gelöscht und es wird auch nicht das Ereignis OnCommandGroupAdded ausgelöst. Man kann also jederzeit eine Gruppierung beginnen, ohne vorher wissen zu müssen, ob tatsächlich eine Änderung anfallen wird.

    History.AddCommand(THcCustomEditText.Init(FEdit, FEdit.FOldText, FEdit.Text));

Wenn sich wirklich etwas geändert hat, dann legen wir mit der Zeile das Änderungsobjekt in dem History-Objekt ab.
Da die Klasse THcCustomEditText nichts Neues zu bieten hat, sparen wir uns hier diese zu besprechen. Sie können sich den Code für die Klasse in dem Abschnitt ansehen, in dem der komplette Code der fertigen Unit UndoRedoControls gezeigt wird.

Damit haben wir auch unsere Edit-Komponente und wir können uns der StringGrid-Komponente zuwenden.


Stringgrid-Komponente

Hier werden wir die Stringgrid-Komponente entwickeln, die Undo/Redo unterstützt.

TUndoRedoStringGrid
TUndoRedoStringGrid.SetEditText
TUndoRedoStringGrid.DoExit
TUndoRedoStringGrid.RowMoved
TUndoRedoStringGrid.MouseDown
TCgStringGridCell
TCgGridRowMoved
THcGridRowMoved


TUndoRedoStringGrid

Als letztes wollen wir eine eigene StringGrid-Komponente schreiben. Spätestens danach sollten Sie in der Lage sein, selbständig weiteren Komponenten eine Undo/Redo-Unterstützung beizubringen.

Einer StringGrid-Komponente mit Undo/Redo auszustatten ist nicht ganz so einfach. Das hat nichts damit zu tun, dass das bei tabellenartigen Komponenten schwerer wäre als bei z.B. einfachen Editfeldern. Das liegt ganz speziell daran, dass die Programmierer von Borland zu faul waren TStringGrid anständig zu designen, so dass es einfach wäre mit abgeleiteten Klassen das Standardverhalten zu verändern. Viele der Methoden sind nicht virtual oder sogar nur private. Deswegen muss man etwas größere Verrenkungen machen, damit man die gewünschten Effekte erzielt. Am besten wäre es, man übernimmt den Code der gesamten Unit Grids und schreibt sich die Klassen einfach um. Wir werden hier aber versuchen durch normales Ableiten zum Ziel zu gelangen.
Wir werden nicht das gesamte StringGrid auf Undo/Redo trimmen. Wir picken uns nur die Eingaben des Anwenders heraus und noch zusätzlich das Verschieben von Spalten und Reihen, damit wir einige unangenehme Seiteneffekte kennen lernen.

  TUndoRedoStringGrid = class(TStringGrid)
  private
    FCgEdit: TRakCommandGroup;
  protected
    procedure MouseDown(Button: TMouseButton; Shift: TShiftState;
      X, Y: Integer); override;
    procedure DoExit; override;
    procedure SetEditText(ACol, ARow: Longint; const Value: string); override;
    procedure ColumnMoved(FromIndex, ToIndex: Longint); override;
    procedure RowMoved(FromIndex, ToIndex: Longint); override;
  end;


TUndoRedoStringGrid.SetEditText

Zuerst sorgen wir dafür, dass die normalen Eingaben des Anwenders durch ein Undo/Redo erfasst werden. Wir werden hier die Änderungen erst dann als eine Änderung behandeln, wenn eine andere Zelle den Fokus erhält.
Bei jedem Buchstaben, den der Anwender eingibt, wir die Methode SetEditText durchlaufen. Deshalb überschreiben wir diese.

procedure TUndoRedoStringGrid.SetEditText(ACol, ARow: Integer;
  const Value: string);
var
  History: TRakHistory;
begin
  // Zuerst besorgen wir uns das Objekt, das alle Änderungen speichert
  History := GetHistory(self);
  if History <> nil then
  begin
    // Das folgende if ist nur eine Sicherheitsabfrage, für den Fall, dass von außen
    // das Hinzufügen von Änderungen in eine Gruppe beendet wurde
    if not History.IsAddingCommands then
      FCgEdit := nil;

    if FCgEdit <> nil then
    begin // Wenn zur Zeit eine Änderung läuft
      if TCgStringGridCell(FCgEdit).CellChanged then
      begin // Wenn in eine andere Zelle gewechselt wurde
        // Dann können wir die Änderung abschließen
        FCgEdit.Abort;
        FCgEdit := nil;
      end;
    end;

    if FCgEdit = nil then
    begin // Wenn zur Zeit keine Änderung läuft
      // Eine neue Änderung beginnen
      FCgEdit := TCgStringGridCell.Init(self, ACol, ARow);
      History.NewGroupStart(FCgEdit);
    end;
  end;

  inherited;
end;

Vieles kennen wir schon von den anderen Komponenten.

    if FCgEdit <> nil then

In dem Feld FCgEdit wird sich eine Referenz auf das Gruppierungsobjekt gemerkt. Solange überhaupt noch nichts geändert wurde, gibt es auch kein Gruppierungsobjekt. Es gibt noch andere Fälle, in denen es kein Gruppierungsobjekt gibt. Diese werden wir aber erst später kennen lernen.

      if TCgStringGridCell(FCgEdit).CellChanged then

Hier wird geprüft, ob noch die gleiche Zelle selektiert ist, die selektiert war als das Gruppierungsobjekt erzeugt wurde. Die Klasse TCgStringGridCell werden wir später noch schreiben.

        FCgEdit.Abort;
        FCgEdit := nil;

Wenn jetzt eine andere Zelle selektiert ist, dann können wir die Änderung für die vorherige abschließen und uns merken, dass es momentan kein Gruppierungsobjekt gibt.

      FCgEdit := TCgStringGridCell.Init(self);
      History.NewGroupStart(FCgEdit);

Wenn es noch kein Gruppierungsobjekt gibt, dann wird hiermit eins angelegt.


TUndoRedoStringGrid.DoExit

Da wir ständig ein Gruppierungsobjekt offen halten, müssen wir dafür sorgen, dass dies geschlossen wird, wenn das StringGrid den Fokus verliert. Dazu überschreiben wir die Methode DoExit.

procedure TUndoRedoStringGrid.DoExit;
var
  History: TRakHistory;
begin
  inherited;

  History := GetHistory(self);
  if History <> nil then
  begin
    // Das folgende if ist nur eine Sicherheitsabfrage, für den Fall, dass von außen
    // das Hinzufügen von Änderungen in eine Gruppe beendet wurde
    if not History.IsAddingCommands then
      FCgEdit := nil;

    if FCgEdit <> nil then
    begin
      FCgEdit.Abort;
      FCgEdit := nil;
    end;
  end;
end;

Da wir hier nichts Neues zu sehen bekommen, gibt es auch nichts Besonderes dazu zu sagen.


TUndoRedoStringGrid.RowMoved

Wenn eine Reihe verschoben wird, dann wird die Methode RowMoved aufgerufen. Bei einer Spalte ist dies ColumnMoved. Deshalb überschreiben wir diese, um das in dem History-Objekt abzulegen.

procedure TUndoRedoStringGrid.RowMoved(FromIndex, ToIndex: Integer);
var
  History: TRakHistory;
begin
  // Zuerst besorgen wir uns das Objekt, das alle Änderungen speichert
  History := GetHistory(self);
  if History <> nil then
  begin
    // Wir starten eine Gruppe, die alle Änderungen gruppiert
    History.NewGroupStart(TCgGridRowMoved.Init(self, FromIndex, ToIndex));
  end;

  try
    // Jetzt wird die eigentliche Verschiebung durchgeführt
    inherited;
  finally
    if History <> nil then
    begin
      if History.IsAddingCommands then
      begin // Wenn das Ändern nicht von außen abgebrochen wurde
        if not (History.ActiveCommandGroup is TCgGridRowMoved) then
          raise ERakUndoRedoSynchronisationLost.Create('TUndoRedoStringGrid: Synchronisation lost');

        // Die Änderung abschließen
        History.ActiveCommandGroup.Abort;
      end;
    end;
  end;
end;

Eigentlich bietet die Implementierung der Methoden nichts Neues.
Eine Besonderheit gibt es aber doch. Um die Änderung von Text korrekt zu erfassen, halten wir ständig ein Gruppierungsobjekt dafür offen. Wenn der Anwender aber eine Reihe mit der Maus verschiebt, dann muss zuerst das Gruppierungsobjekt abgeschlossen werden. Das Ändern von Text und das Verschieben einer Reihe sind zwei vollkommen getrennte Änderungen.
Wir finden aber innerhalb der Methode RowMoved keinen Code, der das offene Gruppierungsobjekt schließen würde. Dies hat den Grund, dass es zu Fehlern führen kann, wenn erst hier das Gruppierungsobjekt geschlossen wird. Erst beim Schließen überprüft das Gruppierungsobjekt, ob sich etwas in der Zelle geändert hat. Wenn aber diese Zelle von der Verschiebung betroffen war, dann würde das Gruppierungsobjekt auf eine falsche Zelle zugreifen.

ColumnMoved ist das Gleiche in Grün. Deshalb sparen wir uns hier den Code von ColumnMoved.


TUndoRedoStringGrid.MouseDown

Die richtige Stelle für das Schließen des Gruppierungsobjekt wäre unmittelbar vor der Verschiebung. Der Autor hat aber leider keine Möglichkeit gefunden, genau zu diesem Zeitpunkt eigenen Code ausführen zu lassen. Deshalb wird eine Notlösung gewählt.
Das Verschieben durch den Anwender erfolgt mit Hilfe der Maus. Deshalb wird einfach immer bei einem Mausklick das Gruppierungsobjekt geschlossen. Dazu wird MouseDown überschrieben.

procedure TUndoRedoStringGrid.MouseDown(Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  History: TRakHistory;
begin
  inherited;

  // Zuerst besorgen wir uns das Objekt, das alle Änderungen speichert
  History := GetHistory(self);
  if History <> nil then
  begin
    // Das folgende if ist nur eine Sicherheitsabfrage, für den Fall, dass von außen
    // das Hinzufügen von Änderungen in eine Gruppe beendet wurde
    if not History.IsAddingCommands then
      FCgEdit := nil;

    if FCgEdit <> nil then
    begin // Falls noch eine Änderung des Textes offen ist
      // Dann schließen wir diese, damit diese sich nicht mit den
      // Column- und Row-Moved-Änderungen ins Gehege kommt.
      History.AbortAdding;
      FCgEdit := nil;
    end;
  end;
end;

Dies hat dann auch den Effekt, dass die Änderung einer Zelle in mehrere Änderungen aufgeteilt wird, wenn der Anwender immer wieder in diese Zelle klickt.

Was uns jetzt noch fehlt sind die Klassen für die Gruppierungs- und Änderungsobjekte der einzelnen Änderungen.


TCgStringGridCell

Bei dem StringGrid verwenden wir die Klasse TCgStringGridCell, um Änderung des Textes von Zellen zu gruppieren. Diese Klasse werden wir jetzt implementieren.

  TCgStringGridCell = class(TRakCommandGroup)
  private
    FCommand: THcStringGridCell;
  protected
    procedure AbortNow; override;
    function GetMenueText: string; override;
  public
    function CellChanged: boolean;

    constructor Init(Obj: TStringGrid; ACol, ARow: Integer);
    destructor Destroy; override;
  end;

Der Aufbau von TCgStringGridCell ist dem der Klasse TCgCustomEditText ähnlich. Es wird ja auch hier eine Textänderung gruppiert.

Als erstes wieder der Konstruktor.

constructor TCgStringGridCell.Init(Obj: TStringGrid; ACol, ARow: Integer);
begin
  Create;

  FCommand := THcStringGridCell.Init(Obj, Obj.Cells[ACol, ARow], '', ACol, ARow);
end;

Im Gegensatz zu TCgCustomEditText wird sich hier nicht eine Referenz auf das geänderte Objekt gemerkt. Hier erzeugen wir sofort das Änderungsobjekt und versorgen es mit Daten, soweit uns diese bereis bekannt sind. Der neue Wert ist uns noch nicht bekannt. Deshalb übergeben wir einfach einen Leerstring.
Wir haben hier das Änderungsobjekt nur erzeugt. Wir haben es noch nicht an das History-Objekt übergeben. Das werden wir erst dann machen, wenn wir wissen, ob sich tatsächlich etwas geändert hat.
Wir könnten hier natürlich auch wieder so vorgehen, wie wir das bei TCgCustomEditText getan haben. Dann müssten wir uns aber mehr als nur eine Referenz auf das geänderte Objekt merken. Wir müssten uns auch merken, welche Spalte und Reihe von der Änderung betroffen sind. Da diese Daten nur kurzzeitig benötigt werden, aber dauerhaft Speicherplatz belegen würden, legen wir hier sofort das Änderungsobjekt an, das diese Daten sowieso speichern muss.

Die Klasse THcStringGridCell müssen wir auch selbst schreiben. Wir werden sie in dem Tutorial aber nicht extra besprechen, da sie nichts wirklich Neues zu bieten hat. Sie können sich den Code der Klasse in dem Abschnitt ansehen, der den kompletten Code der Unit UndoRedoControls zeigt.

Die Methode GetMenueText schenken wir uns hier und kommen gleich zur Methode AbortNow.

procedure TCgStringGridCell.AbortNow;
var
  NewText: string;
begin
  NewText := FCommand.FObj.Cells[FCommand.FCol, FCommand.FRow];
  if FCommand.FOldValue <> NewText then
  begin // Wenn sich wirklich etwas geändert hat
    // Dann müssen wir uns noch den neuen Wert merken
    FCommand.FNewValue := NewText;
    // und das Ganze im History-Objekt ablegen
    History.AddCommand(FCommand);
    // Das Command-Objekt gehört uns jetzt nicht mehr
    FCommand := nil;
  end;
end;

Nochmal zur Erinnerung: AbortNow wird aufgerufen, wenn die Gruppierung abgeschlossen werden muss.

  NewText := FCommand.FObj.Cells[FCommand.FCol, FCommand.FRow];

NewText wird mit dem neuen Text der Zelle versorgt. NewText wird nur verwendet, damit der nachfolgende Code besser lesbar ist.

  if FCommand.FOldValue <> NewText then

Nur wenn sich der Text der Zelle wirklich verändert hat, wird das Änderungsobjekt an das History-Objekt übergeben. Wenn sich nichts verändert hat, dann sorgt der Destruktor dafür, dass das Änderungsobjekt wieder freigegeben wird.

    FCommand.FNewValue := NewText;

Als wir das Änderungsobjekt im Konstruktor angelegt hatten, wussten wir noch nicht, wie der neue Wert der Zelle sein würde. Jetzt wissen wir es. Deshalb müssen wir den Wert nachträglich versorgen.

    FCommand := nil;

Damit der Destruktor nicht ein Objekt freigibt, das uns gar nicht gehört, müssen wir die Referenz auf nil setzen.

Nochmal zur Erinnerung: Gruppierungsobjekte, die "leer" sind, werden von dem History-Objekt gelöscht und es wird so getan, als ob sie nie existiert hätten. Wenn sich nichts geändert hat, dann wird das Änderungsobjekt nicht an das History-Objekt übergeben. Unser Gruppierungsobjekt ist dann also leer und wird nach dem Schließen der Gruppierung entsorgt. Somit wird also nicht unnötig Speicherplatz belegt.

Wie jetzt schon ein paar mal erwähnt, hat der Destruktor die Aufgabe das Änderungsobjekt freizugeben, falls es nicht verwendet wurde.

destructor TCgStringGridCell.Destroy;
begin
  FCommand.Free;

  inherited;
end;

Die Methode CellChanged ist eigentlich nur eine Hilfsmethode mit der in TUndoRedoStringGrid.SetEditText festgestellt wird, ob noch die selbe Zelle den Fokus hat, für die das Gruppierungsobjekt angelegt wurde.

function TCgStringGridCell.CellChanged: boolean;
begin
  Result := (FCommand.FCol <> FCommand.FObj.Col) or (FCommand.FRow <> FCommand.FObj.Row);
end;

Die Funktionsweise dürfte klar sein.
Man könnte sich jetzt darüber streiten, ob diese Methode nicht eher zur Klasse TUndoRedoStringGrid gehört. Da wir aber den inneren Aufbau von TCgStringGridCell nach außen nicht bekannt geben wollen (Stichwort Datenkapselung), haben wir TCgStringGridCell die Methode CellChanged verpasst.


TCgGridRowMoved

Die Gruppierungs- und Änderungsklassen für das Verschieben von Reihen und für das Verschieben von Spalten sind gleich aufgebaut. Deshalb besprechen wir nur die Klassen für das Verschieben der Reihen.
TCgGridRowMoved ist die Klasse für die Gruppierungsobjekte.

  TCgGridRowMoved = class(TRakCommandGroup)
  private
    FCommand: THcGridRowMoved;
  protected
    procedure AbortNow; override;
    function GetMenueText: string; override;
  public
    constructor Init(Obj: TCustomGrid; FromIndex, ToIndex: integer);
    destructor Destroy; override;
  end;

Der Aufbau von den Gruppierungsklassen ist immer wieder das Gleiche. Deshalb ist die Klasse auch schnell besprochen.

constructor TCgGridRowMoved.Init(Obj: TCustomGrid; FromIndex, ToIndex: integer);
begin
  Create;

  if FromIndex <> ToIndex then
    FCommand := THcGridRowMoved.Init(Obj, FromIndex, ToIndex);
end;

Der Konstruktor legt das Änderungsobjekt an, aber nur, wenn wirklich eine Reihe verschoben wird.

procedure TCgGridRowMoved.AbortNow;
begin
  if FCommand <> nil then
    History.AddCommand(FCommand);

  FCommand := nil;
end;

Bevor die Gruppierung geschlossen wird, wird das Änderungsobjekt an das History-Objekt übergeben. Die Referenz wird auf nil gesetzt, damit der Destruktor nicht etwas löscht, was uns nicht mehr gehört.


THcGridRowMoved

Etwas interessanter als die Gruppierungsklasse TCgGridRowMoved ist die Änderungsklasse THcGridRowMoved.

  THcGridRowMoved = class(TRakCommand)
  private
    FObj: TCustomGrid;
    FOldValue: integer;
    FNewValue: integer;
  protected
    function UndoNow: boolean; override;
    function RedoNow: boolean; override;
  public
    constructor Init(Obj: TCustomGrid; OldValue, NewValue: integer);
  end;

Im Gegensatz zu den meisten anderen Änderungsklassen, die wir bis jetzt geschrieben haben, wird THcGridRowMoved direkt von TRakCommand abgeleitet. Das bedeutet dann natürlich, dass wir die abstrakten Methoden UndoNow und RedoNow selbst überschreiben müssen.
Wir merken uns hier nicht eine Referenz auf ein StringGrid-Objekt sondern auf ein TCustomGrid. So etwas ähnliches hatten wir schon einmal bei THcButtonControlChecked. Somit kann diese Änderungsklasse auch bei anderen Controls, die von TCustomGrid abgeleitet sind verwendet werden.

function THcGridRowMoved.RedoNow: boolean;
begin
  TProtectedCustomGrid(FObj).MoveRow(FOldValue, FNewValue);

  Result := true;
end;

Die Implementierung selbst ist dann aber wieder relativ trivial. Wir verwenden hier wieder den kleinen Trick, damit wir auf protected-Methoden zugreifen können.

  TProtectedCustomGrid = class(TCustomGrid);

UndoNow macht das Gleiche, nur in die andere Richtung.

function THcGridRowMoved.UndoNow: boolean;
begin
  TProtectedCustomGrid(FObj).MoveRow(FNewValue, FOldValue);

  Result := true;
end;

Damit haben wir auch dem StringGrid ein Undo/Redo verpasst. Damit ist die Unit UndoRedoControls komplett und wir können im nächsten Kapitel eine Testoberfläche für unsere Komponenten schreiben.


Der komplette Code der Unit UndoRedoControls

unit UndoRedoControls;

interface

uses
  RakUndoRedo,
  Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs,
  StdCtrls, Grids;

var
  URM_GET_HISTORY: integer = WM_USER + 874;
  URM_ACTIVATE_UNDO: integer = WM_USER + 875;

type
  TUndoRedoEdit = class(TEdit)
  private
    FGroupIsOpen: boolean;
    FOldText: string;
  protected
    procedure Change; override;
    procedure DoEnter; override;
    procedure DoExit; override;
  end;

  TUndoRedoCheckBox = class(TCheckBox)
  protected
    procedure Toggle; override;
  end;

  TUndoRedoStringGrid = class(TStringGrid)
  private
    FCgEdit: TRakCommandGroup;
  protected
    procedure MouseDown(Button: TMouseButton; Shift: TShiftState;
      X, Y: Integer); override;
    procedure DoExit; override;
    procedure SetEditText(ACol, ARow: Longint; const Value: string); override;
    procedure ColumnMoved(FromIndex, ToIndex: Longint); override;
    procedure RowMoved(FromIndex, ToIndex: Longint); override;
  end;

procedure Register;

implementation

uses comctrls, extctrls;

type
  TProtectedButtonControl = class(TButtonControl);
  TProtectedNotebook = class(TNotebook);
  TProtectedPageControl = class(TPageControl);
  TProtectedCustomGrid = class(TCustomGrid);

  THcCustomEditText = class(TRakHcString)
  private
    FEdit: TCustomEdit;
  protected
    function GetChangedObject: TObject; override;
    procedure SetValue(Obj: TObject; Value: string; var Done: boolean); override;
  public
    constructor Init(Obj: TCustomEdit; OldValue, NewValue: string);
  end;

  TCgCustomEditText = class(TRakCommandGroup)
  private
    FEdit: TUndoRedoEdit;
  protected
    procedure AbortNow; override;
    function GetMenueText: string; override;
  public
    constructor Init(Obj: TUndoRedoEdit);
  end;

  THcButtonControlChecked = class(TRakHcBoolean)
  private
    FObj: TButtonControl;
  protected
    function GetChangedObject: TObject; override;
    procedure SetValue(Obj: TObject; Value: boolean; var Done: boolean); override;
  public
    constructor Init(Obj: TButtonControl; NewValue: boolean);
  end;

  TCgButtonControlChecked = class(TRakCommandGroup)
  protected
    function GetMenueText: string; override;
  end;

  THcGridColumnMoved = class(TRakCommand)
  private
    FObj: TCustomGrid;
    FOldValue: integer;
    FNewValue: integer;
  protected
    function UndoNow: boolean; override;
    function RedoNow: boolean; override;
  public
    constructor Init(Obj: TCustomGrid; OldValue, NewValue: integer);
  end;

  TCgGridColumnMoved = class(TRakCommandGroup)
  private
    FCommand: THcGridColumnMoved;
  protected
    procedure AbortNow; override;
    function GetMenueText: string; override;
  public
    constructor Init(Obj: TCustomGrid; FromIndex, ToIndex: integer);
    destructor Destroy; override;
  end;

  THcGridRowMoved = class(TRakCommand)
  private
    FObj: TCustomGrid;
    FOldValue: integer;
    FNewValue: integer;
  protected
    function UndoNow: boolean; override;
    function RedoNow: boolean; override;
  public
    constructor Init(Obj: TCustomGrid; OldValue, NewValue: integer);
  end;

  TCgGridRowMoved = class(TRakCommandGroup)
  private
    FCommand: THcGridRowMoved;
  protected
    procedure AbortNow; override;
    function GetMenueText: string; override;
  public
    constructor Init(Obj: TCustomGrid; FromIndex, ToIndex: integer);
    destructor Destroy; override;
  end;

  THcStringGridCell = class(TRakHCString)
  private
    FObj: TStringGrid;
    FCol: integer;
    FRow: integer;
  protected
    function GetChangedObject: TObject; override;
    procedure SetValue(Obj: TObject; Value: string; var Done: boolean); override;
  public
    constructor Init(Obj: TStringGrid; OldValue, NewValue: string; Col, Row: integer);
  end;

  TCgStringGridCell = class(TRakCommandGroup)
  private
    FCommand: THcStringGridCell;
  protected
    procedure AbortNow; override;
    function GetMenueText: string; override;
  public
    function CellChanged: boolean;

    constructor Init(Obj: TStringGrid; ACol, ARow: Integer);
    destructor Destroy; override;
  end;

procedure Register;
begin
  RegisterComponents('UndoRedoControls', [TUndoRedoEdit, TUndoRedoCheckBox, TUndoRedoStringGrid]);
end;

function GetHistory(Ctrl: TControl): TRakHistory;
var
  Frm: TCustomForm;
  Obj: TObject;
begin
  Result := nil;

  // Zuerst das Formular besorgen, auf dem sich das Control befindet
  Frm := GetParentForm(Ctrl);
  if Frm <> nil then
  begin
    // Dann von dem Formular die Referenz auf das History-Objekt anfordern.
    // Wenn das Formular URM_GET_HISTORY nicht kennt, dann wird 0 zurückgegeben, was einem
    // nil entspricht
    Obj := TObject(Frm.Perform(URM_GET_HISTORY, 0, 0));
    if Obj is TRakHistory then
    begin // Wenn wir tatsächlich eine Referenz auf das History-Objekt bekommen haben
      Result := TRakHistory(Obj);
    end;
  end;
end;

procedure ActivateUndo(Ctrl: TControl);
var
  Frm: TCustomForm;
begin
  Frm := GetParentForm(Ctrl);
  if Frm <> nil then
    Frm.Perform(URM_ACTIVATE_UNDO, 0, 0);
end;

function ShowCtrl(Ctrl: TWinControl): boolean;
var
  Tbs: TTabSheet;
  Pgc: TProtectedPageControl;
begin
  Result := true;
  if Ctrl <> nil then
  begin
    // Zuerst sorgen wir dafür, dass der Parent angezeigt wird
    Result := ShowCtrl(Ctrl.Parent);
    if Result then
    begin
      if Ctrl is TTabSheet then
      begin
        // Damit ein Tabsheet sauber erscheint muss einiges getan werden
        Tbs := TTabSheet(Ctrl);
        Pgc := TProtectedPageControl(Tbs.PageControl);
        if Pgc.ActivePage <> Tbs then
        begin // Wenn das TabSheet noch nicht das sichtbare ist
          // Dann lösen wir zuerst das OnChanging-Ereignis des PageControls aus
          Result := Pgc.CanChange;
          if Result then
          begin // Wenn die Seite gewechselt werden darf
            // Dann machen wir das
            Tbs.PageControl.ActivePage := Tbs;
            // und lösen zum Schluss noch das OnChange-Ereignis aus
            Pgc.Change;
          end;
        end;
      end
      else if Ctrl.Parent is TNotebook then
        TProtectedNotebook(Ctrl.Parent).ShowControl(Ctrl);
    end;
  end;
end;

procedure ShowAndFocus(Ctrl: TWinControl);
begin
  if ShowCtrl(Ctrl) then
  try
    Ctrl.SetFocus;
  except;
  end;
end;

{ TUndoRedoEdit }

procedure TUndoRedoEdit.Change;
var
  History: TRakHistory;
  CloseGroup: boolean;
begin
  CloseGroup := false;

  // Zuerst besorgen wir uns das Objekt, das alle Änderungen speichert
  History := GetHistory(self);
  if History <> nil then
  begin
    if History.IsUnRedoing then
    begin // Wenn gerade ein Undo/Redo durchgeführt wird
      // Dann stellen wir sicher, dass wir uns den richtigen Ausgangstext merken
      FOldText := Text
    end
    else
    begin
      if Focused then
      begin // Wenn durch den Anwender der Text geändert wird
        // Dann gruppieren wir alle Änderungen bis das Editfeld den Fokus verliert
        if not FGroupIsOpen then
        begin // Wenn noch keine Gruppierung gestartet wurde
          History.NewGroupStart(TCgCustomEditText.Init(self));
          FGroupIsOpen := true;
        end;
      end
      else
      begin // Wenn durch Code der Text geändert wird
        // Dann Gruppieren wir nur die Änderungen, die jetzt anfallen
        if History <> nil then
        begin
          History.NewGroupStart(TCgCustomEditText.Init(self));
          // Merken, dass wir die Gruppe schließen müssen
          CloseGroup := true;
        end;
      end;

      if History.UndoPtr < 0 then
      begin // Wenn das Formular noch glaubt, dass sich noch nichts geändert hätte
        // Dann teilen wir dem Formular mit, dass sich doch schon etwas geändert hat
        ActivateUndo(self);
      end;
    end;
  end;

  inherited;

  if CloseGroup then
  begin
    if History.IsAddingCommands then
    begin // Wenn das Ändern nicht von außen abgebrochen wurde
      if not (History.ActiveCommandGroup is TCgCustomEditText)
        or (TCgCustomEditText(History.ActiveCommandGroup).FEdit <> self) then
      begin
        raise ERakUndoRedoSynchronisationLost.Create('TUndoRedoEdit: Synchronisation lost');
      end;
      // Die Änderung abschließen
      History.ActiveCommandGroup.Abort;
    end;
  end;
end;

procedure TUndoRedoEdit.DoEnter;
begin
  // Wir merken uns den Text, damit wir später feststellen können,
  // ob sich tatsächlich etwas geändert hat
  FOldText := Text;

  inherited;
end;

procedure TUndoRedoEdit.DoExit;
var
  History: TRakHistory;
begin
  inherited;

  if FGroupIsOpen then
  begin // Wenn eine Gruppierung für Änderungen in der Methode Change geöffnet wurde
    // Dann müssen wir diese jetzt schließen
    // Dazu besorgen wir uns das Objekt, das alle Änderungen speichert
    History := GetHistory(self);
    if History <> nil then
    begin
      if History.IsAddingCommands then
      begin // Wenn das Ändern nicht von außen abgebrochen wurde
        if not (History.ActiveCommandGroup is TCgCustomEditText)
          or (TCgCustomEditText(History.ActiveCommandGroup).FEdit <> self) then
        begin
          raise ERakUndoRedoSynchronisationLost.Create('TUndoRedoEdit: Synchronisation lost');
        end;
        // Die Änderung abschließen
        History.ActiveCommandGroup.Abort;
      end;
    end;
  end;
end;

{ TUndoRedoCheckBox }

procedure TUndoRedoCheckBox.Toggle;
var
  History: TRakHistory;
begin
  // Zuerst besorgen wir uns das Objekt, das alle Änderungen speichert
  History := GetHistory(self);
  if History <> nil then
  begin
    // Wir starten eine Gruppe, die alle Änderungen gruppiert
    History.NewGroupStart(TCgButtonControlChecked.Create);
    // und fügen die Änderung der Checkbox hinzu
    History.AddCommand(THcButtonControlChecked.Init(self, not Checked));
  end;
  try
    // Jetzt wird Checked tatsächlich geändert
    inherited;
  finally
    if History <> nil then
    begin
      if History.IsAddingCommands then
      begin // Wenn das Ändern nicht von außen abgebrochen wurde
        if not (History.ActiveCommandGroup is TCgButtonControlChecked) then
          raise ERakUndoRedoSynchronisationLost.Create('TUndoRedoCheckBox: Synchronisation lost');

        // Die Änderung abschließen
        History.ActiveCommandGroup.Abort;
      end;
    end;
  end;
end;

{ THcCustomEditText }

function THcCustomEditText.GetChangedObject: TObject;
begin
  // Hier geben wir die Referenz auf das Edit-Objekt zurück, das geändert werden soll
  Result := FEdit;
end;

constructor THcCustomEditText.Init(Obj: TCustomEdit; OldValue,
  NewValue: string);
begin
  inherited Init(OldValue, NewValue);
  FEdit := Obj;
end;

procedure THcCustomEditText.SetValue(Obj: TObject; Value: string;
  var Done: boolean);
begin
  // Hier führen wir die Änderung durch,
  (Obj as TCustomEdit).Text := Value;
  // verpassen dem Editfeld den Fokus
  ShowAndFocus(TWinControl(Obj));

  Done := false;
end;

{ THcButtonControlChecked }

function THcButtonControlChecked.GetChangedObject: TObject;
begin
  Result := FObj;
end;

constructor THcButtonControlChecked.Init(Obj: TButtonControl;
  NewValue: boolean);
begin
  inherited Init(NewValue);

  FObj := Obj;
end;

procedure THcButtonControlChecked.SetValue(Obj: TObject; Value: boolean;
  var Done: boolean);
begin
  TProtectedButtonControl(Obj).Checked := Value;
  ShowAndFocus(TWinControl(Obj));
end;

{ TUndoRedoStringGrid }

procedure TUndoRedoStringGrid.ColumnMoved(FromIndex, ToIndex: Integer);
var
  History: TRakHistory;
begin
  // Zuerst besorgen wir uns das Objekt, das alle Änderungen speichert
  History := GetHistory(self);
  if History <> nil then
  begin
    // Wir starten eine Gruppe, die alle Änderungen gruppiert
    History.NewGroupStart(TCgGridColumnMoved.Init(self, FromIndex, ToIndex));
  end;

  try
    // Jetzt wird die eigentliche Verschiebung durchgeführt
    inherited;
  finally
    if History <> nil then
    begin
      if History.IsAddingCommands then
      begin // Wenn das Ändern nicht von außen abgebrochen wurde
        if not (History.ActiveCommandGroup is TCgGridColumnMoved) then
          raise ERakUndoRedoSynchronisationLost.Create('TUndoRedoStringGrid: Synchronisation lost');

        // Die Änderung abschließen
        History.ActiveCommandGroup.Abort;
      end;
    end;
  end;
end;

procedure TUndoRedoStringGrid.DoExit;
var
  History: TRakHistory;
begin
  inherited;

  History := GetHistory(self);
  if History <> nil then
  begin
    // Das folgende if ist nur eine Sicherheitsabfrage, für den Fall, dass von außen
    // das Hinzufügen von Änderungen in eine Gruppe beendet wurde
    if not History.IsAddingCommands then
      FCgEdit := nil;

    if FCgEdit <> nil then
    begin
      FCgEdit.Abort;
      FCgEdit := nil;
    end;
  end;
end;

procedure TUndoRedoStringGrid.MouseDown(Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  History: TRakHistory;
begin
  inherited;

  // Zuerst besorgen wir uns das Objekt, das alle Änderungen speichert
  History := GetHistory(self);
  if History <> nil then
  begin
    // Das folgende if ist nur eine Sicherheitsabfrage, für den Fall, dass von außen
    // das Hinzufügen von Änderungen in eine Gruppe beendet wurde
    if not History.IsAddingCommands then
      FCgEdit := nil;

    if FCgEdit <> nil then
    begin // Falls noch eine Änderung des Textes offen ist
      // Dann schließen wir diese, damit diese sich nicht mit den
      // Column- und Row-Moved-Änderungen ins Gehege kommt.
      History.AbortAdding;
      FCgEdit := nil;
    end;
  end;
end;

procedure TUndoRedoStringGrid.RowMoved(FromIndex, ToIndex: Integer);
var
  History: TRakHistory;
begin
  // Zuerst besorgen wir uns das Objekt, das alle Änderungen speichert
  History := GetHistory(self);
  if History <> nil then
  begin
    // Wir starten eine Gruppe, die alle Änderungen gruppiert
    History.NewGroupStart(TCgGridRowMoved.Init(self, FromIndex, ToIndex));
  end;

  try
    // Jetzt wird die eigentliche Verschiebung durchgeführt
    inherited;
  finally
    if History <> nil then
    begin
      if History.IsAddingCommands then
      begin // Wenn das Ändern nicht von außen abgebrochen wurde
        if not (History.ActiveCommandGroup is TCgGridRowMoved) then
          raise ERakUndoRedoSynchronisationLost.Create('TUndoRedoStringGrid: Synchronisation lost');

        // Die Änderung abschließen
        History.ActiveCommandGroup.Abort;
      end;
    end;
  end;
end;

procedure TUndoRedoStringGrid.SetEditText(ACol, ARow: Integer;
  const Value: string);
var
  History: TRakHistory;
begin
  // Zuerst besorgen wir uns das Objekt, das alle Änderungen speichert
  History := GetHistory(self);
  if History <> nil then
  begin
    // Das folgende if ist nur eine Sicherheitsabfrage, für den Fall, dass von außen
    // das Hinzufügen von Änderungen in eine Gruppe beendet wurde
    if not History.IsAddingCommands then
      FCgEdit := nil;

    if FCgEdit <> nil then
    begin // Wenn zur Zeit eine Änderung läuft
      if TCgStringGridCell(FCgEdit).CellChanged then
      begin // Wenn in eine andere Zelle gewechselt wurde
        // Dann können wir die Änderung abschließen
        FCgEdit.Abort;
        FCgEdit := nil;
      end;
    end;

    if FCgEdit = nil then
    begin // Wenn zur Zeit keine Änderung läuft
      // Eine neue Änderung beginnen
      FCgEdit := TCgStringGridCell.Init(self, ACol, ARow);
      History.NewGroupStart(FCgEdit);
    end;
  end;

  inherited;
end;

{ THcGridColumnMoved }

constructor THcGridColumnMoved.Init(Obj: TCustomGrid; OldValue,
  NewValue: integer);
begin
  inherited Create;

  FObj := Obj;
  FOldValue := OldValue;
  FNewValue := NewValue;
end;

function THcGridColumnMoved.RedoNow: boolean;
begin
  TProtectedCustomGrid(FObj).MoveColumn(FNewValue, FOldValue);

  Result := true;
end;

function THcGridColumnMoved.UndoNow: boolean;
begin
  TProtectedCustomGrid(FObj).MoveColumn(FOldValue, FNewValue);

  Result := true;
end;

{ THcGridRowMoved }

constructor THcGridRowMoved.Init(Obj: TCustomGrid; OldValue,
  NewValue: integer);
begin
  Create;

  FObj := Obj;
  FOldValue := OldValue;
  FNewValue := NewValue;
end;

function THcGridRowMoved.RedoNow: boolean;
begin
  TProtectedCustomGrid(FObj).MoveRow(FOldValue, FNewValue);

  Result := true;
end;

function THcGridRowMoved.UndoNow: boolean;
begin
  TProtectedCustomGrid(FObj).MoveRow(FNewValue, FOldValue);

  Result := true;
end;

{ THcStringGridCell }

function THcStringGridCell.GetChangedObject: TObject;
begin
  Result := FObj;
end;

constructor THcStringGridCell.Init(Obj: TStringGrid; OldValue,
  NewValue: string; Col, Row: integer);
begin
  inherited Init(OldValue, NewValue);

  FObj := Obj;
  FCol := Col;
  FRow := Row;
end;

procedure THcStringGridCell.SetValue(Obj: TObject; Value: string;
  var Done: boolean);
begin
  FObj.Cells[FCol, FRow] := Value;
  ShowAndFocus(FObj);
  FObj.Col := FCol;
  FObj.Row := FRow;
end;

{ TCgCustomEditText }

procedure TCgCustomEditText.AbortNow;
begin
  if FEdit.FOldText <> FEdit.Text then
  begin // Wenn sich der Text wirklich geändert hat
    // Dann speichern wir das in dem History-Objekt ab
    History.AddCommand(THcCustomEditText.Init(FEdit, FEdit.FOldText, FEdit.Text));
    // und merken uns den neuen Text
    FEdit.FOldText := FEdit.Text;
    // und setzen das Flag zurück, dass noch eine Gruppierung zu schließen wäre.
    FEdit.FGroupIsOpen := false;
  end;
end;

function TCgCustomEditText.GetMenueText: string;
begin
  Result := 'Text ändern';
end;

constructor TCgCustomEditText.Init(Obj: TUndoRedoEdit);
begin
  Create;
  FEdit := Obj;
end;

{ TCgButtonControlChecked }

function TCgButtonControlChecked.GetMenueText: string;
begin
  Result := 'CheckBox ändern';
end;

{ TCgGridColumnMoved }

procedure TCgGridColumnMoved.AbortNow;
begin
  if FCommand <> nil then
    History.AddCommand(FCommand);

  FCommand := nil;
end;

destructor TCgGridColumnMoved.Destroy;
begin
  FCommand.Free;

  inherited;
end;

function TCgGridColumnMoved.GetMenueText: string;
begin
  Result := 'Spalte verschoben';
end;

constructor TCgGridColumnMoved.Init(Obj: TCustomGrid; FromIndex, ToIndex: integer);
begin
  Create;

  if FromIndex <> ToIndex then
    FCommand := THcGridColumnMoved.Init(Obj, FromIndex, ToIndex);
end;

{ TCgGridRowMoved }

procedure TCgGridRowMoved.AbortNow;
begin
  if FCommand <> nil then
    History.AddCommand(FCommand);

  FCommand := nil;
end;

destructor TCgGridRowMoved.Destroy;
begin
  FCommand.Free;

  inherited;
end;

function TCgGridRowMoved.GetMenueText: string;
begin
  Result := 'Reihe verschoben';
end;

constructor TCgGridRowMoved.Init(Obj: TCustomGrid; FromIndex, ToIndex: integer);
begin
  Create;

  if FromIndex <> ToIndex then
    FCommand := THcGridRowMoved.Init(Obj, FromIndex, ToIndex);
end;

{ TCgStringGridCell }

procedure TCgStringGridCell.AbortNow;
var
  NewText: string;
begin
  NewText := FCommand.FObj.Cells[FCommand.FCol, FCommand.FRow];
  if FCommand.FOldValue <> NewText then
  begin // Wenn sich wirklich etwas geändert hat
    // Dann müssen wir uns noch den neuen Wert merken
    FCommand.FNewValue := NewText;
    // und das Ganze im History-Objekt ablegen
    History.AddCommand(FCommand);
    // Das Command-Objekt gehört uns jetzt nicht mehr
    FCommand := nil;
  end;
end;

destructor TCgStringGridCell.Destroy;
begin
  FCommand.Free;

  inherited;
end;

function TCgStringGridCell.GetMenueText: string;
begin
  Result := 'Zellentext geändert';
end;

constructor TCgStringGridCell.Init(Obj: TStringGrid; ACol, ARow: Integer);
begin
  Create;

  FCommand := THcStringGridCell.Init(Obj, Obj.Cells[ACol, ARow], '', ACol, ARow);
end;

function TCgStringGridCell.CellChanged: boolean;
begin
  Result := (FCommand.FCol <> FCommand.FObj.Col) or (FCommand.FRow <> FCommand.FObj.Row);
end;

end.


Die Testoberfläche

Jetzt haben wir die ganze Zeit nur programmiert und konnten nie ein Programm starten. Damit sich das jetzt langsam einmal ändert, werden wir nun eine Testoberfläche für unsere neuen Komponenten erstellen.
Wir werden keine sinnvolle Oberfläche erstellen. Sie soll nur zum Testen unserer Komponenten dienen. Unser Formular wird ein TPageControl mit zwei Seiten enthalten. Auf diesen beiden Seiten platzieren wir unsere neuen Komponenten. Da wir auch das Umschalten von TNotebook unterstützen, setzen wir davon auch eins ein.
Da man neue Komponenten erst dann in die Entwicklungsumgebung integrieren soll, wenn diese ausreichend getestet wurden, werden wir natürlich alle benötigten Instanzen von Hand erzeugen.

Als erstes legen wir unser neue Formular an. Wir werden aber nicht das Standardformular verwenden, sondern das Formular, dass wir uns selbst programmiert und der Objektablage hinzugefügt haben.
Wir verpassen ihm zwei Buttons. Den einen Button verbinden wir mit der Action ActUndo und den anderen mit der Action ActRedo. Weiterhin bekommt es ein TPageControl. Das PageControl bekommt zwei Seiten. Auf der ersten Seite wird noch ein TNotebook platziert. Das Notebook bekommt auch zwei Seiten, in denen wir jeweils ein Panel platzieren.

Sie können sich die fertige Testoberfläche auch von der Internetseite http://www.kasparsoft.de/UndoRedo/Download.htm herunterladen.


Controls erzeugen

Die zu testenden Controls erzeugen wir in der FormCreate-Methode des Formulars.

procedure TMainForm.FormCreate(Sender: TObject);
begin
  inherited;

  // Ein Editfeld für die erste Seite des TNotebook
  FEditNbk1 := TUndoRedoEdit.Create(self);
  FEditNbk1.Parent := PnlSeite1;

  // Ein Editfeld für die zweite Seite des TNotebook
  FEditNbk2 := TUndoRedoEdit.Create(self);
  FEditNbk2.Parent := PnlSeite2;

  // Eine Checkbox, mit der man die Notebookseiten umschalten kann
  FCck1 := TUndoRedoCheckBox.Create(self);
  FCck1.Parent := TabSheet1;
  FCck1.Top := NbkDemo.Top + 5;
  FCck1.OnClick := Cck1Click;
  FCck1.Caption := 'Notebookseite 2';

  // Eine Checkbox, die den Seiteneffekt hat ein Editfeld zu löschen
  FCck2 := TUndoRedoCheckBox.Create(self);
  FCck2.Parent := TabSheet1;
  FCck2.Top := FCck1.Top + 40;
  FCck2.OnClick := Cck2Click;
  FCck2.Caption := 'mit Seiteneffekt';

  // Ein Editfeld für die erste Seite des TPageControl
  FEditPgc1_1 := TUndoRedoEdit.Create(self);
  FEditPgc1_1.Parent := TabSheet1;
  FEditPgc1_1.Top := FCck2.Top + 25;

  // Ein Editfeld für die zweite Seite des TPageControl
  FEditPgc2_1 := TUndoRedoEdit.Create(self);
  FEditPgc2_1.Parent := PnlEdits;

  // Noch ein Editfeld für die zweite Seite des TPageControl
  FEditPgc2_2 := TUndoRedoEdit.Create(self);
  FEditPgc2_2.Parent := PnlEdits;
  FEditPgc2_2.Top := 25;

  // Und zum Abschluss ein StringGrid auf die zweite Seite des TPageControl
  FGrid := TUndoRedoStringGrid.Create(self);
  FGrid.Parent := TabSheet2;
  FGrid.Align := alClient;
  FGrid.RowCount := 5;
  FGrid.ColCount := 3;
  FGrid.Options := FGrid.Options + [goEditing, goColMoving, goRowMoving];
end;


Seiteneffekte programmieren

Damit wir Testen können, ob die Gruppierung funktioniert, müssen wir natürlich auf Ereignisse reagieren, indem wir irgend etwas anderes ändern.
Dazu verwenden wir eine der Checkboxen.

procedure TMainForm.Cck2Click(Sender: TObject);
begin
  if FCck2.Checked then
    FEditPgc1_1.Text := '';
end;

Wenn die Markierung der Checkbox gesetzt wird, dann wird der Text eines Editfeldes gelöscht. Wenn alles klappt, dann muss der Anwender nur einmal Undo auslösen, damit sowohl wieder der alte Text in dem Editfeld steht, als auch die Checkbox nicht mehr markiert ist.

Mit der zweiten Checkbox schalten wir nur das Notebook um, damit wir beide Seiten des Notebooks nutzen und damit testen können, ob die automatische Umschaltung funktioniert.

procedure TMainForm.Cck1Click(Sender: TObject);
begin
  if Fcck1.Checked then
    NbkDemo.Activepage := 'Seite2'
  else
    NbkDemo.Activepage := 'Seite1';
end;

Das war es dann auch schon. Jetzt können wir es übersetzen und starten.

Sie müssen nicht umbedingt die Buttons verwenden, um ein Undo bzw. Redo auszulösen. Wir hatten unserem Formular auch beigebracht auf die Shortcuts Strg+Z und Umsch+Strg+Z zu reagieren. Wir hätten also die beiden Buttons auf dem Formular überhaupt nicht benötigt. Damit können wir aber sehen, dass das Abgrauen sauber klappt.


Abschlußbemerkungen

Wir hatten etliches zu programmieren, damit wir unseren drei Komponenten mit einer Undo/Redo-Unterstützung ausstatten konnten. Dementsprechend kann man sich vorstellen, was das für eine enorme Arbeit wäre, wenn man alle Eingabekomponenten damit erweitern möchte. Vielleicht finden sich einige, die Lust haben zusammen eine ganze Sammlung von derartigen Komponenten zu erstellen. Wenn jemand Lust dazu hat, dann kann er sich bei dem Autor (richard@kasparsoft.de) melden und der versucht diese dann zusammen zu bringen.

Die gerade erstellte Implementierung hat mindestens noch einen größeren Schwachpunkt. Bei der Edit- und der String-Komponente ist es momentan so, dass beim Editieren des Textes ständig Change-Ereignisse ausgelöst werden. Für diese Ereignisse könnte es Code geben, der weitere Änderungen durchführt. Wir gruppieren das Ganze zwar, wenn aber diese weiteren Änderungen bei einem Undo voraussetzen, dass in dem Editfeld genau der richtige Text steht, dann haben wir ein Problem. Wir merken uns erst ganz zum Schluss, die gesamte Änderung des Textes. Bei einem Undo würde also zuerst das Editfeld wieder mit dem Text versorgt, den es hatte bevor es den Fokus erhalten hat. Die Änderungen, die während des Eintippens hinzukamen, werden danach rückgängig gemacht. Als diese Änderungen zustande kamen stand allerdings evtl. etwas ganz anderes in dem Editfeld. Das könnte Probleme machen.
Wir könnten das Problem lösen, indem wir jedesmal, bevor das OnChange-Ereignis ausgelöst wird, ein Änderungsobjekt erstellen, das diese "Mini"-Änderung erfasst. Dann würden wir aber ein vielfaches an Speicher benötigen. Damit das Speicherproblem entschärft wird, könnte man beim Schließen der Gruppierung, die einzelnen Änderungsobjekte abklappern und versuchen einige davon in einem Änderungsobjekt zusammen zu fassen. Da hat man dann allerdings einiges zu schreiben und es wird wohl eine Weile dauern, bis das fehlerfrei läuft.

Die hier geschriebenen Klassen sind relativ schlecht getestet. Man sollte sie also nicht ohne weitere Test übernehmen.


Debuggen

Wenn sie Undo/Redo für ihre speziellen Anforderungen programmieren, dann werden sie sicherlich einige Bugs erzeugen. Wenn dann ein Undo nicht so wie erwartet funktioniert, dann fängt die große Fehlersuche an.
Da Gruppierungsobjekte beliebig tief verschachtelt werden können, hat man es mit einer Baumstruktur zu tun. Bei Bäumen wird normalerweise rekursiv programmiert. So ist das auch bei den Undo/Redo-Klassen aus der Unit RakUndoRedo. Beim Debuggen durch rekursiven Code verliert man schnell den Überblick, wo man sich den jetzt genau befindet. Deshalb gibt es ein kleines Tool, das einem beim Debuggen von Undo/Redo unterstützen soll. Dieses Tool kann man von der Internetseite http://www.kasparsot.de/UndoRedo/Download.htm herunter laden.
Damit das Tool funktioniert, muss man zwei Dinge beachten.

1. Die Unit RakUndoRedo muss mit der Bedingung _CONTACT_UNDO_REDO_DEBUGGING_MONITOR_ übersetzt werden.
2. Das Tool muss vor dem zu debuggenden Programm gestartet werden.

Wir werden jetzt anhand unseres komplexen Beispiels sehen, wie man dieses Tool einsetzt. In dem Zug werden wir etwas tiefer in die Klassen aus der Unit RakUndoRedo hineinsehen.


Vorbereitungen

Wie bereits erwähnt, muss die Unit RakUndoRedo der Bedingung _CONTACT_UNDO_REDO_DEBUGGING_MONITOR_ übersetzt worden sein, damit das Tool, der Debuging-Monitor, überhaupt angesprochen wird.

Dazu tragen wir die Bedingung in den Projektoptionen ein

und erzeugen das Projekt neu.

Dann starten wird den Debugging-Monitor

und markieren noch die Checkbox Verfolgung aktiv.
Jetzt können wir loslegen.


Der Debugging-Monitor

Jetzt starten wir einfach unser Programm und sehen was passiert.

Unmittelbar nach dem Start hat sich gar nichts sichtbares getan. Im Hintergrund hat aber das History-Objekt Kontakt zu dem Debugging-Monitor aufgenommen. Wenn das History-Objekt den Debugging-Monitor beim Start nicht gefunden hätte, dann hätte es ab dann keine weiteren Versuche mehr unternommen, mit diesem Kontakt aufzunehmen. Deshalb muss der Debugging-Monitor vorher gestartet werden.

Als nächstes markieren wir die Checkbox Notebookseite 2.

Jetzt hat sich auch etwas im Debugging-Monitor getan. In der linken Liste wird der Menuetext des Gruppierungsobjektes angezeigt. In der rechten Baumansicht kann man sich genauer ansehen, wie dieses aufgebaut ist.

Wir können hier sehen, dass es nur ein einziges Änderungsobjekt gibt. Dieses hat die Klasse THcButtonControlChecked.
Wir können die komplette Vererbungsliste bis zur Klasse TRakCommand sehen.
CmdId und FChangedObjectName sind für uns nicht weiter interessant.
NewValue zeigt uns dann noch, wie der Wert nach der Änderung ist.
Was wir hier nicht sehen können, ist welches Objekt verändert wurde. Dazu müssten wir die Klasse THcButtonControlChecked erweitern, so dass sie die entsprechende Information an den Debugging-Monitor weitergibt. Wie das geht, werden wir uns später ansehen.

Jetzt wollen wir uns eine etwas komplexere Änderung ansehen. Dazu tragen wir etwas in das Editfeld unter der Checkbox mit Seiteneffekt ein und markieren danach diese Checkbox.
Jetzt stehen in der Liste noch zwei weitere Änderungen. Wir interessieren uns für die letzte.

Hier sehen wir, dass zwei Änderungsobjekte gruppiert sind.
Zuerst ein Änderungsobjekt, für das Markieren der Checkbox und danach ein Änderungsobjekt für das Löschen des Editfeldes. Wir erinnern uns, dass dies in der Ereignisbehandlungsroutine des OnClick-Ereignisses der Checkbox passiert. Dadurch, dass diese beiden Änderungen gruppiert sind, muss der Anwender nur einmal Undo auslösen, damit beide Änderungen rückgängig gemacht werden.

Jetzt lösen wir einmal ein Undo aus. Im Debugging-Monitor ändert sich nur der selektierte Knoten. Das interessiert uns nicht weiter.
Wir lösen noch einmal ein Undo aus. Jetzt ist im Debugging-Monitor nicht mehr die dritte, sonder die zweite Änderung markiert. Der Debugging-Monitor zeigt also an, auf welcher Änderung zuletzt ein Undo oder Redo durchgeführt wurde.

Unsere Änderung ist relativ einfach aufgebaut. Dass das nicht immer so ist, soll ein Beipiel aus dem UML-Klassendiagramm-Editor andeuten.

Wenn man in so einer Änderung einen Bug hat, dann ist die Suche ohne den Debugging-Monitor fast aussichtslos.


Fehler bemerken

Die Methoden Undo und Redo der Klasse TRakHistory haben einen Rückgabewert. Dieser gibt an, ob die Aktion erfolgreich ausgeführt werden konnte. Jedes einzelne Änderungsobjekt muss beim Aufruf von UndoNow und RedoNow zurückgeben, ob es seine Änderung erfolgreich rückgängig machen konnte. Wenn auch nur eines der Änderungsobjekte ein false zurückgibt, dann wird insgesamt false zurückgegeben.

Wir haben bis jetzt bei unseren Änderungsobjekten immer true zurückgegeben.

Beispiel:

function THcGridRowMoved.UndoNow: boolean;
begin
  TProtectedCustomGrid(FObj).MoveRow(FNewValue, FOldValue);

  Result := true;
end;

Deshalb hatte es auch keinen Sinn, den Rückgabewert von den Methoden Undo und Redo zu überprüfen.
Während der Entwicklung sollte man aber die Rückgabewerte der Methoden Undo und Redo auswerten und eine Fehlermeldung ausgeben, falls diese false zurück liefern. Somit fällt es schwerer einen Bug in der Undo/Redo-Implementierung zu übersehen. Bei der Version des Programmes, das ausgeliefert werden soll, sollte man diese Fehlermeldung nicht mehr ausgeben. Es gibt nämlich Fälle, dass false zurückgeliefert wird, obwohl das Undo/Redo korrekt ausgeführt wurde. Die Erläuterung eines solchen Falles erspare ich mir hier, da dessen Komplexität über den Rahmen des Tutorials hinausgehen würden.


Einen Bug grob eingrenzen

Komplexe Änderungen bestehen aus vielen Teiländerungen. Wenn bei einer komplexen Änderung z.B. ein Undo nicht funktioniert hat, dann liegt dies in der Regel daran, dass eine der Teiländerungen nicht rückgängig gemacht werden konnte. In der Regel hat man keine Ahnung welche Teiländerung den Fehler verursachte. Also wird man versuchen Schritt für Schritt durch den Code zu debuggen, bis man das Änderungsobjekt gefunden hat, das false zurückgegeben hat. Bei sehr komplexen Änderungen kann dies in eine Sisyphusarbeit ausarten. Hier werden wir aber wieder vom Debugging-Monitor unterstützt, so dass wir den Fehler deutlich schneller aufspüren können.

Damit wir das ausprobieren können, müssen wir erst künstlich einen Fehler einbauen. Dafür verändern wir die Änderungsklasse THcCustomEditText. In deren Methode SetValue gibt es den Rückgabeparameter Done. Dieser muss auf false gesetzt werden, wenn aus irgend einem Grund, die Änderung nicht durchgeführt werden konnte. Da bei uns die Änderung immer klappt, haben wir Done bisher nicht angerührt. Jetzt setzen wir ihn einfach auf false und tun einfach so, als würde es nie klappen.

procedure THcCustomEditText.SetValue(Obj: TObject; Value: string;
  var Done: boolean);
begin
  // Hier führen wir die Änderung durch
  (Obj as TCustomEdit).Text := Value;
  ShowAndFocus(TWinControl(Obj));
  Done := false;
end;

Nachdem wir das Programm übersetzt und gestartet haben, tippen wir wieder etwas in das Editfeld unter der Checkbox mit Seiteneffekt ein und markieren die Checkbox.
Im Debugging-Monitor werden wieder zwei Änderungen angezeigt.

Jetzt klicken wir noch auf den Undo-Button.

Anhand des roten Symbols können wir jetzt sehen, welche Teiländerungen nicht korrekt durchgeführt werden konnten. Somit muss man evtl. gar nicht durch den Code debuggen. Jetzt reicht es vielleicht schon den Code von THcCustomEditText genau anzusehen, damit man den Bug entdeckt.


Den Code debuggen

Auch wenn uns der Debugging-Monitor sagt, welche Teiländerung nicht korrekt durchgeführt werden konnte, kann es sein, dass wir hinein debuggen müssen, um den Bug zu finden. Außerdem gibt es ja noch die Bugs, bei denen der Debugging-Monitor nichts rotes anzeigt. Dann müssen wir auf alle Fälle durch den Code debuggen.
Das werden wir jetzt machen. In dem Zug werden wir die Klassen aus der Unit RakUndoRedo teilweise von innen kennen lernen.

Als erstes setzen wir einen Breakpoint auf den Aufruf von Undo.

Nun starten wir das Programm wieder, tippen etwas in das Editfeld unter der Checkbox mit Seiteneffekt ein und markieren die Checkbox.
Danach klicken wir noch auf den Undo-Button.

Jetzt hat unser Breakpoint zugeschlagen und wir debuggen in die Undo-Methode hinein (F7).

function TRakHistory.Undo: boolean;
var
  Cg: TRakCommandGroup;
begin
  AbortAdding;
  Stati.Add(TRakStatusUndoing);
  try
    Result := false;
    if FUndoPtr >= 0 then
    begin
      Cg := FCommandGroupCol[FUndoPtr];
      Result := Cg.Undo;
      Result := Cg.AfterDone(true) and Result;
      dec(FUndoPtr);
    end;
  finally
    Stati.Delete(TRakStatusUndoing);
  end;

  FOnAfterUndo.FireEvent(self);
end;

Die erste Zeile sorgt dafür, dass alle noch evtl. laufenden Änderungen abgeschlossen werden. Sollte gerade ein Editfeld den Fokus haben, in dem etwas geändert wurde, dann ist noch eine Gruppierung offen. Das AbortAdding sorgt dafür, dass die Gruppierung geschlossen wird. Somit würde das Undo dann gleich diese Änderung rückgängig machen. Bei uns hat aber momentan der Undo-Button den Fokus, es gibt also keinen offenen Gruppierungen.
Das Innenleben von AbortAdding interessiert uns jetzt aber nicht. Deshalb überspringen wir es (F8).
Die zweite Zeile sorgt dafür, dass das Property IsUnRedoing den richtigen Wert zurückgibt. Das interessiert uns aber auch nicht weiter.

    if FUndoPtr >= 0 then

Nur wenn es überhaupt etwas zum rückgängig machen gibt, wird etwas gemacht.

      Result := Cg.Undo;

Hier wird die Aufgabe, die komplette Änderung rückgängig zu machen, auf das Gruppierungobjekt übertragen. Das interessiert uns. Da debuggen wir hinein (F7).

function TRakCustomCommand.Undo: boolean;
var
  DoIt: boolean;
begin
{$IFDEF _CONTACT_UNDO_REDO_DEBUGGING_MONITOR_}
  if History.FMonitorID > 0 then
    PostMessage(History.FMonitorHandle, URM_UNREDOING_COMMAND, History.FMonitorID, longint(self));
{$ENDIF}

  RedoUndoSubCommand(self, false, true);

  DoIt := true;
  Result := false;
  CanUndo(DoIt, Result);

  if DoIt then
    Result := UndoNow;

{$IFDEF _CONTACT_UNDO_REDO_DEBUGGING_MONITOR_}
  if not Result and (History.FMonitorID > 0) then
    PostMessage(History.FMonitorHandle, URM_UNREDOING_FAILED, History.FMonitorID, longint(self));
{$ENDIF}

  RedoUndoSubCommand(self, false, false);
end;

Wenn das PostMessage abgeschickt wurde, dann zeigt der Debugging-Monitor an, welches Objekt gerade durchlaufen wird.
Die folgenden vier Zeilen überspringen wir einfach. Die Methoden RedoUndoSubCommand und CanUndo wären für uns nur interessant, wenn wir diese überschrieben hätten.
Uns interessiert erst wieder der Aufruf von UndoNow.

    Result := UndoNow;

Da gehen wir wieder rein.

function TRakCommandGroup.UndoNow: boolean;
var
  i: integer;
  cc: TRakCustomCommand;
begin
  Result := true;
  for i := Count - 1 downto 0 do
  begin
    cc := Items[i];
    Result := cc.Undo and Result;
  end;
end;

Hier werden alle gruppierten Teiländerungen der Reihe nach rückgängig gemacht. Man beachte, dass dies von hinten nach vorne passiert.

  for i := Count - 1 downto 0 do

Wir gehen bis zu dem Aufruf von Undo.

    Result := cc.Undo and Result;

Hier haben wir unseren ersten rekursiven Aufruf. Wir gehen wieder hinein und durchlaufen somit das zweite mal die Methode Undo.
Nachdem wieder das PostMessage abgeschickt wurde, klappt im Debugging-Monitor der zuletzt selektierte Knoten auf und es ist jetzt der Knoten selektiert, der gerade durchlaufen wird.

Wir debuggen jetzt solange weiter, bis wir in der Methode UndoNow der Klasse TRakHcString landen. Der Debugging-Monitor zeigt dann natürlich wieder an, welches Änderugsobjekt gerade durchlaufen wird.

function TRakHcString.UndoNow: boolean;
var
  Obj: TObject;
begin
  Obj := ChangedObject;
  if CheckChangedObject(Obj) then
  begin
    Result := true;
    SetValue(Obj, FOldValue, Result);
  end
  else
    Result := false;
end;

In die erste Zeile debuggen wir gleich hinein.

function THcCustomEditText.GetChangedObject: TObject;
begin
  // Hier geben wir die Referenz auf das Edit-Objekt zurück, das geändert werden soll
  Result := FEdit;
end;

Jetzt sind wir das erste mal in dem Code eines von uns geschriebenen Änderungsobjektes gelandet.
Aber zurück zu TRakHcString.UndoNow.

  if CheckChangedObject(Obj) then

Der Aufruf von CheckChangedObject soll überprüfen, ob das wirklich das richtige Objekt ist. Da wir diese Methode nicht überschrieben haben, wird nur auf ungleich nil geprüft und true zurückgegeben.

    SetValue(Obj, FOldValue, Result);

SetValue interessiert uns wieder. Da gehen wir rein.

procedure THcCustomEditText.SetValue(Obj: TObject; Value: string;
  var Done: boolean);
begin
  // Hier führen wir die Änderung durch
  (Obj as TCustomEdit).Text := Value;
  ShowAndFocus(TWinControl(Obj));
  Done := false;
end;

Jetzt sind wir wieder in einer von unseren Methoden gelandet. Hier machen wir die Änderung rückgängig und spätestens jetzt sollte uns auffallen, dass wir false zurückgeben.
Wir debuggen aber noch ein Stück weiter, damit wir noch sehen, wann der Debugging-Monitor das rote Symbol anzeigt.
Wir verlassen also SetValue und gleich darauf TRakHcString.UndoNow.
Jetzt sind wir wieder in TRakCustomCommand.Undo.
Nachdem wir die folgenden beiden Zeilen übersprungen haben, wird vom Debugging Monitor das rote Symbol angezeigt.

  if not Result and (History.FMonitorID > 0) then
    PostMessage(History.FMonitorHandle, URM_UNREDOING_FAILED, History.FMonitorID, longint(self));

Wenn Sie wollen, dann können Sie jetzt einfach weiter debuggen, um zu sehen, wann das Änderungsobjekt der Checkbox durchlaufen wird. Irgendwann landen Sie dann wieder in der Methode TRakHistory.Undo.

Bei der dann folgenden Zeile, wird noch einmal die ganze Hierarchie durchlaufen.

      Result := Cg.AfterDone(true) and Result;

Wenn Sie also irgendwann einmal eine Änderung haben, die so komplex ist, dass sie erst dann richtig abgeschlossen werden kann, wenn alle Teiländerungen bereits durchlaufen sind, dann überschreiben Sie die Methode AfterDone. Hier können Sie noch abschließende Aufräumarbeiten durchführen.

Noch tiefer steigen wir hier nicht in die Klassen der Unit RakUndoRedo ein. Das hier gezeigte sollte reichen, dass Sie jetzt problemlos selbst diesen Code durchlaufen können. Wichtig ist einfach, dass man mit Hilfe des Debugging-Monitors immer Bescheid weiß, welches Gruppierungs- bzw. Änderungsobjekt man gerade durchläuft.


Mehr Informationen anzeigen

Wie bereits gesehen, werden vom Debugging-Monitor auch Informationen zu einem Änderungsobjekt angezeigt.

Wenn man will, dann kann man sich hier mehr Informationen anzeigen lassen. Dazu muss man die Klasse erweitern, zu der man mehr Informationen anzeigen lassen will. Wir werden jetzt bei THcButtonControlChecked noch den Zeiger auf die Checkbox und den Namen des Parents anzeigen lassen.

Dazu überschreiben wir die Methode SaveAsBinaryTag.

    procedure SaveToBinaryTag(Tag: TRakBinaryTag); override;

procedure THcButtonControlChecked.SaveToBinaryTag(Tag: TRakBinaryTag);
begin
  inherited;

  TRakBinaryAnsiString.AddToTag(Tag, 'Referenz', '$' + IntToHex(integer(FObj), 8));

  if FObj.Parent = nil then
    TRakBinaryAnsiString.AddToTag(Tag, 'Parent', 'nil')
  else
    TRakBinaryAnsiString.AddToTag(Tag, 'Parent', FObj.Parent.Name);
end;

Im Debugging-Monitor sieht dass dann so aus:

Die Klassen TRakBinaryTag und TRakBinaryAnsiString stammen aus der Unit RakBinaryStreamData. Hire dürfte es sicherlich nicht nötig sein, näher auf diese Klassen einzugehen. Es dürfte klar sein, was man machen muss, wenn man noch andere Informationen im Debugging-Monitor anzeigen lassen will.
Für die Klassen aus der Unit RakBinaryStreamData wird es evtl. ab July 2002 ein eigenes Tutorial geben. Dieses wird dann u.a. auf der Internetseite http://www.kasparsoft.de zur Verfügung stehen.


Abschlußbemerkungen
Previous Top

In dem Tutorial haben wir gesehen, wie man prinzipiell an Undo/Redo herangehen kann und wie dies konkret mit Hilfe der Klassen aus der Unit RakUndoRedo umgesetzt werden kann. Es dürfte klar geworden sein, dass es sich hier nicht um ein triviales Thema handelt. Es gibt viele Abhängigkeiten und damit viele potentielle Fehlerquellen.

In dem komplexen Beispiel haben wir schon einiges kennen gelernt. Es gibt aber noch einen wichtigen Fall, den wir noch nicht angesprochen haben.

Das Erzeugen und Löschen eines Objektes

Alle Objekte, die wir verändert haben, wurden mit dem Formular erzeugt und auch mit dem Formular zerstört. Sie standen also immer zur Verfügung. Dies muss nicht immer so sein. Nehmen wir wieder das Corel Draw Beispiel vom Anfang. Dort hatten wir eine Linie. Bei dieser Linie kann man z.B. die Position und die Farbe ändern. Man kann die Linie aber auch Löschen. Nach einem Undo soll dann natürlich die Linie wieder existieren und zwar genauso wie vor dem Löschen.
Nehmen wir an, eine Linie würde von der Instanz der Klasse TLinie repräsentiert werden. Nach dem Löschen des Objektes sind alle Referenzen darauf ungültig. Bei dem Undo muss wieder eine Instanz von TLinie erzeugt und in den alten Zustand gebracht werden. Es wäre reiner Zufall, wenn die neue Instanz wieder den selben Speicher belegen würde, wie die gelöschte Instanz. Deshalb darf z.B. das Änderungsobjekt, das für die Farbänderung einer Linie zuständig ist, nicht mit einer Referenz auf die Linie arbeiten. Diese Referenz würde nach dem Undo einer Löschung ins Nirvana zeigen.

Es gibt mindestens zwei Ansätze das Problem zu lösen.

· Die Linie wird gar nicht gelöscht, sondern nur als gelöscht gekennzeichnet.
· Die Linie bekommt eine künstliche Referenz, die nach dem erneuten Erzeugen wieder herstellbar ist.

Als gelöscht gekennzeichnet

Es gibt zwei Möglichkeiten ein Objekt als gelöscht zu kennzeichnen.

Die Klasse bekommt ein entsprechendes Flag und jeglicher Code, der mit dieser Klasse arbeitet, muss dieses Flag berücksichtigen. Wenn das History-Objekt sein Änderungen wegschmeißt, dann müssen als gelöscht gekennzeichnete Objekte tatsächlich gelöscht werden, denn sonst würde es irgendwann den Speicher zerreißen.

Die andere Möglichkeit, ist dass das Objekt in den Besitz des entsprechenden Änderungsobjektes übergeht. Dann müssen alle Referenzen auf das Objekt, die nicht im Zusammenhang mit Undo/Redo stehen, sauber aufgelöst werden. Bei einem Undo müssen dann umgekehrt alle Referenzen wieder hergestellt werden und der Besitz geht wieder an das Objekt über, das es bereits vor der Löschung besessen hat.

Künstliche Referenzen

Wenn ein Objekt gelöscht und später wieder erzeugt wird, dann wäre es großer Zufall, wenn es wieder den selben Speicher belegen würde. Das wäre kein allzu großes Problem, wenn man das Objekt nachträglich an den alten Speicher verschieben könnte, denn dann wären alle alten Referenzen wieder gültig.
Deswegen sollten sich Änderungsobjekte künstliche Referenzen auf ein Objekt merken. Diese künstlichen Referenzen müssen die Eigenschaft haben, dass man sie nachträglich wieder herstellen kann. Wenn neue Referenzen benötigt werden, dann dürfen dafür also nie Referenzen benutzt werden, die schon einmal verwendet wurden.
Dieses Konzept wird bereits von TRakCommand unterstützt. Das Property ChangedObjectName speichert diese künstliche Referenz. Die wirkliche Referenz auf das Objekt erhält man mit dem Property ChangedObject.
Für die Vergabe der künstlichen Referenzen ist das globale Objekt RakGlobalObjectManager aus der Unit RakGlobalObjectID zuständig.

Beim Erzeugen von Objekten ergeben sich prinzipiell die gleichen Probleme wie beim Löschen.
Egal für welchen Weg man sich letztendlich entscheidet, man wird viel zu bedenken und zu programmieren haben, damit es fehlerfrei klappt.

Download

Ein Beispiel für die Verwendung der künstlichen Referenzen kann man sich von der Internetseite http://www.kasparsoft.de/UndoRedo/Download.htm herunter laden.
Dort steht auch das Tutorial in verschiedenen Formaten zur Verfügung. Natürlich gibt es dort auch den kompletten Sourcecode, den man benötigt, wenn man die Beispiele des Tutorials ausprobieren möchte.

Wenn Sie Fragen zum Tutorial haben, dann können Sie diese per Email an den Autor senden. (richard@kasparsoft.de)

Das war's. Jetzt bleibt mir nur noch übrig Ihnen viel Spaß bei Ihren eigenen Undo/Redo-Projekten zu wünschen.