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 }