1 /** 2 Copyright: Copyright (c) 2017-2018 Andrey Penechko. 3 License: $(WEB boost.org/LICENSE_1_0.txt, Boost License 1.0). 4 Authors: Andrey Penechko. 5 */ 6 module voxelman.gui.textedit.texteditor; 7 8 import std.stdio : writefln, writeln; 9 10 import voxelman.container.buffer; 11 import voxelman.container.chunkedrange; 12 import voxelman.graphics; 13 import voxelman.gui; 14 import voxelman.math; 15 import voxelman.platform; 16 import voxelman.text.linebuffer; 17 import voxelman.text.scale; 18 19 import voxelman.gui.textedit.cursor; 20 import voxelman.gui.textedit.linebuffer; 21 import voxelman.gui.textedit.textbuffer; 22 import voxelman.gui.textedit.undostack; 23 import voxelman.gui.textedit.textmodel; 24 25 alias lvec2 = Vector!(long, 2); 26 27 // update - called after size is optionally changed 28 // render - called after all updates 29 30 enum HighlightStyle 31 { 32 text, 33 number 34 } 35 36 struct StyleSlice 37 { 38 HighlightStyle style; 39 uint length; 40 } 41 42 TextEditor loadFileAsDocument(string filename = "test.txt") 43 { 44 import std.file : read, exists; 45 import std.path : absolutePath; 46 47 string textData; 48 49 if (exists(filename)) 50 { 51 writefln("load %s", absolutePath(filename)); 52 textData = cast(string)read(filename); 53 } 54 else 55 { 56 writeln("new empty file"); 57 } 58 59 return createTextEditor(textData, filename); 60 } 61 62 TextEditor createTextEditor(string textData = null, string filename = null) 63 { 64 return TextEditor(textData, filename); 65 } 66 67 class EditorTextModel : TextModel 68 { 69 TextEditorRef editor; 70 this(TextEditorRef ed) { editor = ed; } 71 bool isEditable() { return true; } 72 73 int numLines() { return editor.lines.numLines; } 74 int lastLine() { return editor.lines.lastLine; } 75 ChunkedRange!char opSlice(size_t from, size_t to) { return editor.textData[from..to].toChunkedRange; } 76 LineInfo lineInfo(int line) { return editor.lines.lineInfo(line); } 77 78 void onCommand(EditorCommand com) { editor.onCommand(com); } 79 void replaceSelection(const(char)[] str) { editor.replaceSelection(str); } 80 ref Selection selection() { return editor.selection; } 81 //ivec2 textSizeInGlyphs() { return editor.textSizeInGlyphs; } 82 void moveSelectionCursor(MoveCommand com, bool extendSelection) { editor.moveSelectionCursor(com, extendSelection); } 83 } 84 85 /// Default constructed TextEditor is not valid. Use constructor 86 alias TextEditorRef = TextEditor*; 87 struct TextEditor 88 { 89 // text data 90 private PieceTable textData; 91 private string filename; 92 private LineInfoBuffer lines; 93 private ivec2 textSizeInGlyphs; 94 private Selection selection; 95 mixin ReadHelpers!(); 96 mixin WriteHelpers!(); 97 98 this(string textData, string filename) 99 { 100 this.textData = textData; 101 this.filename = filename; 102 textSizeInGlyphs.y = lines.calc(textData); 103 textSizeInGlyphs.x = lines.maxLineSize; 104 } 105 106 void onCommand(EditorCommand com) 107 { 108 switch(com.type) 109 { 110 case EditorCommandType.insert_eol: 111 replaceSelection("\n"); 112 break; 113 case EditorCommandType.insert_tab: 114 replaceSelection("\t"); 115 break; 116 case EditorCommandType.delete_left_char: 117 if (selection.empty) 118 { 119 auto leftCur = moveCursor(selection.end, MoveCommand.move_left_char); 120 removeAndUpdateSelection(Selection(leftCur, selection.end)); 121 } 122 else removeAndUpdateSelection(selection); 123 break; 124 case EditorCommandType.delete_left_word: 125 126 break; 127 case EditorCommandType.delete_left_line: 128 129 break; 130 case EditorCommandType.delete_right_char: 131 if (selection.empty) 132 { 133 auto rightCur = moveCursor(selection.end, MoveCommand.move_right_char); 134 removeAndUpdateSelection(Selection(selection.end, rightCur)); 135 } 136 else removeAndUpdateSelection(selection); 137 break; 138 case EditorCommandType.delete_right_word: 139 break; 140 case EditorCommandType.delete_right_line: 141 break; 142 case EditorCommandType.cut: 143 cutSelection(); 144 break; 145 case EditorCommandType.copy: 146 copySelection(); 147 break; 148 case EditorCommandType.paste: 149 //MonoTime pasteStart = MonoTime.currTime; 150 auto str = clipboard; 151 replaceSelection(str); 152 //writefln("pasted %sB in %ss", 153 // scaledNumberFmt(str.length), 154 // scaledNumberFmt(MonoTime.currTime-pasteStart)); 155 break; 156 case EditorCommandType.select_all: 157 selection = Selection(Cursor(0,0), Cursor(lines.textEnd, lines.lastLine)); 158 break; 159 case EditorCommandType.undo: 160 undo(); 161 break; 162 case EditorCommandType.redo: 163 redo(); 164 break; 165 default: 166 break; 167 } 168 } 169 } 170 171 mixin template ReadHelpers() 172 { 173 //import core.time : MonoTime; 174 import std.utf : stride, strideBack; 175 import std.stdio : writefln, writeln; 176 import voxelman.text.scale; 177 // uses 178 // T textData; 179 // L lines; 180 // Selection selection; 181 182 void delegate(string) setClipboard; 183 void clipboard(S)(S str) { 184 if (!setClipboard) return; 185 import std.experimental.allocator.mallocator; 186 auto buf = cast(char[])Mallocator.instance.allocate(str.length+1); 187 buf[str.length] = '\0'; 188 str.copyInto(buf[0..str.length]); 189 //auto t1 = MonoTime.currTime; 190 setClipboard(cast(string)buf[0..str.length]); 191 //auto t2 = MonoTime.currTime; 192 Mallocator.instance.deallocate(buf); 193 //writefln("clipboard copy %ss", scaledNumberFmt(t2 - t1)); 194 } 195 196 void moveSelectionCursor(MoveCommand com, bool extendSelection) 197 { 198 selection.end = moveCursor(selection.end, com); 199 if (!extendSelection) selection.start = selection.end; 200 } 201 202 void copySelection() 203 { 204 auto sel = getSelectionForCopyCut(); 205 writefln("copy %s %s", sel.start.byteOffset, sel.end.byteOffset); 206 copyText(sel.start.byteOffset, sel.end.byteOffset); 207 } 208 209 void copyText(size_t from, size_t to) 210 { 211 //MonoTime copyStart = MonoTime.currTime; 212 auto copiedText = textData[from..to].toChunkedRange.byItem; 213 214 clipboard = copiedText; 215 216 ////MonoTime copyEnd =// MonoTime.currTime; 217 //writefln("copied %sB in %ss", scaledNumberFmt(copiedText.length), scaledNumberFmt(copyEnd-copyStart)); 218 } 219 220 Selection getSelectionForCopyCut() 221 { 222 if (selection.empty) 223 { 224 // select whole line 225 auto line = selection.end.line; 226 auto info = lines[line]; 227 return Selection(Cursor(info.startOffset, line), 228 Cursor(info.nextStartOffset, line)); 229 } 230 else 231 return selection.normalized; 232 } 233 234 uint strideAt(size_t offset) 235 { 236 auto str = textData[offset..$]; 237 return stride(str); 238 } 239 240 size_t nextOffset(size_t offset) 241 { 242 return offset + strideAt(offset); 243 } 244 245 size_t prevOffset(size_t offset) 246 { 247 return offset - strideBack(textData[0u..offset]); 248 } 249 250 Cursor moveCursor(Cursor cur, MoveCommand com) 251 { 252 auto lineInfo = lines.lineInfo(cur.line); 253 254 final switch(com) with(MoveCommand) 255 { 256 case move_right_char: 257 if (cur.byteOffset == lineInfo.endOffset) 258 { 259 if (cur.line < lines.lastLine) 260 { 261 ++cur.line; 262 cur.byteOffset = lineInfo.nextStartOffset; 263 } 264 } 265 else 266 { 267 auto newOffset = nextOffset(cur.byteOffset); 268 cur.byteOffset = newOffset; 269 } 270 break; 271 case move_right_word: 272 break; 273 case move_left_char: 274 if (cur.byteOffset == lineInfo.startOffset) 275 { 276 if (cur.line > 0) 277 { 278 --cur.line; 279 cur.byteOffset = lines.lineEndOffset(cur.line); 280 } 281 } 282 else 283 { 284 auto newOffset = prevOffset(cur.byteOffset); 285 cur.byteOffset = newOffset; 286 } 287 break; 288 case move_left_word: 289 break; 290 case move_up_line: 291 if (cur.line > 0) 292 { 293 --cur.line; 294 cur.byteOffset = lines.lineStartOffset(cur.line); 295 } 296 break; 297 case move_up_page: 298 break; 299 case move_down_line: 300 if (cur.line < lines.lastLine) 301 { 302 ++cur.line; 303 cur.byteOffset = lineInfo.nextStartOffset; 304 } 305 break; 306 case move_down_page: 307 break; 308 case move_to_bol: 309 cur.byteOffset = lineInfo.startOffset; 310 break; 311 case move_to_eol: 312 cur.byteOffset = lineInfo.endOffset; 313 break; 314 } 315 return cur; 316 } 317 } 318 319 mixin template WriteHelpers() 320 { 321 //import core.time : MonoTime; 322 // uses 323 // T textData; 324 // L lines; 325 // ReadHelpers; 326 327 private UndoStack!UndoItem undoStack; 328 329 string delegate() getClipboard; 330 string clipboard() { if (getClipboard) return getClipboard(); else return null; } 331 332 private void cutSelection() 333 { 334 auto sel = getSelectionForCopyCut(); 335 copyText(sel.start.byteOffset, sel.end.byteOffset); 336 removeAndUpdateSelection(sel); 337 } 338 339 void replaceSelection(const(char)[] str) 340 { 341 removeAndUpdateSelection(selection); 342 selection = emptySelection(insertText(selection.end, str)); 343 } 344 345 /// Returns pos after inserted text 346 Cursor insertText(Cursor cur, const(char)[] str) 347 { 348 if (str.length == 0) return cur; 349 350 auto pieceUndo = textData.insert(cur.byteOffset, str); 351 int insertedLines = lines.onPaste(cur, str); 352 auto afterInsertedText = Cursor(cur.byteOffset + str.length, cur.line + insertedLines); 353 auto undoItem = UndoItem(pieceUndo, cur, afterInsertedText, UndoCommand.undoInsert, selection); 354 putUndo(undoItem); 355 return afterInsertedText; 356 } 357 358 void removeAndUpdateSelection(Selection sel) 359 { 360 Selection normSel = sel.normalized; 361 removeText(normSel); 362 // position cursor at the start of removed text 363 selection = emptySelection(normSel.start); 364 } 365 366 void removeText(Selection sel) 367 { 368 removeText(sel.start, sel.end); 369 } 370 371 void removeText(Cursor from, Cursor to) 372 { 373 if (from == to) return; 374 auto pieceUndo = textData.remove(from.byteOffset, to.byteOffset-from.byteOffset); 375 auto undoItem = UndoItem(pieceUndo, from, to, UndoCommand.undoRemove, selection); 376 putUndo(undoItem); 377 lines.onRemove(from, to); 378 } 379 380 enum UndoCommand 381 { 382 undoInsert, 383 undoRemove 384 } 385 386 private static struct UndoItem 387 { 388 PieceRestoreRange pieceUndo; 389 Cursor from; 390 Cursor to; 391 UndoCommand undoCom; 392 Selection selectionUndo; 393 bool group; // Piece ranges in one group have the same flag 394 } 395 396 private UndoItem onUndoRedoAction(UndoItem undoItem) 397 { 398 // undo text operation 399 undoItem.pieceUndo = undoItem.pieceUndo.apply(textData.pieces.length); 400 401 // undo line buffer operation 402 final switch (undoItem.undoCom) 403 { 404 case UndoCommand.undoInsert: 405 lines.onRemove(undoItem.from, undoItem.to); 406 // now create undoRemove command 407 undoItem.undoCom = UndoCommand.undoRemove; 408 break; 409 410 case UndoCommand.undoRemove: 411 // this needs to happen after text undo, since it passes inserted text to line cache 412 lines.onPaste(undoItem.from, textData[undoItem.from.byteOffset..undoItem.to.byteOffset]); 413 // now create repeat remove command 414 undoItem.undoCom = UndoCommand.undoInsert; 415 break; 416 } 417 418 // collect current cursor info 419 Selection currentSel = selection; 420 // restore cursor 421 selection = undoItem.selectionUndo; 422 // store cursor undo 423 undoItem.selectionUndo = currentSel; 424 425 return undoItem; 426 } 427 428 private void putUndo(UndoItem undoItem) 429 { 430 undoStack.commitUndoItem(undoItem); 431 } 432 433 void undo() 434 { 435 undoStack.undo(&onUndoRedoAction); 436 } 437 438 void redo() 439 { 440 undoStack.redo(&onUndoRedoAction); 441 } 442 } 443 444 unittest 445 { 446 TextEditor ed = createTextEditor("aaaa"); 447 448 assert(ed.lines.textEnd == 4); 449 450 // Test single char insert 451 ed.selection = emptySelection(Cursor(2, 0)); 452 ed.replaceSelection("b"); 453 assert(ed.lines.textEnd == 5); 454 assert(ed.lines.numLines == 1); 455 assert(ed.textData[].equalDchars("aabaa")); 456 assert(ed.selection == emptySelection(Cursor(3, 0))); 457 458 // Test newline insert 459 ed.replaceSelection("\n"); 460 assert(ed.lines.textEnd == 6); 461 assert(ed.lines.numLines == 2); 462 assert(ed.textData[].equalDchars("aab\naa")); 463 assert(ed.selection == emptySelection(Cursor(4, 1))); 464 } 465 466 unittest 467 { 468 TextEditor ed = createTextEditor(); 469 470 assert(ed.lines.lastLine == 0); 471 assert(ed.lines.textEnd == 0); 472 assert(ed.lines.numLines == 1); 473 474 // Test insert in empty file 475 ed.replaceSelection("a"); 476 assert(ed.lines.textEnd == 1); 477 assert(ed.lines.numLines == 1); 478 assert(ed.lines.lastLine == 0); 479 assert(ed.textData[].equalDchars("a")); 480 assert(ed.selection == emptySelection(Cursor(1, 0))); 481 482 ed.replaceSelection("b"); 483 assert(ed.lines.textEnd == 2); 484 assert(ed.lines.numLines == 1); 485 assert(ed.lines.lastLine == 0); 486 assert(ed.textData[].equalDchars("ab")); 487 assert(ed.selection == emptySelection(Cursor(2, 0))); 488 } 489 490 unittest 491 { 492 TextEditor ed = createTextEditor("zz\nzz"); 493 494 assert(ed.lines.lastLine == 1); 495 assert(ed.lines.textEnd == 5); 496 assert(ed.lines.numLines == 2); 497 assert(ed.lines[0] == LineInfo(0, 2, 1)); 498 assert(ed.lines[1] == LineInfo(3, 2, 1)); 499 500 // Test insert in multiline file 501 ed.replaceSelection("a"); 502 assert(ed.lines.textEnd == 6); 503 assert(ed.lines.numLines == 2); 504 assert(ed.lines.lastLine == 1); 505 assert(ed.textData[].equalDchars("azz\nzz")); 506 assert(ed.selection == emptySelection(Cursor(1, 0))); 507 assert(ed.lines[0] == LineInfo(0, 3, 1)); 508 assert(ed.lines[1] == LineInfo(4, 2, 1)); 509 510 // Test correct attribute update of first line 511 // when inserting in col > 0 512 ed.replaceSelection("b"); 513 assert(ed.lines.textEnd == 7); 514 assert(ed.lines.numLines == 2); 515 assert(ed.lines.lastLine == 1); 516 assert(ed.textData[].equalDchars("abzz\nzz")); 517 assert(ed.selection == emptySelection(Cursor(2, 0))); 518 assert(ed.lines[0] == LineInfo(0, 4, 1)); 519 assert(ed.lines[1] == LineInfo(5, 2, 1)); 520 } 521 522 unittest 523 { 524 TextEditor ed = createTextEditor("zz\nzz"); 525 526 ed.selection = emptySelection(Cursor(1, 0)); 527 528 // Test inserting multiple lines 529 ed.replaceSelection("a\nb\nc"); 530 assert(ed.lines.textEnd == 10); 531 assert(ed.lines.numLines == 4); 532 assert(ed.lines.lastLine == 3); 533 assert(ed.textData[].equalDchars("za\nb\ncz\nzz")); 534 assert(ed.selection == emptySelection(Cursor(6, 2))); 535 assert(ed.lines[0] == LineInfo(0, 2, 1)); 536 assert(ed.lines[1] == LineInfo(3, 1, 1)); 537 assert(ed.lines[2] == LineInfo(5, 2, 1)); 538 assert(ed.lines[3] == LineInfo(8, 2, 1)); 539 } 540 541 unittest 542 { 543 TextEditor ed = createTextEditor("abcd"); 544 545 ed.selection = Selection(Cursor(1, 0), Cursor(3, 0)); 546 ed.removeAndUpdateSelection(ed.selection); 547 548 assert(ed.selection == emptySelection(Cursor(1, 0))); 549 assert(ed.textData[].equalDchars("ad")); 550 } 551 552 // Test undo of text, cursor and line cache 553 unittest 554 { 555 TextEditor ed = createTextEditor(); 556 557 assert(ed.lines.textEnd == 0); 558 assert(ed.textData[].equalDchars("")); 559 assert(ed.selection == emptySelection(Cursor(0, 0))); 560 561 ed.replaceSelection("a"); 562 563 assert(ed.lines.textEnd == 1); 564 assert(ed.textData[].equalDchars("a")); 565 assert(ed.selection == emptySelection(Cursor(1, 0))); 566 567 ed.undo(); 568 569 assert(ed.lines.textEnd == 0); 570 assert(ed.textData[].equalDchars("")); 571 assert(ed.selection == emptySelection(Cursor(0, 0))); 572 573 ed.redo(); 574 575 assert(ed.lines.textEnd == 1); 576 assert(ed.textData[].equalDchars("a")); 577 assert(ed.selection == emptySelection(Cursor(1, 0))); 578 }