diff --git a/dlanguilib.visualdproj b/dlanguilib.visualdproj index 128d1fb1..9cc07065 100644 --- a/dlanguilib.visualdproj +++ b/dlanguilib.visualdproj @@ -89,7 +89,6 @@ $(OutDir)\$(ProjectName).lib 1 - 2 @@ -184,7 +183,6 @@ $(OutDir)\$(ProjectName).lib 1 - 1 @@ -337,6 +335,7 @@ + diff --git a/src/dlangui/core/editable.d b/src/dlangui/core/editable.d new file mode 100644 index 00000000..a5f6fb29 --- /dev/null +++ b/src/dlangui/core/editable.d @@ -0,0 +1,978 @@ +// Written in the D programming language. + +/** +This module contains implementation of editable text content. + + +Synopsis: + +---- +import dlangui.core.editable; + +---- + +Copyright: Vadim Lopatin, 2014 +License: Boost License 1.0 +Authors: Vadim Lopatin, coolreader.org@gmail.com +*/ +module dlangui.core.editable; + +import dlangui.core.logger; +import dlangui.core.signals; +import dlangui.core.collections; +import dlangui.core.linestream; +import std.algorithm; +import std.stream; + + +immutable dchar EOL = '\n'; + +const ubyte TOKEN_CATEGORY_SHIFT = 4; +const ubyte TOKEN_CATEGORY_MASK = 0xF0; // token category 0..15 +const ubyte TOKEN_SUBCATEGORY_MASK = 0x0F; // token subcategory 0..15 +const ubyte TOKEN_UNKNOWN = 0; + +/* +Bit mask: +7654 3210 +cccc ssss +| | +| \ ssss = token subcategory +| +\ cccc = token category + +*/ +/// token category for syntax highlight +enum TokenCategory : ubyte { + WhiteSpace = (0 << TOKEN_CATEGORY_SHIFT), + WhiteSpace_Space = (0 << TOKEN_CATEGORY_SHIFT) | 1, + WhiteSpace_Tab = (0 << TOKEN_CATEGORY_SHIFT) | 2, + + Comment = (1 << TOKEN_CATEGORY_SHIFT), + Comment_SingleLine = (1 << TOKEN_CATEGORY_SHIFT) | 1, // single line comment + Comment_SingleLineDoc = (1 << TOKEN_CATEGORY_SHIFT) | 2,// documentation in single line comment + Comment_MultyLine = (1 << TOKEN_CATEGORY_SHIFT) | 3, // multiline coment + Comment_MultyLineDoc = (1 << TOKEN_CATEGORY_SHIFT) | 4, // documentation in multiline comment + Comment_Documentation = (1 << TOKEN_CATEGORY_SHIFT) | 5,// documentation comment + + Identifier = (2 << TOKEN_CATEGORY_SHIFT), // identifier (exact subcategory is unknown) + Identifier_Class = (2 << TOKEN_CATEGORY_SHIFT) | 1, // class name + Identifier_Struct = (2 << TOKEN_CATEGORY_SHIFT) | 2, // struct name + Identifier_Local = (2 << TOKEN_CATEGORY_SHIFT) | 3, // local variable + Identifier_Member = (2 << TOKEN_CATEGORY_SHIFT) | 4, // struct or class member + Identifier_Deprecated = (2 << TOKEN_CATEGORY_SHIFT) | 15, // usage of this identifier is deprecated + /// string literal + String = (3 << TOKEN_CATEGORY_SHIFT), + /// character literal + Character = (4 << TOKEN_CATEGORY_SHIFT), + /// integer literal + Integer = (5 << TOKEN_CATEGORY_SHIFT), + /// floating point number literal + Float = (6 << TOKEN_CATEGORY_SHIFT), + /// keyword + Keyword = (7 << TOKEN_CATEGORY_SHIFT), + /// operator + Op = (8 << TOKEN_CATEGORY_SHIFT), + // add more here + //.... + /// error - unparsed character sequence + Error = (15 << TOKEN_CATEGORY_SHIFT), + /// invalid token - generic + Error_InvalidToken = (15 << TOKEN_CATEGORY_SHIFT) | 1, + /// invalid number token - error occured while parsing number + Error_InvalidNumber = (15 << TOKEN_CATEGORY_SHIFT) | 2, + /// invalid string token - error occured while parsing string + Error_InvalidString = (15 << TOKEN_CATEGORY_SHIFT) | 3, + /// invalid identifier token - error occured while parsing identifier + Error_InvalidIdentifier = (15 << TOKEN_CATEGORY_SHIFT) | 4, + /// invalid comment token - error occured while parsing comment + Error_InvalidComment = (15 << TOKEN_CATEGORY_SHIFT) | 4, +} + + +/// split dstring by delimiters +dstring[] splitDString(dstring source, dchar delimiter = EOL) { + int start = 0; + dstring[] res; + dchar lastchar; + for (int i = 0; i <= source.length; i++) { + if (i == source.length || source[i] == delimiter) { + if (i >= start) { + dchar prevchar = i > 1 && i > start + 1 ? source[i - 1] : 0; + int end = i; + if (delimiter == EOL && prevchar == '\r') // windows CR/LF + end--; + dstring line = i > start ? cast(dstring)(source[start .. end].dup) : ""d; + res ~= line; + } + start = i + 1; + } + } + return res; +} + +version (Windows) { + immutable dstring SYSTEM_DEFAULT_EOL = "\r\n"; +} else { + immutable dstring SYSTEM_DEFAULT_EOL = "\n"; +} + +/// concat strings from array using delimiter +dstring concatDStrings(dstring[] lines, dstring delimiter = SYSTEM_DEFAULT_EOL) { + dchar[] buf; + foreach(line; lines) { + if (buf.length) + buf ~= delimiter; + buf ~= line; + } + return cast(dstring)buf; +} + +/// replace end of lines with spaces +dstring replaceEolsWithSpaces(dstring source) { + dchar[] buf; + dchar lastch; + foreach(ch; source) { + if (ch == '\r') { + buf ~= ' '; + } else if (ch == '\n') { + if (lastch != '\r') + buf ~= ' '; + } else { + buf ~= ch; + } + lastch = ch; + } + return cast(dstring)buf; +} + +/// text content position +struct TextPosition { + /// line number, zero based + int line; + /// character position in line (0 == before first character) + int pos; + /// compares two positions + int opCmp(ref const TextPosition v) const { + if (line < v.line) + return -1; + if (line > v.line) + return 1; + if (pos < v.pos) + return -1; + if (pos > v.pos) + return 1; + return 0; + } +} + +/// text content range +struct TextRange { + TextPosition start; + TextPosition end; + /// returns true if range is empty + @property bool empty() const { + return end <= start; + } + /// returns true if start and end located at the same line + @property bool singleLine() const { + return end.line == start.line; + } + /// returns count of lines in range + @property int lines() const { + return end.line - start.line + 1; + } +} + +/// action performed with editable contents +enum EditAction { + /// insert content into specified position (range.start) + //Insert, + /// delete content in range + //Delete, + /// replace range content with new content + Replace, + + /// replace whole content + ReplaceContent, + /// saved content + SaveContent, +} + +/// edit operation details for EditableContent +class EditOperation { + protected EditAction _action; + /// action performed + @property EditAction action() { return _action; } + protected TextRange _range; + + /// source range to replace with new content + @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) + protected dstring[] _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) { + _action = action; + } + this(EditAction action, TextPosition pos, dstring text) { + this(action, TextRange(pos, pos), text); + } + this(EditAction action, TextRange range, dstring text) { + _action = action; + _range = range; + _content.length = 1; + _content[0] = text.dup; + } + this(EditAction action, TextRange range, dstring[] text) { + _action = action; + _range = range; + _content.length = text.length; + for(int i = 0; i < text.length; i++) + _content[i] = text[i].dup; + //_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.start.line != op._range.start.line) // both ops whould be on the same line + return false; + if (_content.length != 1 || op._content.length != 1) // both ops should operate the same line + return false; + // appending of single character + if (_range.empty && op._range.empty && op._content[0].length == 1 && _newRange.end.pos == op._range.start.pos) { + _content[0] ~= op._content[0]; + _newRange.end.pos++; + return true; + } + // removing single character + if (_newRange.empty && op._newRange.empty && op._oldContent[0].length == 1) { + if (_newRange.end.pos == op.range.end.pos) { + // removed char before + _range.start.pos--; + _newRange.start.pos--; + _newRange.end.pos--; + _oldContent[0] = (op._oldContent[0].dup ~ _oldContent[0].dup).dup; + return true; + } else if (_newRange.end.pos == op._range.start.pos) { + // removed char after + _range.end.pos++; + _oldContent[0] = (_oldContent[0].dup ~ op._oldContent[0].dup).dup; + return true; + } + } + return false; + } +} + +/// 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) { + _redoList.clear(); + if (!_undoList.empty) { + if (_undoList.back.merge(op)) { + 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(); + _savedState = null; + } + + protected EditOperation _savedState; + + /// current state is saved + void saved() { + _savedState = _undoList.peekBack; + } + /// returns true if content has been changed since last saved() or clear() call + @property bool modified() { + return _savedState !is _undoList.peekBack; + } +} + +/// Editable Content change listener +interface EditableContentListener { + void onContentChange(EditableContent content, EditOperation operation, ref TextRange rangeBefore, ref TextRange rangeAfter, Object source); +} + +alias TokenPropString = ubyte[]; + +/// interface for custom syntax highlight +interface SyntaxHighlighter { + /// categorize characters in content by token types + void updateHighlight(dstring[] lines, TokenPropString[] props, int changeStartLine, int changeEndLine); +} + +/// editable plain text (singleline/multiline) +class EditableContent { + + this(bool multiline) { + _multiline = multiline; + _lines.length = 1; // initial state: single empty line + _undoBuffer = new UndoBuffer(); + } + + @property bool modified() { + return _undoBuffer.modified; + } + + protected UndoBuffer _undoBuffer; + + protected SyntaxHighlighter _syntaxHighlighter; + + @property SyntaxHighlighter syntaxHighlighter() { + return _syntaxHighlighter; + } + + @property EditableContent syntaxHighlighter(SyntaxHighlighter syntaxHighlighter) { + _syntaxHighlighter = syntaxHighlighter; + updateTokenProps(0, cast(int)_lines.length); + return this; + } + + /// returns true if content has syntax highlight handler set + @property bool hasSyntaxHighlight() { + return _syntaxHighlighter !is null; + } + + protected bool _readOnly; + + @property bool readOnly() { + return _readOnly; + } + + @property void readOnly(bool readOnly) { + _readOnly = readOnly; + } + + /// listeners for edit operations + Signal!EditableContentListener contentChangeListeners; + + protected bool _multiline; + /// returns true if miltyline content is supported + @property bool multiline() { return _multiline; } + + protected dstring[] _lines; + protected TokenPropString[] _tokenProps; + + /// returns all lines concatenated delimited by '\n' + @property dstring text() { + if (_lines.length == 0) + return ""; + if (_lines.length == 1) + return _lines[0]; + // concat lines + dchar[] buf; + foreach(item;_lines) { + if (buf.length) + buf ~= EOL; + buf ~= item; + } + return cast(dstring)buf; + } + + /// append one or more lines at end + void appendLines(dstring[] lines...) { + TextRange rangeBefore; + rangeBefore.start = rangeBefore.end = lineEnd(_lines.length ? cast(int)_lines.length - 1 : 0); + EditOperation op = new EditOperation(EditAction.Replace, rangeBefore, lines); + performOperation(op, this); + } + + /// call listener to say that whole content is replaced e.g. by loading from file + void notifyContentReplaced() { + TextRange rangeBefore; + TextRange rangeAfter; + // notify about content change + handleContentChange(new EditOperation(EditAction.ReplaceContent), rangeBefore, rangeAfter, this); + } + + /// call listener to say that content is saved + void notifyContentSaved() { + TextRange rangeBefore; + TextRange rangeAfter; + // notify about content change + handleContentChange(new EditOperation(EditAction.SaveContent), rangeBefore, rangeAfter, this); + } + + protected void updateTokenProps(int startLine, int endLine) { + clearTokenProps(startLine, endLine); + if (_syntaxHighlighter) { + _syntaxHighlighter.updateHighlight(_lines, _tokenProps, startLine, endLine); + } + } + + /// set props arrays size equal to text line sizes, bit fill with unknown token + protected void clearTokenProps(int startLine, int endLine) { + for (int i = startLine; i < endLine; i++) { + if (hasSyntaxHighlight) { + int len = cast(int)_lines[i].length; + _tokenProps[i].length = len; + for (int j = 0; j < len; j++) + _tokenProps[i][j] = TOKEN_UNKNOWN; + } else { + _tokenProps[i] = null; // no token props + } + } + } + + /// replace whole text with another content + @property EditableContent text(dstring newContent) { + clearUndo(); + _lines.length = 0; + if (_multiline) { + _lines = splitDString(newContent); + _tokenProps.length = _lines.length; + updateTokenProps(0, cast(int)_lines.length); + } else { + _lines.length = 1; + _lines[0] = replaceEolsWithSpaces(newContent); + _tokenProps.length = 1; + updateTokenProps(0, cast(int)_lines.length); + } + notifyContentReplaced(); + return this; + } + + /// clear content + void clear() { + clearUndo(); + _lines.length = 0; + } + + + /// returns line text + @property int length() { return cast(int)_lines.length; } + dstring opIndex(int index) { + return line(index); + } + + /// returns line text by index, "" if index is out of bounds + dstring line(int index) { + return index >= 0 && index < _lines.length ? _lines[index] : ""d; + } + + /// returns line token properties one item per character + TokenPropString lineTokenProps(int index) { + return index >= 0 && index < _tokenProps.length ? _tokenProps[index] : null; + } + + /// returns text position for end of line lineIndex + TextPosition lineEnd(int lineIndex) { + return TextPosition(lineIndex, lineLength(lineIndex)); + } + + /// returns position before first non-space character of line, returns 0 position if no non-space chars + TextPosition firstNonSpace(int lineIndex) { + dstring s = line(lineIndex); + for (int i = 0; i < s.length; i++) + if (s[i] != ' ' && s[i] != '\t') + return TextPosition(lineIndex, i); + return TextPosition(lineIndex, 0); + } + + /// returns position after last non-space character of line, returns 0 position if no non-space chars on line + TextPosition lastNonSpace(int lineIndex) { + dstring s = line(lineIndex); + for (int i = cast(int)s.length - 1; i >= 0; i--) + if (s[i] != ' ' && s[i] != '\t') + return TextPosition(lineIndex, i + 1); + return TextPosition(lineIndex, 0); + } + + /// returns text position for end of line lineIndex + int lineLength(int lineIndex) { + return lineIndex >= 0 && lineIndex < _lines.length ? cast(int)_lines[lineIndex].length : 0; + } + + /// returns maximum length of line + int maxLineLength() { + int m = 0; + foreach(s; _lines) + if (m < s.length) + m = cast(int)s.length; + return m; + } + + void handleContentChange(EditOperation op, ref TextRange rangeBefore, ref TextRange rangeAfter, Object source) { + // update highlight if necessary + updateTokenProps(rangeAfter.start.line, rangeAfter.end.line + 1); + // call listeners + if (contentChangeListeners.assigned) + contentChangeListeners(this, op, rangeBefore, rangeAfter, source); + } + + /// return text for specified range + dstring[] rangeText(TextRange range) { + dstring[] res; + if (range.empty) { + res ~= ""d; + return res; + } + for (int lineIndex = range.start.line; lineIndex <= range.end.line; lineIndex++) { + dstring lineText = line(lineIndex); + dstring lineFragment = lineText; + int startchar = 0; + int endchar = cast(int)lineText.length; + if (lineIndex == range.start.line) + startchar = range.start.pos; + if (lineIndex == range.end.line) + endchar = range.end.pos; + if (endchar > lineText.length) + endchar = cast(int)lineText.length; + if (endchar <= startchar) + lineFragment = ""d; + else if (startchar != 0 || endchar != lineText.length) + lineFragment = lineText[startchar .. endchar].dup; + res ~= lineFragment; + } + return res; + } + + /// when position is out of content bounds, fix it to nearest valid position + void correctPosition(ref TextPosition position) { + if (position.line >= length) { + position.line = length - 1; + position.pos = lineLength(position.line); + } + if (position.line < 0) { + position.line = 0; + position.pos = 0; + } + int currentLineLength = lineLength(position.line); + if (position.pos > currentLineLength) + position.pos = currentLineLength; + if (position.pos < 0) + position.pos = 0; + } + + /// when range positions is out of content bounds, fix it to nearest valid position + void correctRange(ref TextRange range) { + correctPosition(range.start); + correctPosition(range.end); + } + + /// removes removedCount lines starting from start + protected void removeLines(int start, int removedCount) { + int end = start + removedCount; + assert(removedCount > 0 && start >= 0 && end > 0 && start < _lines.length && end <= _lines.length); + for (int i = start; i < _lines.length - removedCount; i++) { + _lines[i] = _lines[i + removedCount]; + _tokenProps[i] = _tokenProps[i + removedCount]; + } + for (int i = cast(int)_lines.length - removedCount; i < _lines.length; i++) { + _lines[i] = null; // free unused line references + _tokenProps[i] = null; // free unused line references + } + _lines.length -= removedCount; + _tokenProps.length = _lines.length; + } + + /// inserts count empty lines at specified position + protected void insertLines(int start, int count) { + assert(count > 0); + _lines.length += count; + _tokenProps.length = _lines.length; + for (int i = cast(int)_lines.length - 1; i >= start + count; i--) { + _lines[i] = _lines[i - count]; + _tokenProps[i] = _tokenProps[i - count]; + } + for (int i = start; i < start + count; i++) { + _lines[i] = ""d; + _tokenProps[i] = null; + } + } + + /// inserts or removes lines, removes text in range + protected void replaceRange(TextRange before, TextRange after, dstring[] newContent) { + dstring firstLineBefore = line(before.start.line); + dstring lastLineBefore = before.singleLine ? firstLineBefore : line(before.end.line); + dstring firstLineHead = before.start.pos > 0 && before.start.pos <= firstLineBefore.length ? firstLineBefore[0..before.start.pos] : ""d; + dstring lastLineTail = before.end.pos >= 0 && before.end.pos < lastLineBefore.length ? lastLineBefore[before.end.pos .. $] : ""d; + + int linesBefore = before.lines; + int linesAfter = after.lines; + if (linesBefore < linesAfter) { + // add more lines + insertLines(before.start.line + 1, linesAfter - linesBefore); + } else if (linesBefore > linesAfter) { + // remove extra lines + removeLines(before.start.line + 1, linesBefore - linesAfter); + } + for (int i = after.start.line; i <= after.end.line; i++) { + dstring newline = newContent[i - after.start.line]; + if (i == after.start.line && i == after.end.line) { + dchar[] buf; + buf ~= firstLineHead; + buf ~= newline; + buf ~= lastLineTail; + //Log.d("merging lines ", firstLineHead, " ", newline, " ", lastLineTail); + _lines[i] = cast(dstring)buf; + clearTokenProps(i, i + 1); + //Log.d("merge result: ", _lines[i]); + } else if (i == after.start.line) { + dchar[] buf; + buf ~= firstLineHead; + buf ~= newline; + _lines[i] = cast(dstring)buf; + clearTokenProps(i, i + 1); + } else if (i == after.end.line) { + dchar[] buf; + buf ~= newline; + buf ~= lastLineTail; + _lines[i] = cast(dstring)buf; + clearTokenProps(i, i + 1); + } else { + _lines[i] = newline; // no dup needed + clearTokenProps(i, i + 1); + } + } + } + + static bool isDigit(dchar ch) pure nothrow { + return ch >= '0' && ch <= '9'; + } + static bool isAlpha(dchar ch) pure nothrow { + return isLowerAlpha(ch) || isUpperAlpha(ch); + } + static bool isAlNum(dchar ch) pure nothrow { + return isDigit(ch) || isAlpha(ch); + } + static bool isLowerAlpha(dchar ch) pure nothrow { + return (ch >= 'a' && ch <= 'z') || (ch == '_'); + } + static bool isUpperAlpha(dchar ch) pure nothrow { + return (ch >= 'A' && ch <= 'Z'); + } + static bool isPunct(dchar ch) pure nothrow { + switch(ch) { + case '.': + case ',': + case ';': + case '?': + case '!': + return true; + default: + return false; + } + } + static bool isBracket(dchar ch) pure nothrow { + switch(ch) { + case '(': + case ')': + case '[': + case ']': + case '{': + case '}': + return true; + default: + return false; + } + } + + static bool isWordBound(dchar thischar, dchar nextchar) { + return (isAlNum(thischar) && !isAlNum(nextchar)) + || (isPunct(thischar) && !isPunct(nextchar)) + || (isBracket(thischar) && !isBracket(nextchar)) + || (thischar != ' ' && nextchar == ' '); + } + + /// change text position to nearest word bound (direction < 0 - back, > 0 - forward) + TextPosition moveByWord(TextPosition p, int direction, bool camelCasePartsAsWords) { + correctPosition(p); + TextPosition firstns = firstNonSpace(p.line); // before first non space + TextPosition lastns = lastNonSpace(p.line); // after last non space + int linelen = lineLength(p.line); // line length + if (direction < 0) { + // back + if (p.pos <= 0) { + // beginning of line - move to prev line + if (p.line > 0) + p = lastNonSpace(p.line - 1); + } else if (p.pos <= firstns.pos) { // before first nonspace + // to beginning of line + p.pos = 0; + } else { + dstring txt = line(p.line); + int found = -1; + for (int i = p.pos - 1; i > 0; i--) { + // check if position i + 1 is after word end + dchar thischar = i >= 0 && i < linelen ? txt[i] : ' '; + if (thischar == '\t') + thischar = ' '; + dchar nextchar = i - 1 >= 0 && i - 1 < linelen ? txt[i - 1] : ' '; + if (nextchar == '\t') + nextchar = ' '; + if (isWordBound(thischar, nextchar) + || (camelCasePartsAsWords && isUpperAlpha(thischar) && isLowerAlpha(nextchar))) { + found = i; + break; + } + } + if (found >= 0) + p.pos = found; + else + p.pos = 0; + } + } else if (direction > 0) { + // forward + if (p.pos >= linelen) { + // last position of line + if (p.line < length - 1) + p = firstNonSpace(p.line + 1); + } else if (p.pos >= lastns.pos) { // before first nonspace + // to beginning of line + p.pos = linelen; + } else { + dstring txt = line(p.line); + int found = -1; + for (int i = p.pos; i < linelen; i++) { + // check if position i + 1 is after word end + dchar thischar = txt[i]; + if (thischar == '\t') + thischar = ' '; + dchar nextchar = i < linelen - 1 ? txt[i + 1] : ' '; + if (nextchar == '\t') + nextchar = ' '; + if (isWordBound(thischar, nextchar) + || (camelCasePartsAsWords && isLowerAlpha(thischar) && isUpperAlpha(nextchar))) { + found = i + 1; + break; + } + } + if (found >= 0) + p.pos = found; + else + p.pos = linelen; + } + } + return p; + } + + /// edit content + bool performOperation(EditOperation op, Object source) { + if (_readOnly) + throw new Exception("content is readonly"); + if (op.action == EditAction.Replace) { + TextRange rangeBefore = op.range; + assert(rangeBefore.start <= rangeBefore.end); + //correctRange(rangeBefore); + dstring[] oldcontent = rangeText(rangeBefore); + dstring[] newcontent = op.content; + if (newcontent.length == 0) + newcontent ~= ""d; + TextRange rangeAfter = op.range; + rangeAfter.end = rangeAfter.start; + if (newcontent.length > 1) { + // different lines + rangeAfter.end.line = rangeAfter.start.line + cast(int)newcontent.length - 1; + rangeAfter.end.pos = cast(int)newcontent[$ - 1].length; + } else { + // same line + rangeAfter.end.pos = rangeAfter.start.pos + cast(int)newcontent[0].length; + } + assert(rangeAfter.start <= rangeAfter.end); + op.newRange = rangeAfter; + op.oldContent = oldcontent; + replaceRange(rangeBefore, rangeAfter, newcontent); + _undoBuffer.saveForUndo(op); + handleContentChange(op, rangeBefore, rangeAfter, source); + 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; + if (_readOnly) + throw new Exception("content is readonly"); + 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); + handleContentChange(op, rangeBefore, rangeAfter, this); + return true; + } + + /// redoes last undone change + bool redo() { + if (!hasUndo) + return false; + if (_readOnly) + throw new Exception("content is readonly"); + 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, this); + return true; + } + /// clear undo/redp history + void clearUndo() { + _undoBuffer.clear(); + } + + protected string _filename; + protected TextFileFormat _format; + + /// file used to load editor content + @property string filename() { + return _filename; + } + + + /// load content form input stream + bool load(InputStream f, string fname = null) { + import dlangui.core.linestream; + clear(); + _filename = fname; + _format = TextFileFormat.init; + try { + LineStream lines = LineStream.create(f, fname); + for (;;) { + dchar[] s = lines.readLine(); + if (s is null) + break; + int pos = cast(int)(_lines.length++); + _tokenProps.length = _lines.length; + _lines[pos] = s.dup; + clearTokenProps(pos, pos + 1); + } + if (lines.errorCode != 0) { + clear(); + Log.e("Error ", lines.errorCode, " ", lines.errorMessage, " -- at line ", lines.errorLine, " position ", lines.errorPos); + notifyContentReplaced(); + return false; + } + // EOF + _format = lines.textFormat; + _undoBuffer.clear(); + notifyContentReplaced(); + return true; + } catch (Exception e) { + Log.e("Exception while trying to read file ", fname, " ", e.toString); + clear(); + notifyContentReplaced(); + return false; + } + } + /// load content from file + bool load(string filename) { + clear(); + try { + std.stream.File f = new std.stream.File(filename); + scope(exit) { f.close(); } + return load(f, filename); + } catch (Exception e) { + Log.e("Exception while trying to read file ", filename, " ", e.toString); + clear(); + return false; + } + } + /// save to output stream in specified format + bool save(OutputStream stream, string filename, TextFileFormat format) { + if (!filename) + filename = _filename; + _format = format; + import dlangui.core.linestream; + try { + OutputLineStream writer = new OutputLineStream(stream, filename, format); + scope(exit) { writer.close(); } + for (int i = 0; i < _lines.length; i++) { + writer.writeLine(_lines[i]); + } + _undoBuffer.saved(); + notifyContentSaved(); + return true; + } catch (Exception e) { + Log.e("Exception while trying to write file ", filename, " ", e.toString); + return false; + } + } + /// save to output stream in current format + bool save(OutputStream stream, string filename) { + return save(stream, filename, _format); + } + /// save to file in specified format + bool save(string filename, TextFileFormat format) { + if (!filename) + filename = _filename; + try { + std.stream.File f = new std.stream.File(filename, FileMode.OutNew); + scope(exit) { f.close(); } + return save(f, filename, format); + } catch (Exception e) { + Log.e("Exception while trying to save file ", filename, " ", e.toString); + return false; + } + } + /// save to file in current format + bool save(string filename = null) { + return save(filename, _format); + } +} + diff --git a/src/dlangui/widgets/editors.d b/src/dlangui/widgets/editors.d index 2f15857b..7ab411d3 100644 --- a/src/dlangui/widgets/editors.d +++ b/src/dlangui/widgets/editors.d @@ -32,73 +32,15 @@ import dlangui.core.linestream; import dlangui.platforms.common.platform; import dlangui.widgets.menu; import dlangui.widgets.popup; -private import dlangui.graphics.colors; +import dlangui.graphics.colors; +public import dlangui.core.editable; import std.algorithm; import std.stream; -immutable dchar EOL = '\n'; - -const ubyte TOKEN_CATEGORY_SHIFT = 4; -const ubyte TOKEN_CATEGORY_MASK = 0xF0; // token category 0..15 -const ubyte TOKEN_SUBCATEGORY_MASK = 0x0F; // token subcategory 0..15 -const ubyte TOKEN_UNKNOWN = 0; - -/* - Bit mask: - 7654 3210 - cccc ssss - | | - | \ ssss = token subcategory - | - \ cccc = token category - - */ -/// token category for syntax highlight -enum TokenCategory : ubyte { - WhiteSpace = (0 << TOKEN_CATEGORY_SHIFT), - WhiteSpace_Space = (0 << TOKEN_CATEGORY_SHIFT) | 1, - WhiteSpace_Tab = (0 << TOKEN_CATEGORY_SHIFT) | 2, - - Comment = (1 << TOKEN_CATEGORY_SHIFT), - Comment_SingleLine = (1 << TOKEN_CATEGORY_SHIFT) | 1, // single line comment - Comment_SingleLineDoc = (1 << TOKEN_CATEGORY_SHIFT) | 2,// documentation in single line comment - Comment_MultyLine = (1 << TOKEN_CATEGORY_SHIFT) | 3, // multiline coment - Comment_MultyLineDoc = (1 << TOKEN_CATEGORY_SHIFT) | 4, // documentation in multiline comment - Comment_Documentation = (1 << TOKEN_CATEGORY_SHIFT) | 5,// documentation comment - - Identifier = (2 << TOKEN_CATEGORY_SHIFT), // identifier (exact subcategory is unknown) - Identifier_Class = (2 << TOKEN_CATEGORY_SHIFT) | 1, // class name - Identifier_Struct = (2 << TOKEN_CATEGORY_SHIFT) | 2, // struct name - Identifier_Local = (2 << TOKEN_CATEGORY_SHIFT) | 3, // local variable - Identifier_Member = (2 << TOKEN_CATEGORY_SHIFT) | 4, // struct or class member - Identifier_Deprecated = (2 << TOKEN_CATEGORY_SHIFT) | 15, // usage of this identifier is deprecated - /// string literal - String = (3 << TOKEN_CATEGORY_SHIFT), - /// character literal - Character = (4 << TOKEN_CATEGORY_SHIFT), - /// integer literal - Integer = (5 << TOKEN_CATEGORY_SHIFT), - /// floating point number literal - Float = (6 << TOKEN_CATEGORY_SHIFT), - /// keyword - Keyword = (7 << TOKEN_CATEGORY_SHIFT), - /// operator - Op = (8 << TOKEN_CATEGORY_SHIFT), - // add more here - //.... - /// error - unparsed character sequence - Error = (15 << TOKEN_CATEGORY_SHIFT), - /// invalid token - generic - Error_InvalidToken = (15 << TOKEN_CATEGORY_SHIFT) | 1, - /// invalid number token - error occured while parsing number - Error_InvalidNumber = (15 << TOKEN_CATEGORY_SHIFT) | 2, - /// invalid string token - error occured while parsing string - Error_InvalidString = (15 << TOKEN_CATEGORY_SHIFT) | 3, - /// invalid identifier token - error occured while parsing identifier - Error_InvalidIdentifier = (15 << TOKEN_CATEGORY_SHIFT) | 4, - /// invalid comment token - error occured while parsing comment - Error_InvalidComment = (15 << TOKEN_CATEGORY_SHIFT) | 4, +/// Modified state change listener +interface ModifiedStateListener { + void onModifiedStateChange(Widget source, bool modified); } /// Editor action codes @@ -218,899 +160,6 @@ enum EditorActions : int { } - - -/// split dstring by delimiters -dstring[] splitDString(dstring source, dchar delimiter = EOL) { - int start = 0; - dstring[] res; - dchar lastchar; - for (int i = 0; i <= source.length; i++) { - if (i == source.length || source[i] == delimiter) { - if (i >= start) { - dchar prevchar = i > 1 && i > start + 1 ? source[i - 1] : 0; - int end = i; - if (delimiter == EOL && prevchar == '\r') // windows CR/LF - end--; - dstring line = i > start ? cast(dstring)(source[start .. end].dup) : ""d; - res ~= line; - } - start = i + 1; - } - } - return res; -} - -version (Windows) { - immutable dstring SYSTEM_DEFAULT_EOL = "\r\n"; -} else { - immutable dstring SYSTEM_DEFAULT_EOL = "\n"; -} - -/// concat strings from array using delimiter -dstring concatDStrings(dstring[] lines, dstring delimiter = SYSTEM_DEFAULT_EOL) { - dchar[] buf; - foreach(line; lines) { - if (buf.length) - buf ~= delimiter; - buf ~= line; - } - return cast(dstring)buf; -} - -/// replace end of lines with spaces -dstring replaceEolsWithSpaces(dstring source) { - dchar[] buf; - dchar lastch; - foreach(ch; source) { - if (ch == '\r') { - buf ~= ' '; - } else if (ch == '\n') { - if (lastch != '\r') - buf ~= ' '; - } else { - buf ~= ch; - } - lastch = ch; - } - return cast(dstring)buf; -} - -/// text content position -struct TextPosition { - /// line number, zero based - int line; - /// character position in line (0 == before first character) - int pos; - /// compares two positions - int opCmp(ref const TextPosition v) const { - if (line < v.line) - return -1; - if (line > v.line) - return 1; - if (pos < v.pos) - return -1; - if (pos > v.pos) - return 1; - return 0; - } -} - -/// text content range -struct TextRange { - TextPosition start; - TextPosition end; - /// returns true if range is empty - @property bool empty() const { - return end <= start; - } - /// returns true if start and end located at the same line - @property bool singleLine() const { - return end.line == start.line; - } - /// returns count of lines in range - @property int lines() const { - return end.line - start.line + 1; - } -} - -/// action performed with editable contents -enum EditAction { - /// insert content into specified position (range.start) - //Insert, - /// delete content in range - //Delete, - /// replace range content with new content - Replace, - - /// replace whole content - ReplaceContent, - /// saved content - SaveContent, -} - -/// edit operation details for EditableContent -class EditOperation { - protected EditAction _action; - /// action performed - @property EditAction action() { return _action; } - protected TextRange _range; - - /// source range to replace with new content - @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) - protected dstring[] _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) { - _action = action; - } - this(EditAction action, TextPosition pos, dstring text) { - this(action, TextRange(pos, pos), text); - } - this(EditAction action, TextRange range, dstring text) { - _action = action; - _range = range; - _content.length = 1; - _content[0] = text.dup; - } - this(EditAction action, TextRange range, dstring[] text) { - _action = action; - _range = range; - _content.length = text.length; - for(int i = 0; i < text.length; i++) - _content[i] = text[i].dup; - //_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.start.line != op._range.start.line) // both ops whould be on the same line - return false; - if (_content.length != 1 || op._content.length != 1) // both ops should operate the same line - return false; - // appending of single character - if (_range.empty && op._range.empty && op._content[0].length == 1 && _newRange.end.pos == op._range.start.pos) { - _content[0] ~= op._content[0]; - _newRange.end.pos++; - return true; - } - // removing single character - if (_newRange.empty && op._newRange.empty && op._oldContent[0].length == 1) { - if (_newRange.end.pos == op.range.end.pos) { - // removed char before - _range.start.pos--; - _newRange.start.pos--; - _newRange.end.pos--; - _oldContent[0] = (op._oldContent[0].dup ~ _oldContent[0].dup).dup; - return true; - } else if (_newRange.end.pos == op._range.start.pos) { - // removed char after - _range.end.pos++; - _oldContent[0] = (_oldContent[0].dup ~ op._oldContent[0].dup).dup; - return true; - } - } - return false; - } -} - -/// 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) { - _redoList.clear(); - if (!_undoList.empty) { - if (_undoList.back.merge(op)) { - 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(); - _savedState = null; - } - - protected EditOperation _savedState; - - /// current state is saved - void saved() { - _savedState = _undoList.peekBack; - } - /// returns true if content has been changed since last saved() or clear() call - @property bool modified() { - return _savedState !is _undoList.peekBack; - } -} - -/// Editable Content change listener -interface EditableContentListener { - void onContentChange(EditableContent content, EditOperation operation, ref TextRange rangeBefore, ref TextRange rangeAfter, Object source); -} - -/// Modified state change listener -interface ModifiedStateListener { - void onModifiedStateChange(Widget source, bool modified); -} - -alias TokenPropString = ubyte[]; - -/// interface for custom syntax highlight -interface SyntaxHighlighter { - /// categorize characters in content by token types - void updateHighlight(dstring[] lines, TokenPropString[] props, int changeStartLine, int changeEndLine); -} - -/// editable plain text (singleline/multiline) -class EditableContent { - - this(bool multiline) { - _multiline = multiline; - _lines.length = 1; // initial state: single empty line - _undoBuffer = new UndoBuffer(); - } - - @property bool modified() { - return _undoBuffer.modified; - } - - protected UndoBuffer _undoBuffer; - - protected SyntaxHighlighter _syntaxHighlighter; - - @property SyntaxHighlighter syntaxHighlighter() { - return _syntaxHighlighter; - } - - @property EditableContent syntaxHighlighter(SyntaxHighlighter syntaxHighlighter) { - _syntaxHighlighter = syntaxHighlighter; - updateTokenProps(0, cast(int)_lines.length); - return this; - } - - /// returns true if content has syntax highlight handler set - @property bool hasSyntaxHighlight() { - return _syntaxHighlighter !is null; - } - - protected bool _readOnly; - - @property bool readOnly() { - return _readOnly; - } - - @property void readOnly(bool readOnly) { - _readOnly = readOnly; - } - - /// listeners for edit operations - Signal!EditableContentListener contentChangeListeners; - - protected bool _multiline; - /// returns true if miltyline content is supported - @property bool multiline() { return _multiline; } - - protected dstring[] _lines; - protected TokenPropString[] _tokenProps; - - /// returns all lines concatenated delimited by '\n' - @property dstring text() { - if (_lines.length == 0) - return ""; - if (_lines.length == 1) - return _lines[0]; - // concat lines - dchar[] buf; - foreach(item;_lines) { - if (buf.length) - buf ~= EOL; - buf ~= item; - } - return cast(dstring)buf; - } - - /// append one or more lines at end - void appendLines(dstring[] lines...) { - TextRange rangeBefore; - rangeBefore.start = rangeBefore.end = lineEnd(_lines.length ? cast(int)_lines.length - 1 : 0); - EditOperation op = new EditOperation(EditAction.Replace, rangeBefore, lines); - performOperation(op, this); - } - - /// call listener to say that whole content is replaced e.g. by loading from file - void notifyContentReplaced() { - TextRange rangeBefore; - TextRange rangeAfter; - // notify about content change - handleContentChange(new EditOperation(EditAction.ReplaceContent), rangeBefore, rangeAfter, this); - } - - /// call listener to say that content is saved - void notifyContentSaved() { - TextRange rangeBefore; - TextRange rangeAfter; - // notify about content change - handleContentChange(new EditOperation(EditAction.SaveContent), rangeBefore, rangeAfter, this); - } - - protected void updateTokenProps(int startLine, int endLine) { - clearTokenProps(startLine, endLine); - if (_syntaxHighlighter) { - _syntaxHighlighter.updateHighlight(_lines, _tokenProps, startLine, endLine); - } - } - - /// set props arrays size equal to text line sizes, bit fill with unknown token - protected void clearTokenProps(int startLine, int endLine) { - for (int i = startLine; i < endLine; i++) { - if (hasSyntaxHighlight) { - int len = cast(int)_lines[i].length; - _tokenProps[i].length = len; - for (int j = 0; j < len; j++) - _tokenProps[i][j] = TOKEN_UNKNOWN; - } else { - _tokenProps[i] = null; // no token props - } - } - } - - /// replace whole text with another content - @property EditableContent text(dstring newContent) { - clearUndo(); - _lines.length = 0; - if (_multiline) { - _lines = splitDString(newContent); - _tokenProps.length = _lines.length; - updateTokenProps(0, cast(int)_lines.length); - } else { - _lines.length = 1; - _lines[0] = replaceEolsWithSpaces(newContent); - _tokenProps.length = 1; - updateTokenProps(0, cast(int)_lines.length); - } - notifyContentReplaced(); - return this; - } - - /// clear content - void clear() { - clearUndo(); - _lines.length = 0; - } - - - /// returns line text - @property int length() { return cast(int)_lines.length; } - dstring opIndex(int index) { - return line(index); - } - - /// returns line text by index, "" if index is out of bounds - dstring line(int index) { - return index >= 0 && index < _lines.length ? _lines[index] : ""d; - } - - /// returns line token properties one item per character - TokenPropString lineTokenProps(int index) { - return index >= 0 && index < _tokenProps.length ? _tokenProps[index] : null; - } - - /// returns text position for end of line lineIndex - TextPosition lineEnd(int lineIndex) { - return TextPosition(lineIndex, lineLength(lineIndex)); - } - - /// returns position before first non-space character of line, returns 0 position if no non-space chars - TextPosition firstNonSpace(int lineIndex) { - dstring s = line(lineIndex); - for (int i = 0; i < s.length; i++) - if (s[i] != ' ' && s[i] != '\t') - return TextPosition(lineIndex, i); - return TextPosition(lineIndex, 0); - } - - /// returns position after last non-space character of line, returns 0 position if no non-space chars on line - TextPosition lastNonSpace(int lineIndex) { - dstring s = line(lineIndex); - for (int i = cast(int)s.length - 1; i >= 0; i--) - if (s[i] != ' ' && s[i] != '\t') - return TextPosition(lineIndex, i + 1); - return TextPosition(lineIndex, 0); - } - - /// returns text position for end of line lineIndex - int lineLength(int lineIndex) { - return lineIndex >= 0 && lineIndex < _lines.length ? cast(int)_lines[lineIndex].length : 0; - } - - /// returns maximum length of line - int maxLineLength() { - int m = 0; - foreach(s; _lines) - if (m < s.length) - m = cast(int)s.length; - return m; - } - - void handleContentChange(EditOperation op, ref TextRange rangeBefore, ref TextRange rangeAfter, Object source) { - // update highlight if necessary - updateTokenProps(rangeAfter.start.line, rangeAfter.end.line + 1); - // call listeners - if (contentChangeListeners.assigned) - contentChangeListeners(this, op, rangeBefore, rangeAfter, source); - } - - /// return text for specified range - dstring[] rangeText(TextRange range) { - dstring[] res; - if (range.empty) { - res ~= ""d; - return res; - } - for (int lineIndex = range.start.line; lineIndex <= range.end.line; lineIndex++) { - dstring lineText = line(lineIndex); - dstring lineFragment = lineText; - int startchar = 0; - int endchar = cast(int)lineText.length; - if (lineIndex == range.start.line) - startchar = range.start.pos; - if (lineIndex == range.end.line) - endchar = range.end.pos; - if (endchar > lineText.length) - endchar = cast(int)lineText.length; - if (endchar <= startchar) - lineFragment = ""d; - else if (startchar != 0 || endchar != lineText.length) - lineFragment = lineText[startchar .. endchar].dup; - res ~= lineFragment; - } - return res; - } - - /// when position is out of content bounds, fix it to nearest valid position - void correctPosition(ref TextPosition position) { - if (position.line >= length) { - position.line = length - 1; - position.pos = lineLength(position.line); - } - if (position.line < 0) { - position.line = 0; - position.pos = 0; - } - int currentLineLength = lineLength(position.line); - if (position.pos > currentLineLength) - position.pos = currentLineLength; - if (position.pos < 0) - position.pos = 0; - } - - /// when range positions is out of content bounds, fix it to nearest valid position - void correctRange(ref TextRange range) { - correctPosition(range.start); - correctPosition(range.end); - } - - /// removes removedCount lines starting from start - protected void removeLines(int start, int removedCount) { - int end = start + removedCount; - assert(removedCount > 0 && start >= 0 && end > 0 && start < _lines.length && end <= _lines.length); - for (int i = start; i < _lines.length - removedCount; i++) { - _lines[i] = _lines[i + removedCount]; - _tokenProps[i] = _tokenProps[i + removedCount]; - } - for (int i = cast(int)_lines.length - removedCount; i < _lines.length; i++) { - _lines[i] = null; // free unused line references - _tokenProps[i] = null; // free unused line references - } - _lines.length -= removedCount; - _tokenProps.length = _lines.length; - } - - /// inserts count empty lines at specified position - protected void insertLines(int start, int count) { - assert(count > 0); - _lines.length += count; - _tokenProps.length = _lines.length; - for (int i = cast(int)_lines.length - 1; i >= start + count; i--) { - _lines[i] = _lines[i - count]; - _tokenProps[i] = _tokenProps[i - count]; - } - for (int i = start; i < start + count; i++) { - _lines[i] = ""d; - _tokenProps[i] = null; - } - } - - /// inserts or removes lines, removes text in range - protected void replaceRange(TextRange before, TextRange after, dstring[] newContent) { - dstring firstLineBefore = line(before.start.line); - dstring lastLineBefore = before.singleLine ? firstLineBefore : line(before.end.line); - dstring firstLineHead = before.start.pos > 0 && before.start.pos <= firstLineBefore.length ? firstLineBefore[0..before.start.pos] : ""d; - dstring lastLineTail = before.end.pos >= 0 && before.end.pos < lastLineBefore.length ? lastLineBefore[before.end.pos .. $] : ""d; - - int linesBefore = before.lines; - int linesAfter = after.lines; - if (linesBefore < linesAfter) { - // add more lines - insertLines(before.start.line + 1, linesAfter - linesBefore); - } else if (linesBefore > linesAfter) { - // remove extra lines - removeLines(before.start.line + 1, linesBefore - linesAfter); - } - for (int i = after.start.line; i <= after.end.line; i++) { - dstring newline = newContent[i - after.start.line]; - if (i == after.start.line && i == after.end.line) { - dchar[] buf; - buf ~= firstLineHead; - buf ~= newline; - buf ~= lastLineTail; - //Log.d("merging lines ", firstLineHead, " ", newline, " ", lastLineTail); - _lines[i] = cast(dstring)buf; - clearTokenProps(i, i + 1); - //Log.d("merge result: ", _lines[i]); - } else if (i == after.start.line) { - dchar[] buf; - buf ~= firstLineHead; - buf ~= newline; - _lines[i] = cast(dstring)buf; - clearTokenProps(i, i + 1); - } else if (i == after.end.line) { - dchar[] buf; - buf ~= newline; - buf ~= lastLineTail; - _lines[i] = cast(dstring)buf; - clearTokenProps(i, i + 1); - } else { - _lines[i] = newline; // no dup needed - clearTokenProps(i, i + 1); - } - } - } - - static bool isDigit(dchar ch) pure nothrow { - return ch >= '0' && ch <= '9'; - } - static bool isAlpha(dchar ch) pure nothrow { - return isLowerAlpha(ch) || isUpperAlpha(ch); - } - static bool isAlNum(dchar ch) pure nothrow { - return isDigit(ch) || isAlpha(ch); - } - static bool isLowerAlpha(dchar ch) pure nothrow { - return (ch >= 'a' && ch <= 'z') || (ch == '_'); - } - static bool isUpperAlpha(dchar ch) pure nothrow { - return (ch >= 'A' && ch <= 'Z'); - } - static bool isPunct(dchar ch) pure nothrow { - switch(ch) { - case '.': - case ',': - case ';': - case '?': - case '!': - return true; - default: - return false; - } - } - static bool isBracket(dchar ch) pure nothrow { - switch(ch) { - case '(': - case ')': - case '[': - case ']': - case '{': - case '}': - return true; - default: - return false; - } - } - - static bool isWordBound(dchar thischar, dchar nextchar) { - return (isAlNum(thischar) && !isAlNum(nextchar)) - || (isPunct(thischar) && !isPunct(nextchar)) - || (isBracket(thischar) && !isBracket(nextchar)) - || (thischar != ' ' && nextchar == ' '); - } - - /// change text position to nearest word bound (direction < 0 - back, > 0 - forward) - TextPosition moveByWord(TextPosition p, int direction, bool camelCasePartsAsWords) { - correctPosition(p); - TextPosition firstns = firstNonSpace(p.line); // before first non space - TextPosition lastns = lastNonSpace(p.line); // after last non space - int linelen = lineLength(p.line); // line length - if (direction < 0) { - // back - if (p.pos <= 0) { - // beginning of line - move to prev line - if (p.line > 0) - p = lastNonSpace(p.line - 1); - } else if (p.pos <= firstns.pos) { // before first nonspace - // to beginning of line - p.pos = 0; - } else { - dstring txt = line(p.line); - int found = -1; - for (int i = p.pos - 1; i > 0; i--) { - // check if position i + 1 is after word end - dchar thischar = i >= 0 && i < linelen ? txt[i] : ' '; - if (thischar == '\t') - thischar = ' '; - dchar nextchar = i - 1 >= 0 && i - 1 < linelen ? txt[i - 1] : ' '; - if (nextchar == '\t') - nextchar = ' '; - if (isWordBound(thischar, nextchar) - || (camelCasePartsAsWords && isUpperAlpha(thischar) && isLowerAlpha(nextchar))) { - found = i; - break; - } - } - if (found >= 0) - p.pos = found; - else - p.pos = 0; - } - } else if (direction > 0) { - // forward - if (p.pos >= linelen) { - // last position of line - if (p.line < length - 1) - p = firstNonSpace(p.line + 1); - } else if (p.pos >= lastns.pos) { // before first nonspace - // to beginning of line - p.pos = linelen; - } else { - dstring txt = line(p.line); - int found = -1; - for (int i = p.pos; i < linelen; i++) { - // check if position i + 1 is after word end - dchar thischar = txt[i]; - if (thischar == '\t') - thischar = ' '; - dchar nextchar = i < linelen - 1 ? txt[i + 1] : ' '; - if (nextchar == '\t') - nextchar = ' '; - if (isWordBound(thischar, nextchar) - || (camelCasePartsAsWords && isLowerAlpha(thischar) && isUpperAlpha(nextchar))) { - found = i + 1; - break; - } - } - if (found >= 0) - p.pos = found; - else - p.pos = linelen; - } - } - return p; - } - - /// edit content - bool performOperation(EditOperation op, Object source) { - if (_readOnly) - throw new Exception("content is readonly"); - if (op.action == EditAction.Replace) { - TextRange rangeBefore = op.range; - assert(rangeBefore.start <= rangeBefore.end); - //correctRange(rangeBefore); - dstring[] oldcontent = rangeText(rangeBefore); - dstring[] newcontent = op.content; - if (newcontent.length == 0) - newcontent ~= ""d; - TextRange rangeAfter = op.range; - rangeAfter.end = rangeAfter.start; - if (newcontent.length > 1) { - // different lines - rangeAfter.end.line = rangeAfter.start.line + cast(int)newcontent.length - 1; - rangeAfter.end.pos = cast(int)newcontent[$ - 1].length; - } else { - // same line - rangeAfter.end.pos = rangeAfter.start.pos + cast(int)newcontent[0].length; - } - assert(rangeAfter.start <= rangeAfter.end); - op.newRange = rangeAfter; - op.oldContent = oldcontent; - replaceRange(rangeBefore, rangeAfter, newcontent); - _undoBuffer.saveForUndo(op); - handleContentChange(op, rangeBefore, rangeAfter, source); - 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; - if (_readOnly) - throw new Exception("content is readonly"); - 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); - handleContentChange(op, rangeBefore, rangeAfter, this); - return true; - } - - /// redoes last undone change - bool redo() { - if (!hasUndo) - return false; - if (_readOnly) - throw new Exception("content is readonly"); - 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, this); - return true; - } - /// clear undo/redp history - void clearUndo() { - _undoBuffer.clear(); - } - - protected string _filename; - protected TextFileFormat _format; - - /// file used to load editor content - @property string filename() { - return _filename; - } - - - /// load content form input stream - bool load(InputStream f, string fname = null) { - import dlangui.core.linestream; - clear(); - _filename = fname; - _format = TextFileFormat.init; - try { - LineStream lines = LineStream.create(f, fname); - for (;;) { - dchar[] s = lines.readLine(); - if (s is null) - break; - int pos = cast(int)(_lines.length++); - _tokenProps.length = _lines.length; - _lines[pos] = s.dup; - clearTokenProps(pos, pos + 1); - } - if (lines.errorCode != 0) { - clear(); - Log.e("Error ", lines.errorCode, " ", lines.errorMessage, " -- at line ", lines.errorLine, " position ", lines.errorPos); - notifyContentReplaced(); - return false; - } - // EOF - _format = lines.textFormat; - _undoBuffer.clear(); - notifyContentReplaced(); - return true; - } catch (Exception e) { - Log.e("Exception while trying to read file ", fname, " ", e.toString); - clear(); - notifyContentReplaced(); - return false; - } - } - /// load content from file - bool load(string filename) { - clear(); - try { - std.stream.File f = new std.stream.File(filename); - scope(exit) { f.close(); } - return load(f, filename); - } catch (Exception e) { - Log.e("Exception while trying to read file ", filename, " ", e.toString); - clear(); - return false; - } - } - /// save to output stream in specified format - bool save(OutputStream stream, string filename, TextFileFormat format) { - if (!filename) - filename = _filename; - _format = format; - import dlangui.core.linestream; - try { - OutputLineStream writer = new OutputLineStream(stream, filename, format); - scope(exit) { writer.close(); } - for (int i = 0; i < _lines.length; i++) { - writer.writeLine(_lines[i]); - } - _undoBuffer.saved(); - notifyContentSaved(); - return true; - } catch (Exception e) { - Log.e("Exception while trying to write file ", filename, " ", e.toString); - return false; - } - } - /// save to output stream in current format - bool save(OutputStream stream, string filename) { - return save(stream, filename, _format); - } - /// save to file in specified format - bool save(string filename, TextFileFormat format) { - if (!filename) - filename = _filename; - try { - std.stream.File f = new std.stream.File(filename, FileMode.OutNew); - scope(exit) { f.close(); } - return save(f, filename, format); - } catch (Exception e) { - Log.e("Exception while trying to save file ", filename, " ", e.toString); - return false; - } - } - /// save to file in current format - bool save(string filename = null) { - return save(filename, _format); - } -} - /// base for all editor widgets class EditWidgetBase : ScrollWidgetBase, EditableContentListener, MenuItemActionHandler { protected EditableContent _content;