Undo/Redo for editors, part 1

This commit is contained in:
Vadim Lopatin 2014-04-23 13:55:17 +04:00
parent c335a6dd5f
commit 83b1651969
2 changed files with 210 additions and 6 deletions

View File

@ -44,6 +44,8 @@ import std.algorithm;
struct Collection(T) { struct Collection(T) {
private T[] _items; private T[] _items;
private size_t _len; private size_t _len;
/// returns true if there are no items in collection
@property bool empty() { return _len == 0; }
/// returns number of items in collection /// returns number of items in collection
@property size_t length() { return _len; } @property size_t length() { return _len; }
/// returns currently allocated capacity (may be more than length) /// returns currently allocated capacity (may be more than length)
@ -151,6 +153,48 @@ struct Collection(T) {
_len = 0; _len = 0;
_items = null; _items = null;
} }
//===================================
// stack/queue-like ops
/// remove first item
@property T popFront() {
if (empty)
return T.init; // no items
return remove(0);
}
/// insert item at beginning of collection
void pushFront(T item) {
add(item, 0);
}
/// remove last item
@property T popBack() {
if (empty)
return T.init; // no items
return remove(length - 1);
}
/// insert item at end of collection
void pushBack(T item) {
add(item);
}
/// peek first item
@property T front() {
if (empty)
return T.init; // no items
return _items[0];
}
/// peek last item
@property T back() {
if (empty)
return T.init; // no items
return _items[_len - 1];
}
~this() { ~this() {
clear(); clear();
} }

View File

@ -23,6 +23,7 @@ module dlangui.widgets.editors;
import dlangui.widgets.widget; import dlangui.widgets.widget;
import dlangui.widgets.controls; import dlangui.widgets.controls;
import dlangui.core.signals; import dlangui.core.signals;
import dlangui.core.collections;
import dlangui.platforms.common.platform; import dlangui.platforms.common.platform;
import std.algorithm; import std.algorithm;
@ -135,16 +136,28 @@ enum EditAction {
/// edit operation details for EditableContent /// edit operation details for EditableContent
class EditOperation { class EditOperation {
/// action performed
protected EditAction _action; protected EditAction _action;
/// action performed
@property EditAction action() { return _action; } @property EditAction action() { return _action; }
/// range
protected TextRange _range; protected TextRange _range;
/// source range to replace with new content
@property ref TextRange range() { return _range; } @property ref TextRange range() { return _range; }
protected TextRange _newRange;
/// new range after operation applied
@property ref TextRange newRange() { return _newRange; }
@property void newRange(TextRange range) { _newRange = range; }
/// new content for range (if required for this action) /// new content for range (if required for this action)
protected dstring[] _content; protected dstring[] _content;
@property ref dstring[] content() { return _content; } @property ref dstring[] content() { return _content; }
/// old content for range
protected dstring[] _oldContent;
@property ref dstring[] oldContent() { return _oldContent; }
@property void oldContent(dstring[] content) { _oldContent = content; }
this(EditAction action) { this(EditAction action) {
_action = action; _action = action;
} }
@ -162,21 +175,90 @@ class EditOperation {
_range = range; _range = range;
_content = text; _content = text;
} }
/// try to merge two operations (simple entering of characters in the same line), return true if succeded
bool merge(EditOperation op) {
if (!_range.empty || !op._range.empty)
return false; // only merge simple single character append operations
if (_content.length != 1 || op._content.length != 1 || op._content[0].length != 1)
return false;
if (_range.start.line != op._range.start.line) // both ops whould be on the same line
return false;
if (_range.start.pos + _content[0].length != op._range.start.pos)
return false;
_content[0] ~= op._content[0];
return true;
}
}
/// Undo/Redo buffer
class UndoBuffer {
protected Collection!EditOperation _undoList;
protected Collection!EditOperation _redoList;
/// returns true if buffer contains any undo items
@property bool hasUndo() {
return !_undoList.empty;
}
/// returns true if buffer contains any redo items
@property bool hasRedo() {
return !_redoList.empty;
}
/// adds undo operation
void saveForUndo(EditOperation op) {
if (!_undoList.empty) {
if (_undoList.back.merge(op)) {
_redoList.clear();
return; // merged - no need to add new operation
}
}
_undoList.pushBack(op);
}
/// returns operation to be undone (put it to redo), null if no undo ops available
EditOperation undo() {
if (!hasUndo)
return null; // no undo operations
EditOperation res = _undoList.popBack();
_redoList.pushBack(res);
return res;
}
/// returns operation to be redone (put it to undo), null if no undo ops available
EditOperation redo() {
if (!hasRedo)
return null; // no undo operations
EditOperation res = _redoList.popBack();
_undoList.pushBack(res);
return res;
}
/// clears both undo and redo buffers
void clear() {
_undoList.clear();
_redoList.clear();
}
} }
interface EditableContentListener { interface EditableContentListener {
bool onContentChange(EditableContent content, EditOperation operation, ref TextRange rangeBefore, ref TextRange rangeAfter); bool onContentChange(EditableContent content, EditOperation operation, ref TextRange rangeBefore, ref TextRange rangeAfter);
} }
/// editable plain text (multiline) /// editable plain text (singleline/multiline)
class EditableContent { class EditableContent {
/// listeners for edit operations
Signal!EditableContentListener contentChangeListeners;
this(bool multiline) { this(bool multiline) {
_multiline = multiline; _multiline = multiline;
_lines.length = 1; // initial state: single empty line _lines.length = 1; // initial state: single empty line
_undoBuffer = new UndoBuffer();
} }
protected UndoBuffer _undoBuffer;
/// listeners for edit operations
Signal!EditableContentListener contentChangeListeners;
protected bool _multiline; protected bool _multiline;
/// returns true if miltyline content is supported /// returns true if miltyline content is supported
@property bool multiline() { return _multiline; } @property bool multiline() { return _multiline; }
@ -325,11 +407,51 @@ class EditableContent {
// same line // same line
rangeAfter.end.pos = rangeAfter.start.pos + cast(int)newcontent[0].length; rangeAfter.end.pos = rangeAfter.start.pos + cast(int)newcontent[0].length;
} }
op.newRange = rangeAfter;
op.oldContent = oldcontent;
replaceRange(rangeBefore, rangeAfter, newcontent);
handleContentChange(op, rangeBefore, rangeAfter);
_undoBuffer.saveForUndo(op);
return true;
}
return false;
}
/// return true if there is at least one operation in undo buffer
@property bool hasUndo() {
return _undoBuffer.hasUndo;
}
/// return true if there is at least one operation in redo buffer
@property bool hasRedo() {
return _undoBuffer.hasRedo;
}
/// undoes last change
bool undo() {
if (!hasUndo)
return false;
EditOperation op = _undoBuffer.undo();
TextRange rangeBefore = op.newRange;
dstring[] oldcontent = op.content;
dstring[] newcontent = op.oldContent;
TextRange rangeAfter = op.range;
Log.d("Undoing op rangeBefore=", rangeBefore, " contentBefore=`", oldcontent, "` rangeAfter=", rangeAfter, " contentAfter=`", newcontent, "`");
replaceRange(rangeBefore, rangeAfter, newcontent); replaceRange(rangeBefore, rangeAfter, newcontent);
handleContentChange(op, rangeBefore, rangeAfter); handleContentChange(op, rangeBefore, rangeAfter);
return true; return true;
} }
/// redoes last undone change
bool redo() {
if (!hasUndo)
return false; return false;
EditOperation op = _undoBuffer.redo();
TextRange rangeBefore = op.range;
dstring[] oldcontent = op.oldContent;
dstring[] newcontent = op.content;
TextRange rangeAfter = op.newRange;
Log.d("Redoing op rangeBefore=", rangeBefore, " contentBefore=`", oldcontent, "` rangeAfter=", rangeAfter, " contentAfter=`", newcontent, "`");
replaceRange(rangeBefore, rangeAfter, newcontent);
handleContentChange(op, rangeBefore, rangeAfter);
return true;
} }
} }
@ -400,14 +522,22 @@ enum EditorActions {
DelPrevWord, DelPrevWord,
/// delete char after cursor (ctrl + del key) /// delete char after cursor (ctrl + del key)
DelNextWord, DelNextWord,
/// insert new line (Enter) /// insert new line (Enter)
InsertNewLine, InsertNewLine,
/// insert new line after current position (Ctrl+Enter)
PrependNewLine,
/// Copy selection to clipboard /// Copy selection to clipboard
Copy, Copy,
/// Cut selection to clipboard /// Cut selection to clipboard
Cut, Cut,
/// Paste selection from clipboard /// Paste selection from clipboard
Paste, Paste,
/// Undo last change
Undo,
/// Redo last undoed change
Redo,
} }
/// base for all editor widgets /// base for all editor widgets
@ -455,19 +585,31 @@ class EditWidgetBase : WidgetGroup, EditableContentListener {
new Action(EditorActions.SelectDocumentEnd, KeyCode.END, KeyFlag.Control | KeyFlag.Shift), new Action(EditorActions.SelectDocumentEnd, KeyCode.END, KeyFlag.Control | KeyFlag.Shift),
new Action(EditorActions.InsertNewLine, KeyCode.RETURN, 0), new Action(EditorActions.InsertNewLine, KeyCode.RETURN, 0),
new Action(EditorActions.InsertNewLine, KeyCode.RETURN, KeyFlag.Shift),
new Action(EditorActions.PrependNewLine, KeyCode.RETURN, KeyFlag.Control),
// Backspace/Del
new Action(EditorActions.DelPrevChar, KeyCode.BACK, 0), new Action(EditorActions.DelPrevChar, KeyCode.BACK, 0),
new Action(EditorActions.DelNextChar, KeyCode.DEL, 0), new Action(EditorActions.DelNextChar, KeyCode.DEL, 0),
new Action(EditorActions.DelPrevWord, KeyCode.BACK, KeyFlag.Control), new Action(EditorActions.DelPrevWord, KeyCode.BACK, KeyFlag.Control),
new Action(EditorActions.DelNextWord, KeyCode.DEL, KeyFlag.Control), new Action(EditorActions.DelNextWord, KeyCode.DEL, KeyFlag.Control),
// Copy/Paste
new Action(EditorActions.Copy, KeyCode.KEY_C, KeyFlag.Control), new Action(EditorActions.Copy, KeyCode.KEY_C, KeyFlag.Control),
new Action(EditorActions.Copy, KeyCode.KEY_C, KeyFlag.Control|KeyFlag.Shift),
new Action(EditorActions.Copy, KeyCode.INS, KeyFlag.Control), new Action(EditorActions.Copy, KeyCode.INS, KeyFlag.Control),
new Action(EditorActions.Cut, KeyCode.KEY_X, KeyFlag.Control), new Action(EditorActions.Cut, KeyCode.KEY_X, KeyFlag.Control),
new Action(EditorActions.Cut, KeyCode.KEY_X, KeyFlag.Control|KeyFlag.Shift),
new Action(EditorActions.Cut, KeyCode.DEL, KeyFlag.Shift), new Action(EditorActions.Cut, KeyCode.DEL, KeyFlag.Shift),
new Action(EditorActions.Paste, KeyCode.KEY_V, KeyFlag.Control), new Action(EditorActions.Paste, KeyCode.KEY_V, KeyFlag.Control),
new Action(EditorActions.Paste, KeyCode.KEY_V, KeyFlag.Control|KeyFlag.Shift),
new Action(EditorActions.Paste, KeyCode.INS, KeyFlag.Shift), new Action(EditorActions.Paste, KeyCode.INS, KeyFlag.Shift),
// Undo/Redo
new Action(EditorActions.Undo, KeyCode.KEY_Z, KeyFlag.Control),
new Action(EditorActions.Redo, KeyCode.KEY_Y, KeyFlag.Control),
new Action(EditorActions.Redo, KeyCode.KEY_Z, KeyFlag.Control|KeyFlag.Shift),
]); ]);
} }
@ -742,6 +884,16 @@ class EditWidgetBase : WidgetGroup, EditableContentListener {
EditOperation op = new EditOperation(EditAction.Replace, _selectionRange, lines); EditOperation op = new EditOperation(EditAction.Replace, _selectionRange, lines);
_content.performOperation(op); _content.performOperation(op);
} }
return true;
case EditorActions.Undo:
{
_content.undo();
}
return true;
case EditorActions.Redo:
{
_content.redo();
}
return true; return true;
default: default:
break; break;
@ -1142,6 +1294,14 @@ class EditBox : EditWidgetBase, OnScrollHandler {
TextPosition oldCaretPos = _caretPos; TextPosition oldCaretPos = _caretPos;
dstring currentLine = _content[_caretPos.line]; dstring currentLine = _content[_caretPos.line];
switch (a.id) { switch (a.id) {
case EditorActions.PrependNewLine:
{
correctCaretPos();
_caretPos.pos = 0;
EditOperation op = new EditOperation(EditAction.Replace, _selectionRange, [""d, ""d]);
_content.performOperation(op);
}
return true;
case EditorActions.InsertNewLine: case EditorActions.InsertNewLine:
{ {
correctCaretPos(); correctCaretPos();