// Written in the D programming language. /** This module contains implementation of editors. EditLine - single line editor. EditBox - multiline editor LogWidget - readonly text box for showing logs Synopsis: ---- import dlangui.widgets.editors; ---- Copyright: Vadim Lopatin, 2014 License: Boost License 1.0 Authors: Vadim Lopatin, coolreader.org@gmail.com */ module dlangui.widgets.editors; import dlangui.widgets.widget; import dlangui.widgets.controls; import dlangui.widgets.scroll; import dlangui.core.signals; import dlangui.core.collections; import dlangui.core.linestream; import dlangui.platforms.common.platform; import dlangui.widgets.menu; import dlangui.widgets.popup; private import dlangui.graphics.colors; 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, } /// Editor action codes enum EditorActions : int { None = 0, /// move cursor one char left Left = 1000, /// move cursor one char left with selection SelectLeft, /// move cursor one char right Right, /// move cursor one char right with selection SelectRight, /// move cursor one line up Up, /// move cursor one line up with selection SelectUp, /// move cursor one line down Down, /// move cursor one line down with selection SelectDown, /// move cursor one word left WordLeft, /// move cursor one word left with selection SelectWordLeft, /// move cursor one word right WordRight, /// move cursor one word right with selection SelectWordRight, /// move cursor one page up PageUp, /// move cursor one page up with selection SelectPageUp, /// move cursor one page down PageDown, /// move cursor one page down with selection SelectPageDown, /// move cursor to the beginning of page PageBegin, /// move cursor to the beginning of page with selection SelectPageBegin, /// move cursor to the end of page PageEnd, /// move cursor to the end of page with selection SelectPageEnd, /// move cursor to the beginning of line LineBegin, /// move cursor to the beginning of line with selection SelectLineBegin, /// move cursor to the end of line LineEnd, /// move cursor to the end of line with selection SelectLineEnd, /// move cursor to the beginning of document DocumentBegin, /// move cursor to the beginning of document with selection SelectDocumentBegin, /// move cursor to the end of document DocumentEnd, /// move cursor to the end of document with selection SelectDocumentEnd, /// delete char before cursor (backspace) DelPrevChar, /// delete char after cursor (del key) DelNextChar, /// delete word before cursor (ctrl + backspace) DelPrevWord, /// delete char after cursor (ctrl + del key) DelNextWord, /// insert new line (Enter) InsertNewLine, /// insert new line after current position (Ctrl+Enter) PrependNewLine, /// Turn On/Off replace mode ToggleReplaceMode, /// Copy selection to clipboard Copy, /// Cut selection to clipboard Cut, /// Paste selection from clipboard Paste, /// Undo last change Undo, /// Redo last undoed change Redo, /// Tab (e.g., Tab key to insert tab character or indent text) Tab, /// Tab (unindent text, or remove whitespace before cursor, usually Shift+Tab) BackTab, /// Select whole content (usually, Ctrl+A) SelectAll, // Scroll operations /// Scroll one line up (not changing cursor) ScrollLineUp, /// Scroll one line down (not changing cursor) ScrollLineDown, /// Scroll one page up (not changing cursor) ScrollPageUp, /// Scroll one page down (not changing cursor) ScrollPageDown, /// Scroll window left ScrollLeft, /// Scroll window right ScrollRight, /// Zoom in editor font ZoomIn, /// Zoom out editor font ZoomOut, } /// 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, } /// 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(); } } /// 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(); } 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); } 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 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); handleContentChange(op, rangeBefore, rangeAfter, source); _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; 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; 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]); } // EOF 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; protected int _lineHeight; protected Point _scrollPos; protected bool _fixedFont; protected int _spaceWidth; protected int _tabSize = 4; protected int _leftPaneWidth; // left pane - can be used to show line numbers, collapse controls, bookmarks, breakpoints, custom icons protected int _minFontSize = -1; // disable zooming protected int _maxFontSize = -1; // disable zooming protected bool _wantTabs = true; protected bool _useSpacesForTabs = false; protected bool _showLineNumbers = false; // show line numbers in left pane protected bool _showModificationMarks = false; // show modification marks in left pane protected bool _showIcons = false; // show icons in left pane protected bool _showFolding = false; // show folding controls in left pane protected int _lineNumbersWidth = 0; protected int _modificationMarksWidth = 0; protected int _iconsWidth = 0; protected int _foldingWidth = 0; protected bool _replaceMode; // TODO: move to styles protected uint _selectionColorFocused = 0xB060A0FF; protected uint _selectionColorNormal = 0xD060A0FF; protected uint _leftPaneBackgroundColor = 0xE0E0E0; protected uint _leftPaneBackgroundColor2 = 0xFFFFFF; protected uint _leftPaneBackgroundColor3 = 0xC0C0C0; protected uint _leftPaneLineNumberColor = 0x4060D0; protected uint _leftPaneLineNumberBackgroundColor = 0xF0F0F0; protected uint _iconsPaneWidth = 16; protected uint _foldingPaneWidth = 16; protected uint _modificationMarksPaneWidth = 8; /// override to support modification of client rect after change, e.g. apply offset override protected void handleClientRectLayout(ref Rect rc) { updateLeftPaneWidth(); rc.left += _leftPaneWidth; } /// override for multiline editors protected int lineCount() { return 1; } /// override to add custom items on left panel protected void updateLeftPaneWidth() { _iconsWidth = _showIcons ? _iconsPaneWidth : 0; _foldingWidth = _showFolding ? _foldingPaneWidth : 0; _modificationMarksWidth = _showModificationMarks ? _modificationMarksPaneWidth : 0; _lineNumbersWidth = 0; if (_showLineNumbers) { dchar[] s = to!(dchar[])(lineCount + 1); foreach(ref ch; s) ch = '9'; FontRef fnt = font; Point sz = fnt.textSize(s); _lineNumbersWidth = sz.x; } _leftPaneWidth = _lineNumbersWidth + _modificationMarksWidth + _foldingWidth + _iconsWidth; if (_leftPaneWidth) _leftPaneWidth += 3; } protected void drawLeftPaneFolding(DrawBuf buf, Rect rc, int line) { } protected void drawLeftPaneIcons(DrawBuf buf, Rect rc, int line) { } protected void drawLeftPaneModificationMarks(DrawBuf buf, Rect rc, int line) { } protected void drawLeftPaneLineNumbers(DrawBuf buf, Rect rc, int line) { buf.fillRect(rc, _leftPaneLineNumberBackgroundColor); if (line < 0) return; dstring s = to!dstring(line + 1); FontRef fnt = font; Point sz = fnt.textSize(s); int x = rc.right - sz.x; int y = rc.top + (rc.height - sz.y) / 2; fnt.drawText(buf, x, y, s, _leftPaneLineNumberColor); } protected void drawLeftPane(DrawBuf buf, Rect rc, int line) { // override for custom drawn left pane buf.fillRect(rc, _leftPaneBackgroundColor); buf.fillRect(Rect(rc.right - 2, rc.top, rc.right - 1, rc.bottom), _leftPaneBackgroundColor2); buf.fillRect(Rect(rc.right - 1, rc.top, rc.right - 0, rc.bottom), _leftPaneBackgroundColor3); rc.right -= 3; if (_foldingWidth) { Rect rc2 = rc; rc.right = rc2.left = rc2.right - _foldingWidth; drawLeftPaneFolding(buf, rc2, line); } if (_lineNumbersWidth) { Rect rc2 = rc; rc.right = rc2.left = rc2.right - _lineNumbersWidth; drawLeftPaneLineNumbers(buf, rc2, line); } if (_modificationMarksWidth) { Rect rc2 = rc; rc.right = rc2.left = rc2.right - _modificationMarksWidth; drawLeftPaneModificationMarks(buf, rc2, line); } if (_iconsWidth) { Rect rc2 = rc; rc.right = rc2.left = rc2.right - _iconsWidth; drawLeftPaneIcons(buf, rc2, line); } } this(string ID, ScrollBarMode hscrollbarMode = ScrollBarMode.Visible, ScrollBarMode vscrollbarMode = ScrollBarMode.Visible) { super(ID, hscrollbarMode, vscrollbarMode); focusable = true; acceleratorMap.add( [ new Action(EditorActions.Up, KeyCode.UP, 0), new Action(EditorActions.SelectUp, KeyCode.UP, KeyFlag.Shift), new Action(EditorActions.Down, KeyCode.DOWN, 0), new Action(EditorActions.SelectDown, KeyCode.DOWN, KeyFlag.Shift), new Action(EditorActions.Left, KeyCode.LEFT, 0), new Action(EditorActions.SelectLeft, KeyCode.LEFT, KeyFlag.Shift), new Action(EditorActions.Right, KeyCode.RIGHT, 0), new Action(EditorActions.SelectRight, KeyCode.RIGHT, KeyFlag.Shift), new Action(EditorActions.WordLeft, KeyCode.LEFT, KeyFlag.Control), new Action(EditorActions.SelectWordLeft, KeyCode.LEFT, KeyFlag.Control | KeyFlag.Shift), new Action(EditorActions.WordRight, KeyCode.RIGHT, KeyFlag.Control), new Action(EditorActions.SelectWordRight, KeyCode.RIGHT, KeyFlag.Control | KeyFlag.Shift), new Action(EditorActions.PageUp, KeyCode.PAGEUP, 0), new Action(EditorActions.SelectPageUp, KeyCode.PAGEUP, KeyFlag.Shift), new Action(EditorActions.PageDown, KeyCode.PAGEDOWN, 0), new Action(EditorActions.SelectPageDown, KeyCode.PAGEDOWN, KeyFlag.Shift), new Action(EditorActions.PageBegin, KeyCode.PAGEUP, KeyFlag.Control), new Action(EditorActions.SelectPageBegin, KeyCode.PAGEUP, KeyFlag.Control | KeyFlag.Shift), new Action(EditorActions.PageEnd, KeyCode.PAGEDOWN, KeyFlag.Control), new Action(EditorActions.SelectPageEnd, KeyCode.PAGEDOWN, KeyFlag.Control | KeyFlag.Shift), new Action(EditorActions.LineBegin, KeyCode.HOME, 0), new Action(EditorActions.SelectLineBegin, KeyCode.HOME, KeyFlag.Shift), new Action(EditorActions.LineEnd, KeyCode.END, 0), new Action(EditorActions.SelectLineEnd, KeyCode.END, KeyFlag.Shift), new Action(EditorActions.DocumentBegin, KeyCode.HOME, KeyFlag.Control), new Action(EditorActions.SelectDocumentBegin, KeyCode.HOME, KeyFlag.Control | KeyFlag.Shift), new Action(EditorActions.DocumentEnd, KeyCode.END, KeyFlag.Control), new Action(EditorActions.SelectDocumentEnd, KeyCode.END, KeyFlag.Control | KeyFlag.Shift), new Action(EditorActions.ScrollLineUp, KeyCode.UP, KeyFlag.Control), new Action(EditorActions.ScrollLineDown, KeyCode.DOWN, KeyFlag.Control), 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.DelNextChar, KeyCode.DEL, 0), new Action(EditorActions.DelPrevWord, KeyCode.BACK, 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|KeyFlag.Shift), 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|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|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), new Action(EditorActions.Tab, KeyCode.TAB, 0), new Action(EditorActions.BackTab, KeyCode.TAB, KeyFlag.Shift), new Action(EditorActions.ToggleReplaceMode, KeyCode.INS, 0), new Action(EditorActions.SelectAll, KeyCode.KEY_A, KeyFlag.Control), ]); } protected MenuItem _popupMenu; @property MenuItem popupMenu() { return _popupMenu; } @property EditWidgetBase popupMenu(MenuItem popupMenu) { _popupMenu = popupMenu; return this; } /// override bool onMenuItemAction(const Action action) { return dispatchAction(action); } /// returns true if widget can show popup (e.g. by mouse right click at point x,y) override bool canShowPopupMenu(int x, int y) { if (_popupMenu is null) return false; if (_popupMenu.onBeforeOpeningSubmenu.assigned) if (!_popupMenu.onBeforeOpeningSubmenu(_popupMenu)) return false; return true; } /// returns true if widget is focusable and visible and enabled override @property bool canFocus() { // allow to focus even if not enabled return focusable && visible; } /// override to change popup menu items state override bool isActionEnabled(const Action action) { switch (action.id) { case EditorActions.Copy: case EditorActions.Cut: return !_selectionRange.empty; case EditorActions.Paste: return Platform.instance.getClipboardText().length > 0; case EditorActions.Undo: return _content.hasUndo; case EditorActions.Redo: return _content.hasRedo; default: return super.isActionEnabled(action); } } /// shows popup at (x,y) override void showPopupMenu(int x, int y) { /// if preparation signal handler assigned, call it; don't show popup if false is returned from handler if (_popupMenu.onBeforeOpeningSubmenu.assigned) if (!_popupMenu.onBeforeOpeningSubmenu(_popupMenu)) return; for (int i = 0; i < _popupMenu.subitemCount; i++) { MenuItem item = _popupMenu.subitem(i); if (item.action && isActionEnabled(item.action)) { item.enabled = true; } else { item.enabled = false; } } PopupMenu popupMenu = new PopupMenu(_popupMenu); popupMenu.onMenuItemActionListener = this; PopupWidget popup = window.showPopup(popupMenu, this, PopupAlign.Point | PopupAlign.Right, x, y); popup.flags = PopupFlags.CloseOnClickOutside; } void onPopupMenuItem(MenuItem item) { // TODO } /// returns mouse cursor type for widget override uint getCursorType(int x, int y) { return CursorType.IBeam; } /// when true, Tab / Shift+Tab presses are processed internally in widget (e.g. insert tab character) instead of focus change navigation. @property bool wantTabs() { return _wantTabs; } /// sets tab size (in number of spaces) @property EditWidgetBase wantTabs(bool wantTabs) { _wantTabs = wantTabs; return this; } /// when true, line numbers are shown @property bool showLineNumbers() { return _showLineNumbers; } /// when true, line numbers are shown @property EditWidgetBase showLineNumbers(bool flg) { if (_showLineNumbers != flg) { _showLineNumbers = flg; updateLeftPaneWidth(); requestLayout(); } return this; } /// readonly flag (when true, user cannot change content of editor) @property bool readOnly() { return !enabled || _content.readOnly; } /// sets readonly flag @property EditWidgetBase readOnly(bool readOnly) { enabled = !readOnly; invalidate(); return this; } /// replace mode flag (when true, entered character replaces character under cursor) @property bool replaceMode() { return _replaceMode; } /// sets replace mode flag @property EditWidgetBase replaceMode(bool replaceMode) { _replaceMode = replaceMode; invalidate(); return this; } /// when true, spaces will be inserted instead of tabs @property bool useSpacesForTabs() { return _useSpacesForTabs; } /// set new Tab key behavior flag: when true, spaces will be inserted instead of tabs @property EditWidgetBase useSpacesForTabs(bool useSpacesForTabs) { _useSpacesForTabs = useSpacesForTabs; return this; } /// returns tab size (in number of spaces) @property int tabSize() { return _tabSize; } /// sets tab size (in number of spaces) @property EditWidgetBase tabSize(int newTabSize) { if (newTabSize < 1) newTabSize = 1; else if (newTabSize > 16) newTabSize = 16; if (newTabSize != _tabSize) { _tabSize = newTabSize; requestLayout(); } return this; } /// editor content object @property EditableContent content() { return _content; } /// when _ownContent is false, _content should not be destroyed in editor destructor protected bool _ownContent = true; /// set content object @property EditWidgetBase content(EditableContent content) { if (_content is content) return this; // not changed if (_content !is null) { // disconnect old content _content.contentChangeListeners.disconnect(this); if (_ownContent) { destroy(_content); } } _content = content; _ownContent = false; _content.contentChangeListeners.connect(this); if (_content.readOnly) enabled = false; return this; } /// free resources ~this() { if (_ownContent) { destroy(_content); _content = null; } } protected void updateMaxLineWidth() { } override void onContentChange(EditableContent content, EditOperation operation, ref TextRange rangeBefore, ref TextRange rangeAfter, Object source) { Log.d("onContentChange rangeBefore=", rangeBefore, " rangeAfter=", rangeAfter, " text=", operation.content); updateMaxLineWidth(); measureVisibleText(); if (source is this) { if (operation.action == EditAction.ReplaceContent) { // loaded from file _caretPos = rangeAfter.end; _selectionRange.start = _caretPos; _selectionRange.end = _caretPos; ensureCaretVisible(); correctCaretPos(); requestLayout(); } else { _caretPos = rangeAfter.end; _selectionRange.start = _caretPos; _selectionRange.end = _caretPos; ensureCaretVisible(); } } else { correctCaretPos(); // TODO: do something better (e.g. take into account ranges when correcting) } invalidate(); return; } /// get widget text override @property dstring text() { return _content.text; } /// set text override @property Widget text(dstring s) { _content.text = s; requestLayout(); return this; } /// set text override @property Widget text(UIString s) { _content.text = s; requestLayout(); return this; } protected TextPosition _caretPos; protected TextRange _selectionRange; abstract protected Rect textPosToClient(TextPosition p); abstract protected TextPosition clientToTextPos(Point pt); abstract protected void ensureCaretVisible(); abstract protected Point measureVisibleText(); /// returns cursor rectangle protected Rect caretRect() { Rect caretRc = textPosToClient(_caretPos); if (_replaceMode) { dstring s = _content[_caretPos.line]; if (_caretPos.pos < s.length) { TextPosition nextPos = _caretPos; nextPos.pos++; Rect nextRect = textPosToClient(nextPos); caretRc.right = nextRect.right; } else { caretRc.right += _spaceWidth; } } caretRc.offset(_clientRect.left, _clientRect.top); return caretRc; } /// draws caret protected void drawCaret(DrawBuf buf) { if (focused) { // draw caret Rect caretRc = caretRect(); if (caretRc.intersects(_clientRect)) { Rect rc1 = caretRc; rc1.right = rc1.left + 1; caretRc.left++; if (_replaceMode) buf.fillRect(caretRc, 0x808080FF); buf.fillRect(rc1, 0x000000); } } } protected void updateFontProps() { FontRef font = font(); _fixedFont = font.isFixed; _spaceWidth = font.spaceWidth; _lineHeight = font.height; } /// when cursor position or selection is out of content bounds, fix it to nearest valid position protected void correctCaretPos() { _content.correctPosition(_caretPos); _content.correctPosition(_selectionRange.start); _content.correctPosition(_selectionRange.end); if (_selectionRange.empty) _selectionRange = TextRange(_caretPos, _caretPos); } private int[] _lineWidthBuf; protected int calcLineWidth(dstring s) { int w = 0; if (_fixedFont) { int tabw = _tabSize * _spaceWidth; // version optimized for fixed font for (int i = 0; i < s.length; i++) { if (s[i] == '\t') { w += _spaceWidth; w = (w + tabw - 1) / tabw * tabw; } else { w += _spaceWidth; } } } else { // variable pitch font if (_lineWidthBuf.length < s.length) _lineWidthBuf.length = s.length; int charsMeasured = font.measureText(s, _lineWidthBuf, int.max); if (charsMeasured > 0) w = _lineWidthBuf[charsMeasured - 1]; } return w; } protected void updateSelectionAfterCursorMovement(TextPosition oldCaretPos, bool selecting) { if (selecting) { if (oldCaretPos == _selectionRange.start) { if (_caretPos >= _selectionRange.end) { _selectionRange.start = _selectionRange.end; _selectionRange.end = _caretPos; } else { _selectionRange.start = _caretPos; } } else if (oldCaretPos == _selectionRange.end) { if (_caretPos < _selectionRange.start) { _selectionRange.end = _selectionRange.start; _selectionRange.start = _caretPos; } else { _selectionRange.end = _caretPos; } } else { _selectionRange.start = _caretPos; _selectionRange.end = _caretPos; } } else { _selectionRange.start = _caretPos; _selectionRange.end = _caretPos; } invalidate(); } protected void updateCaretPositionByMouse(int x, int y, bool selecting) { TextPosition oldCaretPos = _caretPos; TextPosition newPos = clientToTextPos(Point(x,y)); if (newPos != _caretPos) { _caretPos = newPos; updateSelectionAfterCursorMovement(oldCaretPos, selecting); invalidate(); } } /// generate string of spaces, to reach next tab position protected dstring spacesForTab(int currentPos) { int newPos = (currentPos + tabSize + 1) / tabSize * tabSize; return " "d[0..(newPos - currentPos)]; } /// returns true if one or more lines selected fully protected bool wholeLinesSelected() { return _selectionRange.end.line > _selectionRange.start.line && _selectionRange.end.pos == 0 && _selectionRange.start.pos == 0; } protected bool _camelCasePartsAsWords = true; protected bool removeSelectionTextIfSelected() { if (_selectionRange.empty) return false; // clear selection EditOperation op = new EditOperation(EditAction.Replace, _selectionRange, [""d]); _content.performOperation(op, this); ensureCaretVisible(); return true; } protected bool removeRangeText(TextRange range) { if (range.empty) return false; _selectionRange = range; _caretPos = _selectionRange.start; EditOperation op = new EditOperation(EditAction.Replace, range, [""d]); _content.performOperation(op, this); //_selectionRange.start = _caretPos; //_selectionRange.end = _caretPos; ensureCaretVisible(); return true; } override protected bool handleAction(const Action a) { TextPosition oldCaretPos = _caretPos; dstring currentLine = _content[_caretPos.line]; switch (a.id) { case EditorActions.Left: case EditorActions.SelectLeft: correctCaretPos(); if (_caretPos.pos > 0) { _caretPos.pos--; updateSelectionAfterCursorMovement(oldCaretPos, (a.id & 1) != 0); ensureCaretVisible(); } else if (_caretPos.line > 0) { _caretPos = _content.lineEnd(_caretPos.line - 1); updateSelectionAfterCursorMovement(oldCaretPos, (a.id & 1) != 0); ensureCaretVisible(); } return true; case EditorActions.Right: case EditorActions.SelectRight: correctCaretPos(); if (_caretPos.pos < currentLine.length) { _caretPos.pos++; updateSelectionAfterCursorMovement(oldCaretPos, (a.id & 1) != 0); ensureCaretVisible(); } else if (_caretPos.line < _content.length) { _caretPos.pos = 0; _caretPos.line++; updateSelectionAfterCursorMovement(oldCaretPos, (a.id & 1) != 0); ensureCaretVisible(); } return true; case EditorActions.WordLeft: case EditorActions.SelectWordLeft: { TextPosition newpos = _content.moveByWord(_caretPos, -1, _camelCasePartsAsWords); if (newpos != _caretPos) { _caretPos = newpos; updateSelectionAfterCursorMovement(oldCaretPos, a.id == EditorActions.SelectWordLeft); ensureCaretVisible(); } } return true; case EditorActions.WordRight: case EditorActions.SelectWordRight: { TextPosition newpos = _content.moveByWord(_caretPos, 1, _camelCasePartsAsWords); if (newpos != _caretPos) { _caretPos = newpos; updateSelectionAfterCursorMovement(oldCaretPos, a.id == EditorActions.SelectWordRight); ensureCaretVisible(); } } return true; case EditorActions.DocumentBegin: case EditorActions.SelectDocumentBegin: if (_caretPos.pos > 0 || _caretPos.line > 0) { _caretPos.line = 0; _caretPos.pos = 0; ensureCaretVisible(); updateSelectionAfterCursorMovement(oldCaretPos, (a.id & 1) != 0); } return true; case EditorActions.LineBegin: case EditorActions.SelectLineBegin: if (_caretPos.pos > 0) { _caretPos.pos = 0; ensureCaretVisible(); updateSelectionAfterCursorMovement(oldCaretPos, (a.id & 1) != 0); } return true; case EditorActions.DocumentEnd: case EditorActions.SelectDocumentEnd: if (_caretPos.line < _content.length - 1 || _caretPos.pos < _content[_content.length - 1].length) { _caretPos.line = _content.length - 1; _caretPos.pos = cast(int)_content[_content.length - 1].length; ensureCaretVisible(); updateSelectionAfterCursorMovement(oldCaretPos, (a.id & 1) != 0); } return true; case EditorActions.LineEnd: case EditorActions.SelectLineEnd: if (_caretPos.pos < currentLine.length) { _caretPos.pos = cast(int)currentLine.length; ensureCaretVisible(); updateSelectionAfterCursorMovement(oldCaretPos, (a.id & 1) != 0); } return true; case EditorActions.DelPrevWord: if (readOnly) return true; correctCaretPos(); if (removeSelectionTextIfSelected()) // clear selection return true; TextPosition newpos = _content.moveByWord(_caretPos, -1, _camelCasePartsAsWords); if (newpos < _caretPos) removeRangeText(TextRange(newpos, _caretPos)); return true; case EditorActions.DelNextWord: if (readOnly) return true; correctCaretPos(); if (removeSelectionTextIfSelected()) // clear selection return true; TextPosition newpos = _content.moveByWord(_caretPos, 1, _camelCasePartsAsWords); if (newpos > _caretPos) removeRangeText(TextRange(_caretPos, newpos)); return true; case EditorActions.DelPrevChar: if (readOnly) return true; correctCaretPos(); if (removeSelectionTextIfSelected()) // clear selection return true; if (_caretPos.pos > 0) { // delete prev char in current line TextRange range = TextRange(_caretPos, _caretPos); range.start.pos--; removeRangeText(range); } else if (_caretPos.line > 0) { // merge with previous line TextRange range = TextRange(_caretPos, _caretPos); range.start = _content.lineEnd(range.start.line - 1); removeRangeText(range); } return true; case EditorActions.DelNextChar: if (readOnly) return true; correctCaretPos(); if (removeSelectionTextIfSelected()) // clear selection return true; if (_caretPos.pos < currentLine.length) { // delete char in current line TextRange range = TextRange(_caretPos, _caretPos); range.end.pos++; removeRangeText(range); } else if (_caretPos.line < _content.length - 1) { // merge with next line TextRange range = TextRange(_caretPos, _caretPos); range.end.line++; range.end.pos = 0; removeRangeText(range); } return true; case EditorActions.Copy: if (!_selectionRange.empty) { dstring selectionText = concatDStrings(_content.rangeText(_selectionRange)); platform.setClipboardText(selectionText); } return true; case EditorActions.Cut: if (!_selectionRange.empty) { dstring selectionText = concatDStrings(_content.rangeText(_selectionRange)); platform.setClipboardText(selectionText); if (readOnly) return true; EditOperation op = new EditOperation(EditAction.Replace, _selectionRange, [""d]); _content.performOperation(op, this); } return true; case EditorActions.Paste: { if (readOnly) return true; dstring selectionText = platform.getClipboardText(); dstring[] lines; if (_content.multiline) { lines = splitDString(selectionText); } else { lines = [replaceEolsWithSpaces(selectionText)]; } EditOperation op = new EditOperation(EditAction.Replace, _selectionRange, lines); _content.performOperation(op, this); } return true; case EditorActions.Undo: { if (readOnly) return true; _content.undo(); } return true; case EditorActions.Redo: { if (readOnly) return true; _content.redo(); } return true; case EditorActions.Tab: { if (readOnly) return true; if (_selectionRange.empty) { if (_useSpacesForTabs) { // insert one or more spaces to EditOperation op = new EditOperation(EditAction.Replace, TextRange(_caretPos, _caretPos), [spacesForTab(_caretPos.pos)]); _content.performOperation(op, this); } else { // just insert tab character EditOperation op = new EditOperation(EditAction.Replace, TextRange(_caretPos, _caretPos), ["\t"d]); _content.performOperation(op, this); } } else { if (wholeLinesSelected()) { // indent range indentRange(false); } else { // insert tab if (_useSpacesForTabs) { // insert one or more spaces to EditOperation op = new EditOperation(EditAction.Replace, _selectionRange, [spacesForTab(_selectionRange.start.pos)]); _content.performOperation(op, this); } else { // just insert tab character EditOperation op = new EditOperation(EditAction.Replace, _selectionRange, ["\t"d]); _content.performOperation(op, this); } } } } return true; case EditorActions.BackTab: { if (readOnly) return true; if (_selectionRange.empty) { // remove spaces before caret TextRange r = spaceBefore(_caretPos); if (!r.empty) { EditOperation op = new EditOperation(EditAction.Replace, r, [""d]); _content.performOperation(op, this); } } else { if (wholeLinesSelected()) { // unindent range indentRange(true); } else { // remove space before selection TextRange r = spaceBefore(_selectionRange.start); if (!r.empty) { int nchars = r.end.pos - r.start.pos; TextRange saveRange = _selectionRange; TextPosition saveCursor = _caretPos; EditOperation op = new EditOperation(EditAction.Replace, r, [""d]); _content.performOperation(op, this); if (saveCursor.line == saveRange.start.line) saveCursor.pos -= nchars; if (saveRange.end.line == saveRange.start.line) saveRange.end.pos -= nchars; saveRange.start.pos -= nchars; _selectionRange = saveRange; _caretPos = saveCursor; ensureCaretVisible(); } } } } return true; case EditorActions.ToggleReplaceMode: replaceMode = !replaceMode; return true; case EditorActions.SelectAll: _selectionRange.start.line = 0; _selectionRange.start.pos = 0; _selectionRange.end = _content.lineEnd(_content.length - 1); _caretPos = _selectionRange.end; ensureCaretVisible(); return true; default: break; } return super.handleAction(a); } protected TextRange spaceBefore(TextPosition pos) { TextRange res = TextRange(pos, pos); dstring s = _content[pos.line]; int x = 0; int start = -1; for (int i = 0; i < pos.pos; i++) { dchar ch = s[i]; if (ch == ' ') { if (start == -1 || (x % tabSize) == 0) start = i; x++; } else if (ch == '\t') { if (start == -1 || (x % tabSize) == 0) start = i; x = (x + tabSize + 1) / tabSize * tabSize; } else { x++; start = -1; } } if (start != -1) { res.start.pos = start; } return res; } /// change line indent protected dstring indentLine(dstring src, bool back) { int firstNonSpace = -1; int x = 0; int unindentPos = -1; for (int i = 0; i < src.length; i++) { dchar ch = src[i]; if (ch == ' ') { x++; } else if (ch == '\t') { x = (x + tabSize + 1) / tabSize * tabSize; } else { firstNonSpace = i; break; } if (x <= tabSize) unindentPos = i + 1; } if (firstNonSpace == -1) // only spaces or empty line -- do not change it return src; if (back) { // unindent if (unindentPos == -1) return src; // no change if (unindentPos == src.length) return ""d; return src[unindentPos .. $].dup; } else { // indent if (_useSpacesForTabs) { return spacesForTab(0) ~ src; } else { return "\t"d ~ src; } } } /// indent / unindent range protected void indentRange(bool back) { int lineCount = _selectionRange.end.line - _selectionRange.start.line; dstring[] newContent = new dstring[lineCount + 1]; bool changed = false; for (int i = 0; i < lineCount; i++) { dstring srcline = _content.line(_selectionRange.start.line + i); dstring dstline = indentLine(srcline, back); newContent[i] = dstline; if (dstline.length != srcline.length) changed = true; } if (changed) { TextRange saveRange = _selectionRange; TextPosition saveCursor = _caretPos; EditOperation op = new EditOperation(EditAction.Replace, _selectionRange, newContent); _content.performOperation(op, this); _selectionRange = saveRange; _caretPos = saveCursor; ensureCaretVisible(); } } /// map key to action override protected Action findKeyAction(uint keyCode, uint flags) { // don't handle tabs when disabled if (keyCode == KeyCode.TAB && (flags == 0 || flags == KeyFlag.Shift) && (!_wantTabs || readOnly)) return null; return super.findKeyAction(keyCode, flags); } /// handle keys override bool onKeyEvent(KeyEvent event) { if (event.action == KeyAction.Text && event.text.length && !(event.flags & (KeyFlag.Control | KeyFlag.Alt))) { Log.d("text entered: ", event.text); if (readOnly) return true; dchar ch = event.text[0]; if (replaceMode && _selectionRange.empty && _content[_caretPos.line].length >= _caretPos.pos + event.text.length) { // replace next char(s) TextRange range = _selectionRange; range.end.pos += cast(int)event.text.length; EditOperation op = new EditOperation(EditAction.Replace, range, [event.text]); _content.performOperation(op, this); } else { EditOperation op = new EditOperation(EditAction.Replace, _selectionRange, [event.text]); _content.performOperation(op, this); } return true; } return super.onKeyEvent(event); } /// process mouse event; return true if event is processed by widget. override bool onMouseEvent(MouseEvent event) { //Log.d("onMouseEvent ", id, " ", event.action, " (", event.x, ",", event.y, ")"); // support onClick if (event.action == MouseAction.ButtonDown && event.button == MouseButton.Left) { setFocus(); updateCaretPositionByMouse(event.x - _clientRect.left, event.y - _clientRect.top, false); invalidate(); return true; } if (event.action == MouseAction.Move && (event.flags & MouseButton.Left) != 0) { updateCaretPositionByMouse(event.x - _clientRect.left, event.y - _clientRect.top, true); return true; } if (event.action == MouseAction.ButtonUp && event.button == MouseButton.Left) { return true; } if (event.action == MouseAction.FocusOut || event.action == MouseAction.Cancel) { return true; } if (event.action == MouseAction.FocusIn) { return true; } if (event.action == MouseAction.Wheel) { uint keyFlags = event.flags & (MouseFlag.Shift | MouseFlag.Control | MouseFlag.Alt); if (event.wheelDelta < 0) { if (keyFlags == MouseFlag.Shift) return handleAction(new Action(EditorActions.ScrollRight)); if (keyFlags == MouseFlag.Control) return handleAction(new Action(EditorActions.ZoomOut)); return handleAction(new Action(EditorActions.ScrollLineDown)); } else if (event.wheelDelta > 0) { if (keyFlags == MouseFlag.Shift) return handleAction(new Action(EditorActions.ScrollLeft)); if (keyFlags == MouseFlag.Control) return handleAction(new Action(EditorActions.ZoomIn)); return handleAction(new Action(EditorActions.ScrollLineUp)); } } return super.onMouseEvent(event); } } interface EditorActionHandler { bool onEditorAction(const Action action); } /// single line editor class EditLine : EditWidgetBase { Signal!EditorActionHandler editorActionListener; /// empty parameter list constructor - for usage by factory this() { this(null); } /// create with ID parameter this(string ID, dstring initialContent = null) { super(ID, ScrollBarMode.Invisible, ScrollBarMode.Invisible); _content = new EditableContent(false); _content.contentChangeListeners = this; wantTabs = false; styleId = STYLE_EDIT_LINE; text = initialContent; } protected dstring _measuredText; protected int[] _measuredTextWidths; protected Point _measuredTextSize; override protected Rect textPosToClient(TextPosition p) { Rect res; res.bottom = _clientRect.height; if (p.pos == 0) res.left = 0; else if (p.pos >= _measuredText.length) res.left = _measuredTextSize.x; else res.left = _measuredTextWidths[p.pos - 1]; res.left -= _scrollPos.x; res.right = res.left + 1; return res; } override protected TextPosition clientToTextPos(Point pt) { pt.x += _scrollPos.x; TextPosition res; for (int i = 0; i < _measuredText.length; i++) { int x0 = i > 0 ? _measuredTextWidths[i - 1] : 0; int x1 = _measuredTextWidths[i]; int mx = (x0 + x1) >> 1; if (pt.x < mx) { res.pos = i; return res; } } res.pos = cast(int)_measuredText.length; return res; } override protected void ensureCaretVisible() { //_scrollPos Rect rc = textPosToClient(_caretPos); if (rc.left < 0) { // scroll left _scrollPos.x -= -rc.left + _clientRect.width / 10; if (_scrollPos.x < 0) _scrollPos.x = 0; invalidate(); } else if (rc.left >= _clientRect.width - 10) { // scroll right _scrollPos.x += (rc.left - _clientRect.width) + _spaceWidth * 4; invalidate(); } updateScrollBars(); } override protected Point measureVisibleText() { FontRef font = font(); //Point sz = font.textSize(text); _measuredText = text; _measuredTextWidths.length = _measuredText.length; int charsMeasured = font.measureText(_measuredText, _measuredTextWidths, int.max, tabSize); _measuredTextSize.x = charsMeasured > 0 ? _measuredTextWidths[charsMeasured - 1]: 0; _measuredTextSize.y = font.height; return _measuredTextSize; } /// measure override void measure(int parentWidth, int parentHeight) { updateFontProps(); measureVisibleText(); measuredContent(parentWidth, parentHeight, _measuredTextSize.x + _leftPaneWidth, _measuredTextSize.y); } override protected bool handleAction(const Action a) { switch (a.id) { case EditorActions.InsertNewLine: case EditorActions.PrependNewLine: if (editorActionListener.assigned) { return editorActionListener(a); } break; case EditorActions.Up: break; case EditorActions.Down: break; case EditorActions.PageUp: break; case EditorActions.PageDown: break; default: break; } return super.handleAction(a); } /// handle keys override bool onKeyEvent(KeyEvent event) { return super.onKeyEvent(event); } /// process mouse event; return true if event is processed by widget. override bool onMouseEvent(MouseEvent event) { return super.onMouseEvent(event); } /// Set widget rectangle to specified value and layout widget contents. (Step 2 of two phase layout). override void layout(Rect rc) { if (visibility == Visibility.Gone) { return; } _needLayout = false; Point sz = Point(rc.width, measuredHeight); applyAlign(rc, sz); _pos = rc; _clientRect = rc; applyMargins(_clientRect); applyPadding(_clientRect); } /// override to custom highlight of line background protected void drawLineBackground(DrawBuf buf, Rect lineRect, Rect visibleRect) { if (!_selectionRange.empty) { // line inside selection Rect startrc = textPosToClient(_selectionRange.start); Rect endrc = textPosToClient(_selectionRange.end); int startx = startrc.left + _clientRect.left; int endx = endrc.left + _clientRect.left; Rect rc = lineRect; rc.left = startx; rc.right = endx; if (!rc.empty) { // draw selection rect for line buf.fillRect(rc, focused ? _selectionColorFocused : _selectionColorNormal); } if (_leftPaneWidth > 0) { Rect leftPaneRect = visibleRect; leftPaneRect.right = leftPaneRect.left; leftPaneRect.left -= _leftPaneWidth; drawLeftPane(buf, leftPaneRect, 0); } } } /// draw content override void onDraw(DrawBuf buf) { if (visibility != Visibility.Visible) return; super.onDraw(buf); Rect rc = _pos; applyMargins(rc); applyPadding(rc); auto saver = ClipRectSaver(buf, rc, alpha); FontRef font = font(); dstring txt = text; Point sz = font.textSize(txt); //applyAlign(rc, sz); Rect lineRect = _clientRect; lineRect.left = _clientRect.left - _scrollPos.x; lineRect.right = lineRect.left + calcLineWidth(txt); Rect visibleRect = lineRect; visibleRect.left = _clientRect.left; visibleRect.right = _clientRect.right; drawLineBackground(buf, lineRect, visibleRect); font.drawText(buf, rc.left - _scrollPos.x, rc.top, txt, textColor, tabSize); drawCaret(buf); } } /// single line editor class EditBox : EditWidgetBase { /// empty parameter list constructor - for usage by factory this() { this(null); } /// create with ID parameter this(string ID, dstring initialContent = null, ScrollBarMode hscrollbarMode = ScrollBarMode.Visible, ScrollBarMode vscrollbarMode = ScrollBarMode.Visible) { super(ID, hscrollbarMode, vscrollbarMode); _content = new EditableContent(true); // multiline _content.contentChangeListeners = this; styleId = STYLE_EDIT_BOX; text = initialContent; acceleratorMap.add( [ // zoom new Action(EditorActions.ZoomIn, KeyCode.ADD, KeyFlag.Control), new Action(EditorActions.ZoomOut, KeyCode.SUB, KeyFlag.Control), ]); } protected int _firstVisibleLine; protected int _maxLineWidth; protected int _numVisibleLines; // number of lines visible in client area protected dstring[] _visibleLines; // text for visible lines protected int[][] _visibleLinesMeasurement; // char positions for visible lines protected int[] _visibleLinesWidths; // width (in pixels) of visible lines protected CustomCharProps[][] _visibleLinesHighlights; override protected int lineCount() { return _content.length; } override protected void updateMaxLineWidth() { // find max line width. TODO: optimize!!! int maxw; int[] buf; for (int i = 0; i < _content.length; i++) { dstring s = _content[i]; int w = calcLineWidth(s); if (maxw < w) maxw = w; } _maxLineWidth = maxw; } @property int minFontSize() { return _minFontSize; } @property EditBox minFontSize(int size) { _minFontSize = size; return this; } @property int maxFontSize() { return _maxFontSize; } @property EditBox maxFontSize(int size) { _maxFontSize = size; return this; } override protected Point measureVisibleText() { Point sz; FontRef font = font(); _lineHeight = font.height; _numVisibleLines = (_clientRect.height + _lineHeight - 1) / _lineHeight; if (_firstVisibleLine + _numVisibleLines > _content.length) _numVisibleLines = _content.length - _firstVisibleLine; _visibleLines.length = _numVisibleLines; _visibleLinesMeasurement.length = _numVisibleLines; _visibleLinesWidths.length = _numVisibleLines; _visibleLinesHighlights.length = _numVisibleLines; for (int i = 0; i < _numVisibleLines; i++) { _visibleLines[i] = _content[_firstVisibleLine + i]; _visibleLinesMeasurement[i].length = _visibleLines[i].length; _visibleLinesHighlights[i] = handleCustomLineHighlight(_firstVisibleLine + i, _visibleLines[i]); int charsMeasured = font.measureText(_visibleLines[i], _visibleLinesMeasurement[i], int.max, tabSize); _visibleLinesWidths[i] = charsMeasured > 0 ? _visibleLinesMeasurement[i][charsMeasured - 1] : 0; if (sz.x < _visibleLinesWidths[i]) sz.x = _visibleLinesWidths[i]; // width - max from visible lines } sz.x = _maxLineWidth; sz.y = _lineHeight * _content.length; // height - for all lines return sz; } /// update horizontal scrollbar widget position override protected void updateHScrollBar() { _hscrollbar.setRange(0, _maxLineWidth + _clientRect.width / 4); _hscrollbar.pageSize = _clientRect.width; _hscrollbar.position = _scrollPos.x; } /// update verticat scrollbar widget position override protected void updateVScrollBar() { int visibleLines = _clientRect.height / _lineHeight; // fully visible lines if (visibleLines < 1) visibleLines = 1; _vscrollbar.setRange(0, _content.length - 1); _vscrollbar.pageSize = visibleLines; _vscrollbar.position = _firstVisibleLine; } /// process horizontal scrollbar event override bool onHScroll(ScrollEvent event) { if (event.action == ScrollAction.SliderMoved || event.action == ScrollAction.SliderReleased) { if (_scrollPos.x != event.position) { _scrollPos.x = event.position; invalidate(); } } else if (event.action == ScrollAction.PageUp) { dispatchAction(new Action(EditorActions.ScrollLeft)); } else if (event.action == ScrollAction.PageDown) { dispatchAction(new Action(EditorActions.ScrollRight)); } else if (event.action == ScrollAction.LineUp) { dispatchAction(new Action(EditorActions.ScrollLeft)); } else if (event.action == ScrollAction.LineDown) { dispatchAction(new Action(EditorActions.ScrollRight)); } return true; } /// process vertical scrollbar event override bool onVScroll(ScrollEvent event) { if (event.action == ScrollAction.SliderMoved || event.action == ScrollAction.SliderReleased) { if (_firstVisibleLine != event.position) { _firstVisibleLine = event.position; measureVisibleText(); invalidate(); } } else if (event.action == ScrollAction.PageUp) { dispatchAction(new Action(EditorActions.ScrollPageUp)); } else if (event.action == ScrollAction.PageDown) { dispatchAction(new Action(EditorActions.ScrollPageDown)); } else if (event.action == ScrollAction.LineUp) { dispatchAction(new Action(EditorActions.ScrollLineUp)); } else if (event.action == ScrollAction.LineDown) { dispatchAction(new Action(EditorActions.ScrollLineDown)); } return true; } override protected void ensureCaretVisible() { if (_caretPos.line >= _content.length) _caretPos.line = _content.length - 1; if (_caretPos.line < 0) _caretPos.line = 0; int visibleLines = _clientRect.height / _lineHeight; // fully visible lines if (visibleLines < 1) visibleLines = 1; if (_caretPos.line < _firstVisibleLine) { _firstVisibleLine = _caretPos.line; measureVisibleText(); invalidate(); } else if (_caretPos.line >= _firstVisibleLine + visibleLines) { _firstVisibleLine = _caretPos.line - visibleLines + 1; if (_firstVisibleLine < 0) _firstVisibleLine = 0; measureVisibleText(); invalidate(); } //_scrollPos Rect rc = textPosToClient(_caretPos); if (rc.left < 0) { // scroll left _scrollPos.x -= -rc.left + _clientRect.width / 4; if (_scrollPos.x < 0) _scrollPos.x = 0; invalidate(); } else if (rc.left >= _clientRect.width - 10) { // scroll right _scrollPos.x += (rc.left - _clientRect.width) + _clientRect.width / 4; invalidate(); } updateScrollBars(); } override protected Rect textPosToClient(TextPosition p) { Rect res; int lineIndex = p.line - _firstVisibleLine; res.top = lineIndex * _lineHeight; res.bottom = res.top + _lineHeight; if (lineIndex >=0 && lineIndex < _visibleLines.length) { if (p.pos == 0) res.left = 0; else if (p.pos >= _visibleLinesMeasurement[lineIndex].length) res.left = _visibleLinesWidths[lineIndex]; else res.left = _visibleLinesMeasurement[lineIndex][p.pos - 1]; } res.left -= _scrollPos.x; res.right = res.left + 1; return res; } override protected TextPosition clientToTextPos(Point pt) { TextPosition res; pt.x += _scrollPos.x; int lineIndex = pt.y / _lineHeight; if (lineIndex < 0) lineIndex = 0; if (lineIndex < _visibleLines.length) { res.line = lineIndex + _firstVisibleLine; for (int i = 0; i < _visibleLinesMeasurement[lineIndex].length; i++) { int x0 = i > 0 ? _visibleLinesMeasurement[lineIndex][i - 1] : 0; int x1 = _visibleLinesMeasurement[lineIndex][i]; int mx = (x0 + x1) >> 1; if (pt.x < mx) { res.pos = i; return res; } } res.pos = cast(int)_visibleLines[lineIndex].length; } else if (_visibleLines.length > 0) { res.line = _firstVisibleLine + cast(int)_visibleLines.length - 1; res.pos = cast(int)_visibleLines[$ - 1].length; } else { res.line = 0; res.pos = 0; } return res; } override protected bool handleAction(const Action a) { TextPosition oldCaretPos = _caretPos; dstring currentLine = _content[_caretPos.line]; switch (a.id) { case EditorActions.PrependNewLine: { correctCaretPos(); _caretPos.pos = 0; EditOperation op = new EditOperation(EditAction.Replace, _selectionRange, [""d, ""d]); _content.performOperation(op, this); } return true; case EditorActions.InsertNewLine: { correctCaretPos(); EditOperation op = new EditOperation(EditAction.Replace, _selectionRange, [""d, ""d]); _content.performOperation(op, this); } return true; case EditorActions.Up: case EditorActions.SelectUp: if (_caretPos.line > 0) { _caretPos.line--; updateSelectionAfterCursorMovement(oldCaretPos, (a.id & 1) != 0); ensureCaretVisible(); } return true; case EditorActions.Down: case EditorActions.SelectDown: if (_caretPos.line < _content.length - 1) { _caretPos.line++; updateSelectionAfterCursorMovement(oldCaretPos, (a.id & 1) != 0); ensureCaretVisible(); } return true; case EditorActions.PageBegin: case EditorActions.SelectPageBegin: { ensureCaretVisible(); _caretPos.line = _firstVisibleLine; updateSelectionAfterCursorMovement(oldCaretPos, (a.id & 1) != 0); } return true; case EditorActions.PageEnd: case EditorActions.SelectPageEnd: { ensureCaretVisible(); int fullLines = _clientRect.height / _lineHeight; int newpos = _firstVisibleLine + fullLines - 1; if (newpos >= _content.length) newpos = _content.length - 1; _caretPos.line = newpos; updateSelectionAfterCursorMovement(oldCaretPos, (a.id & 1) != 0); } return true; case EditorActions.PageUp: case EditorActions.SelectPageUp: { ensureCaretVisible(); int fullLines = _clientRect.height / _lineHeight; int newpos = _firstVisibleLine - fullLines; if (newpos < 0) { _firstVisibleLine = 0; _caretPos.line = 0; } else { int delta = _firstVisibleLine - newpos; _firstVisibleLine = newpos; _caretPos.line -= delta; } measureVisibleText(); updateScrollBars(); updateSelectionAfterCursorMovement(oldCaretPos, (a.id & 1) != 0); } return true; case EditorActions.PageDown: case EditorActions.SelectPageDown: { ensureCaretVisible(); int fullLines = _clientRect.height / _lineHeight; int newpos = _firstVisibleLine + fullLines; if (newpos >= _content.length) { _caretPos.line = _content.length - 1; } else { int delta = newpos - _firstVisibleLine; _firstVisibleLine = newpos; _caretPos.line += delta; } measureVisibleText(); updateScrollBars(); updateSelectionAfterCursorMovement(oldCaretPos, (a.id & 1) != 0); } return true; case EditorActions.ScrollLeft: { if (_scrollPos.x > 0) { int newpos = _scrollPos.x - _spaceWidth * 4; if (newpos < 0) newpos = 0; _scrollPos.x = newpos; updateScrollBars(); invalidate(); } } return true; case EditorActions.ScrollRight: { if (_scrollPos.x < _maxLineWidth - _clientRect.width) { int newpos = _scrollPos.x + _spaceWidth * 4; if (newpos > _maxLineWidth - _clientRect.width) newpos = _maxLineWidth - _clientRect.width; _scrollPos.x = newpos; updateScrollBars(); invalidate(); } } return true; case EditorActions.ScrollLineUp: { if (_firstVisibleLine > 0) { _firstVisibleLine -= 3; if (_firstVisibleLine < 0) _firstVisibleLine = 0; measureVisibleText(); updateScrollBars(); invalidate(); } } return true; case EditorActions.ScrollPageUp: { int fullLines = _clientRect.height / _lineHeight; if (_firstVisibleLine > 0) { _firstVisibleLine -= fullLines * 3 / 4; if (_firstVisibleLine < 0) _firstVisibleLine = 0; measureVisibleText(); updateScrollBars(); invalidate(); } } return true; case EditorActions.ScrollLineDown: { int fullLines = _clientRect.height / _lineHeight; if (_firstVisibleLine + fullLines < _content.length) { _firstVisibleLine += 3; if (_firstVisibleLine > _content.length - fullLines) _firstVisibleLine = _content.length - fullLines; if (_firstVisibleLine < 0) _firstVisibleLine = 0; measureVisibleText(); updateScrollBars(); invalidate(); } } return true; case EditorActions.ScrollPageDown: { int fullLines = _clientRect.height / _lineHeight; if (_firstVisibleLine + fullLines < _content.length) { _firstVisibleLine += fullLines * 3 / 4; if (_firstVisibleLine > _content.length - fullLines) _firstVisibleLine = _content.length - fullLines; if (_firstVisibleLine < 0) _firstVisibleLine = 0; measureVisibleText(); updateScrollBars(); invalidate(); } } return true; case EditorActions.ZoomIn: { if (_minFontSize < _maxFontSize && _minFontSize >= 9 && _maxFontSize >= 9) { int currentFontSize = fontSize; int increment = currentFontSize >= 30 ? 2 : 1; int newFontSize = currentFontSize + increment; //* 110 / 100; if (currentFontSize != newFontSize && newFontSize <= _maxFontSize) { fontSize = cast(ushort)newFontSize; updateFontProps(); measureVisibleText(); updateScrollBars(); invalidate(); } } } return true; case EditorActions.ZoomOut: { if (_minFontSize < _maxFontSize && _minFontSize >= 9 && _maxFontSize >= 9) { int currentFontSize = fontSize; int increment = currentFontSize >= 30 ? 2 : 1; int newFontSize = currentFontSize - increment; //* 100 / 110; if (currentFontSize != newFontSize && newFontSize >= _minFontSize) { fontSize = cast(ushort)newFontSize; updateFontProps(); measureVisibleText(); updateScrollBars(); invalidate(); } } } return true; default: break; } return super.handleAction(a); } /// calculate full content size in pixels override Point fullContentSize() { Point textSz = measureVisibleText(); int maxy = _lineHeight * 5; // limit measured height if (textSz.y > maxy) textSz.y = maxy; return textSz; } /// measure override void measure(int parentWidth, int parentHeight) { if (visibility == Visibility.Gone) { return; } updateFontProps(); updateMaxLineWidth(); super.measure(parentWidth, parentHeight); // do we need to add vsbwidth, hsbheight ??? //measuredContent(parentWidth, parentHeight, textSz.x + vsbwidth, textSz.y + hsbheight); } /// override to custom highlight of line background protected void drawLineBackground(DrawBuf buf, int lineIndex, Rect lineRect, Rect visibleRect) { // highlight odd lines //if ((lineIndex & 1)) // buf.fillRect(visibleRect, 0xF4808080); if (!_selectionRange.empty && _selectionRange.start.line <= lineIndex && _selectionRange.end.line >= lineIndex) { // line inside selection Rect startrc = textPosToClient(_selectionRange.start); Rect endrc = textPosToClient(_selectionRange.end); int startx = lineIndex == _selectionRange.start.line ? startrc.left + _clientRect.left : lineRect.left; int endx = lineIndex == _selectionRange.end.line ? endrc.left + _clientRect.left : lineRect.right + _spaceWidth; Rect rc = lineRect; rc.left = startx; rc.right = endx; if (!rc.empty) { // draw selection rect for line buf.fillRect(rc, focused ? _selectionColorFocused : _selectionColorNormal); } } // frame around current line if (focused && lineIndex == _caretPos.line && _selectionRange.singleLine && _selectionRange.start.line == _caretPos.line) { buf.drawFrame(visibleRect, 0xA0808080, Rect(1,1,1,1)); } } override protected void drawExtendedArea(DrawBuf buf) { if (_leftPaneWidth <= 0) return; Rect rc = _clientRect; FontRef font = font(); int i = _firstVisibleLine; int lc = lineCount; for (;;) { Rect lineRect = rc; lineRect.left = _clientRect.left - _leftPaneWidth; lineRect.right = _clientRect.left; lineRect.bottom = lineRect.top + _lineHeight; if (lineRect.top >= _clientRect.bottom) break; drawLeftPane(buf, lineRect, i < lc ? i : -1); i++; rc.top += _lineHeight; } } protected CustomCharProps[ubyte] _tokenHighlightColors; /// set highlight options for particular token category void setTokenHightlightColor(ubyte tokenCategory, uint color, bool underline = false, bool strikeThrough = false) { _tokenHighlightColors[tokenCategory] = CustomCharProps(color, underline, strikeThrough); } /// clear highlight colors void clearTokenHightlightColors() { destroy(_tokenHighlightColors); } /** Custom text color and style highlight (using text highlight) support. Return null if no syntax highlight required for line. */ protected CustomCharProps[] handleCustomLineHighlight(int line, dstring txt) { if (!_tokenHighlightColors) return null; // no highlight colors set TokenPropString tokenProps = _content.lineTokenProps(line); if (tokenProps.length > 0) { bool hasNonzeroTokens = false; foreach(t; tokenProps) if (t) { hasNonzeroTokens = true; break; } if (!hasNonzeroTokens) return null; // all characters are of unknown token type (or white space) CustomCharProps[] colors = new CustomCharProps[tokenProps.length]; for (int i = 0; i < tokenProps.length; i++) { ubyte p = tokenProps[i]; if (p in _tokenHighlightColors) colors[i] = _tokenHighlightColors[p]; else if ((p & TOKEN_CATEGORY_MASK) in _tokenHighlightColors) colors[i] = _tokenHighlightColors[(p & TOKEN_CATEGORY_MASK)]; else colors[i].color = textColor; if (isFullyTransparentColor(colors[i].color)) colors[i].color = textColor; } return colors; } return null; } override protected void drawClient(DrawBuf buf) { Rect rc = _clientRect; FontRef font = font(); for (int i = 0; i < _visibleLines.length; i++) { dstring txt = _visibleLines[i]; Rect lineRect = rc; lineRect.left = _clientRect.left - _scrollPos.x; lineRect.right = lineRect.left + calcLineWidth(_content[_firstVisibleLine + i]); lineRect.top = _clientRect.top + i * _lineHeight; lineRect.bottom = lineRect.top + _lineHeight; Rect visibleRect = lineRect; visibleRect.left = _clientRect.left; visibleRect.right = _clientRect.right; drawLineBackground(buf, _firstVisibleLine + i, lineRect, visibleRect); if (_leftPaneWidth > 0) { Rect leftPaneRect = visibleRect; leftPaneRect.right = leftPaneRect.left; leftPaneRect.left -= _leftPaneWidth; drawLeftPane(buf, leftPaneRect, 0); } if (txt.length > 0) { CustomCharProps[] highlight = _visibleLinesHighlights[i]; if (highlight) font.drawColoredText(buf, rc.left - _scrollPos.x, rc.top + i * _lineHeight, txt, highlight, tabSize); else font.drawText(buf, rc.left - _scrollPos.x, rc.top + i * _lineHeight, txt, textColor, tabSize); } } drawCaret(buf); } } /// Read only edit box for displaying logs with lines append operation class LogWidget : EditBox { protected int _maxLines; /// max lines to show (when appended more than max lines, older lines will be truncated), 0 means no limit @property int maxLines() { return _maxLines; } /// set max lines to show (when appended more than max lines, older lines will be truncated), 0 means no limit @property void maxLines(int n) { _maxLines = n; } protected bool _scrollLock; /// when true, automatically scrolls down when new lines are appended (usually being reset by scrollbar interaction) @property bool scrollLock() { return _scrollLock; } /// when true, automatically scrolls down when new lines are appended (usually being reset by scrollbar interaction) @property void scrollLock(bool flg) { _scrollLock = flg; } this(string ID) { super(ID); _scrollLock = true; enabled = false; fontSize = 12; fontFace = "Consolas,Lucida Console,Courier New"; fontFamily = FontFamily.MonoSpace; } /// append lines to the end of text void appendLines(dstring[] lines...) { lines ~= ""d; // append new line after last line content.appendLines(lines); if (_maxLines > 0 && lineCount > _maxLines) { TextRange range; range.end.line = lineCount - _maxLines; EditOperation op = new EditOperation(EditAction.Replace, range, [""d]); _content.performOperation(op, this); } updateScrollBars(); if (_scrollLock) { _caretPos = TextPosition(lineCount > 0 ? lineCount - 1 : 0, 0); ensureCaretVisible(); } } }