mirror of https://github.com/buggins/dlangui.git
Undo/Redo for editors, part 1
This commit is contained in:
parent
c335a6dd5f
commit
83b1651969
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
Loading…
Reference in New Issue