Merge branch 'subtree'
[fanfix.git] / src / jexer / teditor / Document.java
1 /*
2 * Jexer - Java Text User Interface
3 *
4 * The MIT License (MIT)
5 *
6 * Copyright (C) 2019 Kevin Lamonte
7 *
8 * Permission is hereby granted, free of charge, to any person obtaining a
9 * copy of this software and associated documentation files (the "Software"),
10 * to deal in the Software without restriction, including without limitation
11 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
12 * and/or sell copies of the Software, and to permit persons to whom the
13 * Software is furnished to do so, subject to the following conditions:
14 *
15 * The above copyright notice and this permission notice shall be included in
16 * all copies or substantial portions of the Software.
17 *
18 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
21 * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
24 * DEALINGS IN THE SOFTWARE.
25 *
26 * @author Kevin Lamonte [kevin.lamonte@gmail.com]
27 * @version 1
28 */
29 package jexer.teditor;
30
31 import java.io.FileOutputStream;
32 import java.io.IOException;
33 import java.io.OutputStreamWriter;
34 import java.util.ArrayList;
35 import java.util.List;
36
37 import jexer.bits.CellAttributes;
38
39 /**
40 * A Document represents a text file, as a collection of lines.
41 */
42 public class Document {
43
44 // ------------------------------------------------------------------------
45 // Variables --------------------------------------------------------------
46 // ------------------------------------------------------------------------
47
48 /**
49 * The list of lines.
50 */
51 private ArrayList<Line> lines = new ArrayList<Line>();
52
53 /**
54 * The current line number being edited. Note that this is 0-based, the
55 * first line is line number 0.
56 */
57 private int lineNumber = 0;
58
59 /**
60 * The overwrite flag. When true, characters overwrite data.
61 */
62 private boolean overwrite = false;
63
64 /**
65 * If true, the document has been edited.
66 */
67 private boolean dirty = false;
68
69 /**
70 * The default color for the TEditor class.
71 */
72 private CellAttributes defaultColor = null;
73
74 /**
75 * The text highlighter to use.
76 */
77 private Highlighter highlighter = new Highlighter();
78
79 /**
80 * The tab stop size.
81 */
82 private int tabSize = 8;
83
84 /**
85 * If true, backspace at an indent level goes back a full indent level.
86 * If false, backspace always goes back one column.
87 */
88 private boolean backspaceUnindents = false;
89
90 /**
91 * If true, save files with tab characters. If false, convert tabs to
92 * spaces when saving files.
93 */
94 private boolean saveWithTabs = false;
95
96 // ------------------------------------------------------------------------
97 // Constructors -----------------------------------------------------------
98 // ------------------------------------------------------------------------
99
100 /**
101 * Construct a new Document from an existing text string.
102 *
103 * @param str the text string
104 * @param defaultColor the color for unhighlighted text
105 */
106 public Document(final String str, final CellAttributes defaultColor) {
107 this.defaultColor = defaultColor;
108
109 // Set colors to resemble the Borland IDE colors, but for Java
110 // language keywords.
111 highlighter.setJavaColors();
112
113 String [] rawLines = str.split("\n");
114 for (int i = 0; i < rawLines.length; i++) {
115 lines.add(new Line(rawLines[i], this.defaultColor, highlighter));
116 }
117 }
118
119 /**
120 * Private constructor used by dup().
121 */
122 private Document() {
123 // NOP
124 }
125
126 // ------------------------------------------------------------------------
127 // Document ---------------------------------------------------------------
128 // ------------------------------------------------------------------------
129
130 /**
131 * Create a duplicate instance.
132 *
133 * @return duplicate intance
134 */
135 public Document dup() {
136 Document other = new Document();
137 for (Line line: lines) {
138 other.lines.add(line.dup());
139 }
140 other.lineNumber = lineNumber;
141 other.overwrite = overwrite;
142 other.dirty = dirty;
143 other.defaultColor = defaultColor;
144 other.highlighter.setTo(highlighter);
145 return other;
146 }
147
148 /**
149 * Get the overwrite flag.
150 *
151 * @return true if addChar() overwrites data, false if it inserts
152 */
153 public boolean isOverwrite() {
154 return overwrite;
155 }
156
157 /**
158 * Get the dirty value.
159 *
160 * @return true if the buffer is dirty
161 */
162 public boolean isDirty() {
163 return dirty;
164 }
165
166 /**
167 * Unset the dirty flag.
168 */
169 public void setNotDirty() {
170 dirty = false;
171 }
172
173 /**
174 * Save contents to file.
175 *
176 * @param filename file to save to
177 * @throws IOException if a java.io operation throws
178 */
179 public void saveToFilename(final String filename) throws IOException {
180 OutputStreamWriter output = null;
181 try {
182 output = new OutputStreamWriter(new FileOutputStream(filename),
183 "UTF-8");
184
185 for (Line line: lines) {
186 if (saveWithTabs) {
187 output.write(convertSpacesToTabs(line.getRawString()));
188 } else {
189 output.write(line.getRawString());
190 }
191 output.write("\n");
192 }
193
194 dirty = false;
195 }
196 finally {
197 if (output != null) {
198 output.close();
199 }
200 }
201 }
202
203 /**
204 * Set the overwrite flag.
205 *
206 * @param overwrite true if addChar() should overwrite data, false if it
207 * should insert
208 */
209 public void setOverwrite(final boolean overwrite) {
210 this.overwrite = overwrite;
211 }
212
213 /**
214 * Get the current line number being edited.
215 *
216 * @return the line number. Note that this is 0-based: 0 is the first
217 * line.
218 */
219 public int getLineNumber() {
220 return lineNumber;
221 }
222
223 /**
224 * Get the current editing line.
225 *
226 * @return the line
227 */
228 public Line getCurrentLine() {
229 return lines.get(lineNumber);
230 }
231
232 /**
233 * Get a specific line by number.
234 *
235 * @param lineNumber the line number. Note that this is 0-based: 0 is
236 * the first line.
237 * @return the line
238 */
239 public Line getLine(final int lineNumber) {
240 return lines.get(lineNumber);
241 }
242
243 /**
244 * Set the current line number being edited.
245 *
246 * @param n the line number. Note that this is 0-based: 0 is the first
247 * line.
248 */
249 public void setLineNumber(final int n) {
250 if ((n < 0) || (n > lines.size())) {
251 throw new IndexOutOfBoundsException("Lines array size is " +
252 lines.size() + ", requested index " + n);
253 }
254 lineNumber = n;
255 }
256
257 /**
258 * Get the current cursor position of the editing line.
259 *
260 * @return the cursor position
261 */
262 public int getCursor() {
263 return lines.get(lineNumber).getCursor();
264 }
265
266 /**
267 * Get the character at the current cursor position in the text.
268 *
269 * @return the character, or -1 if the cursor is at the end of the line
270 */
271 public int getChar() {
272 return lines.get(lineNumber).getChar();
273 }
274
275 /**
276 * Set the current cursor position of the editing line. 0-based.
277 *
278 * @param cursor the new cursor position
279 */
280 public void setCursor(final int cursor) {
281 if (cursor >= lines.get(lineNumber).getDisplayLength()) {
282 lines.get(lineNumber).end();
283 } else {
284 lines.get(lineNumber).setCursor(cursor);
285 }
286 }
287
288 /**
289 * Increment the line number by one. If at the last line, do nothing.
290 *
291 * @return true if the editing line changed
292 */
293 public boolean down() {
294 if (lineNumber < lines.size() - 1) {
295 int x = lines.get(lineNumber).getCursor();
296 lineNumber++;
297 if (x >= lines.get(lineNumber).getDisplayLength()) {
298 lines.get(lineNumber).end();
299 } else {
300 lines.get(lineNumber).setCursor(x);
301 }
302 return true;
303 }
304 return false;
305 }
306
307 /**
308 * Increment the line number by n. If n would go past the last line,
309 * increment only to the last line.
310 *
311 * @param n the number of lines to increment by
312 * @return true if the editing line changed
313 */
314 public boolean down(final int n) {
315 if (lineNumber < lines.size() - 1) {
316 int x = lines.get(lineNumber).getCursor();
317 lineNumber += n;
318 if (lineNumber > lines.size() - 1) {
319 lineNumber = lines.size() - 1;
320 }
321 if (x >= lines.get(lineNumber).getDisplayLength()) {
322 lines.get(lineNumber).end();
323 } else {
324 lines.get(lineNumber).setCursor(x);
325 }
326 return true;
327 }
328 return false;
329 }
330
331 /**
332 * Decrement the line number by one. If at the first line, do nothing.
333 *
334 * @return true if the editing line changed
335 */
336 public boolean up() {
337 if (lineNumber > 0) {
338 int x = lines.get(lineNumber).getCursor();
339 lineNumber--;
340 if (x >= lines.get(lineNumber).getDisplayLength()) {
341 lines.get(lineNumber).end();
342 } else {
343 lines.get(lineNumber).setCursor(x);
344 }
345 return true;
346 }
347 return false;
348 }
349
350 /**
351 * Decrement the line number by n. If n would go past the first line,
352 * decrement only to the first line.
353 *
354 * @param n the number of lines to decrement by
355 * @return true if the editing line changed
356 */
357 public boolean up(final int n) {
358 if (lineNumber > 0) {
359 int x = lines.get(lineNumber).getCursor();
360 lineNumber -= n;
361 if (lineNumber < 0) {
362 lineNumber = 0;
363 }
364 if (x >= lines.get(lineNumber).getDisplayLength()) {
365 lines.get(lineNumber).end();
366 } else {
367 lines.get(lineNumber).setCursor(x);
368 }
369 return true;
370 }
371 return false;
372 }
373
374 /**
375 * Decrement the cursor by one. If at the first column on the first
376 * line, do nothing.
377 *
378 * @return true if the cursor position changed
379 */
380 public boolean left() {
381 if (!lines.get(lineNumber).left()) {
382 // We are on the leftmost column, wrap
383 if (up()) {
384 end();
385 } else {
386 return false;
387 }
388 }
389 return true;
390 }
391
392 /**
393 * Increment the cursor by one. If at the last column on the last line,
394 * do nothing.
395 *
396 * @return true if the cursor position changed
397 */
398 public boolean right() {
399 if (!lines.get(lineNumber).right()) {
400 // We are on the rightmost column, wrap
401 if (down()) {
402 home();
403 } else {
404 return false;
405 }
406 }
407 return true;
408 }
409
410 /**
411 * Go back to the beginning of this word if in the middle, or the
412 * beginning of the previous word.
413 */
414 public void backwardsWord() {
415
416 // If at the beginning of a word already, push past it.
417 if ((getChar() != -1)
418 && (getRawLine().length() > 0)
419 && !Character.isWhitespace((char) getChar())
420 ) {
421 left();
422 }
423
424 // int line = lineNumber;
425 while ((getChar() == -1)
426 || (getRawLine().length() == 0)
427 || Character.isWhitespace((char) getChar())
428 ) {
429 if (left() == false) {
430 return;
431 }
432 }
433
434
435 assert (getChar() != -1);
436
437 if (!Character.isWhitespace((char) getChar())
438 && (getRawLine().length() > 0)
439 ) {
440 // Advance until at the beginning of the document or a whitespace
441 // is encountered.
442 while (!Character.isWhitespace((char) getChar())) {
443 int line = lineNumber;
444 if (left() == false) {
445 // End of document, bail out.
446 return;
447 }
448 if (lineNumber != line) {
449 // We wrapped a line. Here that counts as whitespace.
450 right();
451 return;
452 }
453 }
454 }
455
456 // We went one past the word, push back to the first character of
457 // that word.
458 right();
459 return;
460 }
461
462 /**
463 * Go to the beginning of the next word.
464 */
465 public void forwardsWord() {
466 int line = lineNumber;
467 while ((getChar() == -1)
468 || (getRawLine().length() == 0)
469 ) {
470 if (right() == false) {
471 return;
472 }
473 if (lineNumber != line) {
474 // We wrapped a line. Here that counts as whitespace.
475 if (!Character.isWhitespace((char) getChar())) {
476 // We found a character immediately after the line.
477 // Done!
478 return;
479 }
480 // Still looking...
481 line = lineNumber;
482 }
483 }
484 assert (getChar() != -1);
485
486 if (!Character.isWhitespace((char) getChar())
487 && (getRawLine().length() > 0)
488 ) {
489 // Advance until at the end of the document or a whitespace is
490 // encountered.
491 while (!Character.isWhitespace((char) getChar())) {
492 line = lineNumber;
493 if (right() == false) {
494 // End of document, bail out.
495 return;
496 }
497 if (lineNumber != line) {
498 // We wrapped a line. Here that counts as whitespace.
499 if (!Character.isWhitespace((char) getChar())
500 && (getRawLine().length() > 0)
501 ) {
502 // We found a character immediately after the line.
503 // Done!
504 return;
505 }
506 break;
507 }
508 }
509 }
510
511 while ((getChar() == -1)
512 || (getRawLine().length() == 0)
513 ) {
514 if (right() == false) {
515 return;
516 }
517 if (lineNumber != line) {
518 // We wrapped a line. Here that counts as whitespace.
519 if (!Character.isWhitespace((char) getChar())) {
520 // We found a character immediately after the line.
521 // Done!
522 return;
523 }
524 // Still looking...
525 line = lineNumber;
526 }
527 }
528 assert (getChar() != -1);
529
530 if (Character.isWhitespace((char) getChar())) {
531 // Advance until at the end of the document or a non-whitespace
532 // is encountered.
533 while (Character.isWhitespace((char) getChar())) {
534 if (right() == false) {
535 // End of document, bail out.
536 return;
537 }
538 }
539 return;
540 }
541
542 // We wrapped the line to get here.
543 return;
544 }
545
546 /**
547 * Get the raw string that matches this line.
548 *
549 * @return the string
550 */
551 public String getRawLine() {
552 return lines.get(lineNumber).getRawString();
553 }
554
555 /**
556 * Go to the first column of this line.
557 *
558 * @return true if the cursor position changed
559 */
560 public boolean home() {
561 return lines.get(lineNumber).home();
562 }
563
564 /**
565 * Go to the last column of this line.
566 *
567 * @return true if the cursor position changed
568 */
569 public boolean end() {
570 return lines.get(lineNumber).end();
571 }
572
573 /**
574 * Delete the character under the cursor.
575 */
576 public void del() {
577 dirty = true;
578 int cursor = lines.get(lineNumber).getCursor();
579 if (cursor < lines.get(lineNumber).getDisplayLength() - 1) {
580 lines.get(lineNumber).del();
581 } else if (lineNumber < lines.size() - 2) {
582 // Join two lines
583 StringBuilder newLine = new StringBuilder(lines.
584 get(lineNumber).getRawString());
585 newLine.append(lines.get(lineNumber + 1).getRawString());
586 lines.set(lineNumber, new Line(newLine.toString(),
587 defaultColor, highlighter));
588 lines.get(lineNumber).setCursor(cursor);
589 lines.remove(lineNumber + 1);
590 }
591 }
592
593 /**
594 * Delete the character immediately preceeding the cursor.
595 */
596 public void backspace() {
597 dirty = true;
598 int cursor = lines.get(lineNumber).getCursor();
599 if (cursor > 0) {
600 lines.get(lineNumber).backspace(tabSize, backspaceUnindents);
601 } else if (lineNumber > 0) {
602 // Join two lines
603 lineNumber--;
604 String firstLine = lines.get(lineNumber).getRawString();
605 if (firstLine.length() > 0) {
606 // Backspacing combining two lines
607 StringBuilder newLine = new StringBuilder(firstLine);
608 newLine.append(lines.get(lineNumber + 1).getRawString());
609 lines.set(lineNumber, new Line(newLine.toString(),
610 defaultColor, highlighter));
611 lines.get(lineNumber).setCursor(firstLine.length());
612 lines.remove(lineNumber + 1);
613 } else {
614 // Backspacing an empty line
615 lines.remove(lineNumber);
616 lines.get(lineNumber).setCursor(0);
617 }
618 }
619 }
620
621 /**
622 * Split the current line into two, like pressing the enter key.
623 */
624 public void enter() {
625 dirty = true;
626 int cursor = lines.get(lineNumber).getRawCursor();
627 String original = lines.get(lineNumber).getRawString();
628 String firstLine = original.substring(0, cursor);
629 String secondLine = original.substring(cursor);
630 lines.add(lineNumber + 1, new Line(secondLine, defaultColor,
631 highlighter));
632 lines.set(lineNumber, new Line(firstLine, defaultColor, highlighter));
633 lineNumber++;
634 lines.get(lineNumber).home();
635 }
636
637 /**
638 * Replace or insert a character at the cursor, depending on overwrite
639 * flag.
640 *
641 * @param ch the character to replace or insert
642 */
643 public void addChar(final int ch) {
644 dirty = true;
645 if (overwrite) {
646 lines.get(lineNumber).replaceChar(ch);
647 } else {
648 lines.get(lineNumber).addChar(ch);
649 }
650 }
651
652 /**
653 * Get the tab stop size.
654 *
655 * @return the tab stop size
656 */
657 public int getTabSize() {
658 return tabSize;
659 }
660
661 /**
662 * Set the tab stop size.
663 *
664 * @param tabSize the new tab stop size
665 */
666 public void setTabSize(final int tabSize) {
667 this.tabSize = tabSize;
668 }
669
670 /**
671 * Set the backspace unindent option.
672 *
673 * @param backspaceUnindents If true, backspace at an indent level goes
674 * back a full indent level. If false, backspace always goes back one
675 * column.
676 */
677 public void setBackspaceUnindents(final boolean backspaceUnindents) {
678 this.backspaceUnindents = backspaceUnindents;
679 }
680
681 /**
682 * Set the save with tabs option.
683 *
684 * @param saveWithTabs If true, save files with tab characters. If
685 * false, convert tabs to spaces when saving files.
686 */
687 public void setSaveWithTabs(final boolean saveWithTabs) {
688 this.saveWithTabs = saveWithTabs;
689 }
690
691 /**
692 * Handle the tab character.
693 */
694 public void tab() {
695 if (overwrite) {
696 del();
697 }
698 lines.get(lineNumber).tab(tabSize);
699 }
700
701 /**
702 * Handle the backtab (shift-tab) character.
703 */
704 public void backTab() {
705 lines.get(lineNumber).backTab(tabSize);
706 }
707
708 /**
709 * Get a (shallow) copy of the list of lines.
710 *
711 * @return the list of lines
712 */
713 public List<Line> getLines() {
714 return new ArrayList<Line>(lines);
715 }
716
717 /**
718 * Get the number of lines.
719 *
720 * @return the number of lines
721 */
722 public int getLineCount() {
723 return lines.size();
724 }
725
726 /**
727 * Compute the maximum line length for this document.
728 *
729 * @return the number of cells needed to display the longest line
730 */
731 public int getLineLengthMax() {
732 int n = 0;
733 for (Line line : lines) {
734 if (line.getDisplayLength() > n) {
735 n = line.getDisplayLength();
736 }
737 }
738 return n;
739 }
740
741 /**
742 * Get the current line length.
743 *
744 * @return the number of cells needed to display the current line
745 */
746 public int getLineLength() {
747 return lines.get(lineNumber).getDisplayLength();
748 }
749
750 /**
751 * Get the entire contents of the document as one string.
752 *
753 * @return the document contents
754 */
755 public String getText() {
756 StringBuilder sb = new StringBuilder();
757 for (Line line: getLines()) {
758 sb.append(line.getRawString());
759 sb.append("\n");
760 }
761 return sb.toString();
762 }
763
764 /**
765 * Trim trailing whitespace from lines and trailing empty
766 * lines from the document.
767 */
768 public void cleanWhitespace() {
769 for (Line line: getLines()) {
770 line.trimRight();
771 }
772 if (lines.size() == 0) {
773 return;
774 }
775 while (lines.get(lines.size() - 1).length() == 0) {
776 lines.remove(lines.size() - 1);
777 }
778 if (lineNumber > lines.size() - 1) {
779 lineNumber = lines.size() - 1;
780 }
781 }
782
783 /**
784 * Set keyword highlighting.
785 *
786 * @param enabled if true, enable keyword highlighting
787 */
788 public void setHighlighting(final boolean enabled) {
789 highlighter.setEnabled(enabled);
790 for (Line line: getLines()) {
791 line.scanLine();
792 }
793 }
794
795 /**
796 * Convert a string with leading spaces to a mix of tabs and spaces.
797 *
798 * @param string the string to convert
799 */
800 private String convertSpacesToTabs(final String string) {
801 if (string.length() == 0) {
802 return string;
803 }
804
805 int start = 0;
806 while (string.charAt(start) == ' ') {
807 start++;
808 }
809 int tabCount = start / 8;
810 if (tabCount == 0) {
811 return string;
812 }
813
814 StringBuilder sb = new StringBuilder(string.length());
815
816 for (int i = 0; i < tabCount; i++) {
817 sb.append('\t');
818 }
819 sb.append(string.substring(tabCount * 8));
820 return sb.toString();
821 }
822
823 }