Friday, November 14, 2008

Kate Internals: The Undo/Redo System

The Kate Editor Component (also called KatePart) has its own undo/redo system. It did not change much since KDE2 and basically it is very simple. Meanwhile there are classes for undo/redo support in Qt as well. In fact both systems are very similar. Let's focus on KatePart's system for now.

Text Operations

First we have to take a look at what actions need to be saved. In KatePart this basically comes down to
  • insert text or line
  • remove text or line
  • selection changes
  • (and a few others like wrapping a line)
When typing text, each keystroke inserts a character. This is exactly one undo/redo item. As example, typing a character 'x' creates a new undo item:
  • the content is 'x'
  • the type is 'insert text'
Undo in this case means 'remove x'. Redo means 'insert x (again)'. The undo/redo history is just a list (more like a stack to be precise) of simple edit actions.

KateUndo Items

In KatePart, an undo item is represented by the class KateUndo:
class KateUndo {
public:
KateUndo (KateUndoGroup::UndoType type, uint line,
uint col, uint len, const QString &text);
~KateUndo ();

bool merge(KateUndo* u);

void undo (KateDocument *doc);
void redo (KateDocument *doc);

inline KateUndoGroup::UndoType type() const;

inline uint line () const;
inline uint col () const;
inline uint len() const;

inline const QString& text() const { return m_text; }
};

Item Merging

Note the function KateUndo::merge(KateUndo* u); This functions merges two undo items of the same type if possible. For instance, typing 'hello world' inserts one undo item for every character, i.e. 11 undo items of type 'insert text'. Kate merges those 11 items into only 1 item with the string 'hello world'. Merging leads to less KateUndo items (less memory) and faster undo/redo replaying.

Item Grouping

What's still missing is the possibility to group several undo items together. Imagine you have selected the text 'hello world' and paste the text 'cheers' from the clipboard. What happens is this
  1. remove selected text
  2. insert text from clipboard
So there are two undo items of different type. They cannot be merged into only one KateUndo item. Though, we want to support undoing both items in one go, that's why we add several undo items into undo groups. In KatePart, this is done by the class KateUndoGroup:
class KateUndoGroup
{
public:
explicit KateUndoGroup (KateDocument *doc);
~KateUndoGroup ();

void undo ();
void redo ();

enum UndoType { ... };

void addItem (KateUndoGroup::UndoType type, uint line, uint col, uint len, const QString &text);

void setUndoSelection (const KTextEditor::Range &selection);
void setRedoSelection (const KTextEditor::Range &selection);

void setUndoCursor(const KTextEditor::Cursor &cursor);
void setRedoCursor(const KTextEditor::Cursor &cursor);

bool merge(KateUndoGroup* newGroup,bool complex);

void safePoint (bool safePoint=true);
};
Every KateUndo item belongs to one KateUndoGroup. A KateUndoGroup can have an arbitrary count of KateUndo items. In the example above we want to group 'remove selected text' and 'insert text' together. Grouping can be explicitely done in the code as follows (simplified version):
void KateDocument::paste ( KateView* view, QClipboard::Mode mode )
{
QString s = QApplication::clipboard()->text(mode);

editStart();
view->removeSelectedText();
insertText(pos, s, view->blockSelectionMode());
editEnd();
}

Grouping: editStart()/editEnd()

The call of editStart() tells the document that an edit operation is running. All text operations are added to the current KateUndoGroup, until editEnd() is called. editStart() and editEnd() do reference counting, i.e. editStart() can be called nested as long as for each call of editStart() there is (finally) a call of editEnd().

Grouping: Cursors and Selections

Undoing the paste-action above should restore the selection if there was one previously. Redo (i.e. paste again) should remove the selection again. So there are two different types of selections: one before the undo group, and one after. That's why each undo group has the functions setUndoSelection() and setRedoSelection(). The same applies for the cursor position: We have to store two different cursor positions, one for undo and one for redo.
For instance, imagine we removed the text 'world'. Undo (i.e. insert 'hello') should set the cursor position to the end of 'hello'. Redo (i.e. remove 'hello') should set the cursor position to the start of it.

Luckily a programmer does not have to set the undo/redo cursor positions and text selections manually. undoStart() is called the first time editStart() is called. The closing editEnd() finally calls undoEnd(). So undoStart() sets the undo cursor position and undo text selection, while undoEnd() sets the redo cursor position and redo text selection.

Group Merging

The careful reader might have noticed KateUndoGroup::merge(). So merging of two groups is also supported. Whether text operations should be merged into an existing undo group can be controlled with KateDocument::setUndoDontMerge(). Pasting text for example set's this flag.

Undo and Redo

Every document in KatePart has two lists: An undo list, and a redo list. Suppose we have 10 KateUndoGroups in the undo list and the user invokes undo 4 times. Then the undo list only contains 6 items and the redo list 4. Now it is also possible to redo. However, typing text clears the redo list.

Document Modified Flag

KateDocument::updateModifed() is called to update the modified flag of a file. This update also relies on the current active undo group. Saving the file saves a pointer to the current undo group, and later we simply can check whether the current undo group is the active one. Pretty simple mechanism. However, there right now seems to be a bug you can reproduce as follows:
  1. save doc [not modified]
  2. type a character [modified]
  3. undo [not modified]
  4. type a character [modified]
  5. undo [still modified]
Step 5 is incorrect, as the document is not modified here. Maybe now you have enough knowledge to fix this. Any takers? :)

6 comments:

Erlend said...

Excellent explanation, Dominik!
Much appreciated! :-)

Leo S said...

What's the bug #?

dhaumann said...

after step 5 it should be unmodified. This bug is not reported in KDE's bug tracker as far as I know.

remur said...

Sorry for my ignorance, I just read parts of it but we use something similiar in an editor of our own, why don't you just check the size of the undostack, if it is zero then the document is unmodified...
Propably you can do this at the same place where you grey out the undo option when the stack is empty

mathstuf said...

@remur: you can save in the middle of an undo stack making (for example) the 5th item clean and undoing it makes it dirty again.

Why not store a flag with each undo state as to whether that state is clean or dirty? when the user saves, clear the old clean state and set on the current undo state (I guess it would sit between states though or mark whether the document is clean or dirty when it sits on top of the undo stack).

Thoughts?

dhaumann said...

@mathstuf: Yes, I think that should be possible, very simple way to do it. Would have to take a closer look, let's see whether I'll find the time. ...or maybe someone else will fix it.