wrap up TODOs
[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 // Constructors -----------------------------------------------------------
81 // ------------------------------------------------------------------------
82
83 /**
84 * Construct a new Document from an existing text string.
85 *
86 * @param str the text string
87 * @param defaultColor the color for unhighlighted text
88 */
89 public Document(final String str, final CellAttributes defaultColor) {
90 this.defaultColor = defaultColor;
91
92 // Set colors to resemble the Borland IDE colors, but for Java
93 // language keywords.
94 highlighter.setJavaColors();
95
96 String [] rawLines = str.split("\n");
97 for (int i = 0; i < rawLines.length; i++) {
98 lines.add(new Line(rawLines[i], this.defaultColor, highlighter));
99 }
100 }
101
102 // ------------------------------------------------------------------------
103 // Document ---------------------------------------------------------------
104 // ------------------------------------------------------------------------
105
106 /**
107 * Get the overwrite flag.
108 *
109 * @return true if addChar() overwrites data, false if it inserts
110 */
111 public boolean getOverwrite() {
112 return overwrite;
113 }
114
115 /**
116 * Get the dirty value.
117 *
118 * @return true if the buffer is dirty
119 */
120 public boolean isDirty() {
121 return dirty;
122 }
123
124 /**
125 * Save contents to file.
126 *
127 * @param filename file to save to
128 * @throws IOException if a java.io operation throws
129 */
130 public void saveToFilename(final String filename) throws IOException {
131 OutputStreamWriter output = null;
132 try {
133 output = new OutputStreamWriter(new FileOutputStream(filename),
134 "UTF-8");
135
136 for (Line line: lines) {
137 output.write(line.getRawString());
138 output.write("\n");
139 }
140
141 dirty = false;
142 }
143 finally {
144 if (output != null) {
145 output.close();
146 }
147 }
148 }
149
150 /**
151 * Set the overwrite flag.
152 *
153 * @param overwrite true if addChar() should overwrite data, false if it
154 * should insert
155 */
156 public void setOverwrite(final boolean overwrite) {
157 this.overwrite = overwrite;
158 }
159
160 /**
161 * Get the current line number being edited.
162 *
163 * @return the line number. Note that this is 0-based: 0 is the first
164 * line.
165 */
166 public int getLineNumber() {
167 return lineNumber;
168 }
169
170 /**
171 * Get the current editing line.
172 *
173 * @return the line
174 */
175 public Line getCurrentLine() {
176 return lines.get(lineNumber);
177 }
178
179 /**
180 * Get a specific line by number.
181 *
182 * @param lineNumber the line number. Note that this is 0-based: 0 is
183 * the first line.
184 * @return the line
185 */
186 public Line getLine(final int lineNumber) {
187 return lines.get(lineNumber);
188 }
189
190 /**
191 * Set the current line number being edited.
192 *
193 * @param n the line number. Note that this is 0-based: 0 is the first
194 * line.
195 */
196 public void setLineNumber(final int n) {
197 if ((n < 0) || (n > lines.size())) {
198 throw new IndexOutOfBoundsException("Lines array size is " +
199 lines.size() + ", requested index " + n);
200 }
201 lineNumber = n;
202 }
203
204 /**
205 * Get the current cursor position of the editing line.
206 *
207 * @return the cursor position
208 */
209 public int getCursor() {
210 return lines.get(lineNumber).getCursor();
211 }
212
213 /**
214 * Get the character at the current cursor position in the text.
215 *
216 * @return the character, or -1 if the cursor is at the end of the line
217 */
218 public int getChar() {
219 return lines.get(lineNumber).getChar();
220 }
221
222 /**
223 * Set the current cursor position of the editing line. 0-based.
224 *
225 * @param cursor the new cursor position
226 */
227 public void setCursor(final int cursor) {
228 if (cursor >= lines.get(lineNumber).getDisplayLength()) {
229 lines.get(lineNumber).end();
230 } else {
231 lines.get(lineNumber).setCursor(cursor);
232 }
233 }
234
235 /**
236 * Increment the line number by one. If at the last line, do nothing.
237 *
238 * @return true if the editing line changed
239 */
240 public boolean down() {
241 if (lineNumber < lines.size() - 1) {
242 int x = lines.get(lineNumber).getCursor();
243 lineNumber++;
244 if (x >= lines.get(lineNumber).getDisplayLength()) {
245 lines.get(lineNumber).end();
246 } else {
247 lines.get(lineNumber).setCursor(x);
248 }
249 return true;
250 }
251 return false;
252 }
253
254 /**
255 * Increment the line number by n. If n would go past the last line,
256 * increment only to the last line.
257 *
258 * @param n the number of lines to increment by
259 * @return true if the editing line changed
260 */
261 public boolean down(final int n) {
262 if (lineNumber < lines.size() - 1) {
263 int x = lines.get(lineNumber).getCursor();
264 lineNumber += n;
265 if (lineNumber > lines.size() - 1) {
266 lineNumber = lines.size() - 1;
267 }
268 if (x >= lines.get(lineNumber).getDisplayLength()) {
269 lines.get(lineNumber).end();
270 } else {
271 lines.get(lineNumber).setCursor(x);
272 }
273 return true;
274 }
275 return false;
276 }
277
278 /**
279 * Decrement the line number by one. If at the first line, do nothing.
280 *
281 * @return true if the editing line changed
282 */
283 public boolean up() {
284 if (lineNumber > 0) {
285 int x = lines.get(lineNumber).getCursor();
286 lineNumber--;
287 if (x >= lines.get(lineNumber).getDisplayLength()) {
288 lines.get(lineNumber).end();
289 } else {
290 lines.get(lineNumber).setCursor(x);
291 }
292 return true;
293 }
294 return false;
295 }
296
297 /**
298 * Decrement the line number by n. If n would go past the first line,
299 * decrement only to the first line.
300 *
301 * @param n the number of lines to decrement by
302 * @return true if the editing line changed
303 */
304 public boolean up(final int n) {
305 if (lineNumber > 0) {
306 int x = lines.get(lineNumber).getCursor();
307 lineNumber -= n;
308 if (lineNumber < 0) {
309 lineNumber = 0;
310 }
311 if (x >= lines.get(lineNumber).getDisplayLength()) {
312 lines.get(lineNumber).end();
313 } else {
314 lines.get(lineNumber).setCursor(x);
315 }
316 return true;
317 }
318 return false;
319 }
320
321 /**
322 * Decrement the cursor by one. If at the first column on the first
323 * line, do nothing.
324 *
325 * @return true if the cursor position changed
326 */
327 public boolean left() {
328 if (!lines.get(lineNumber).left()) {
329 // We are on the leftmost column, wrap
330 if (up()) {
331 end();
332 } else {
333 return false;
334 }
335 }
336 return true;
337 }
338
339 /**
340 * Increment the cursor by one. If at the last column on the last line,
341 * do nothing.
342 *
343 * @return true if the cursor position changed
344 */
345 public boolean right() {
346 if (!lines.get(lineNumber).right()) {
347 // We are on the rightmost column, wrap
348 if (down()) {
349 home();
350 } else {
351 return false;
352 }
353 }
354 return true;
355 }
356
357 /**
358 * Go back to the beginning of this word if in the middle, or the
359 * beginning of the previous word.
360 */
361 public void backwardsWord() {
362
363 // If at the beginning of a word already, push past it.
364 if ((getChar() != -1)
365 && (getRawLine().length() > 0)
366 && !Character.isWhitespace((char) getChar())
367 ) {
368 left();
369 }
370
371 // int line = lineNumber;
372 while ((getChar() == -1)
373 || (getRawLine().length() == 0)
374 || Character.isWhitespace((char) getChar())
375 ) {
376 if (left() == false) {
377 return;
378 }
379 }
380
381
382 assert (getChar() != -1);
383
384 if (!Character.isWhitespace((char) getChar())
385 && (getRawLine().length() > 0)
386 ) {
387 // Advance until at the beginning of the document or a whitespace
388 // is encountered.
389 while (!Character.isWhitespace((char) getChar())) {
390 int line = lineNumber;
391 if (left() == false) {
392 // End of document, bail out.
393 return;
394 }
395 if (lineNumber != line) {
396 // We wrapped a line. Here that counts as whitespace.
397 right();
398 return;
399 }
400 }
401 }
402
403 // We went one past the word, push back to the first character of
404 // that word.
405 right();
406 return;
407 }
408
409 /**
410 * Go to the beginning of the next word.
411 */
412 public void forwardsWord() {
413 int line = lineNumber;
414 while ((getChar() == -1)
415 || (getRawLine().length() == 0)
416 ) {
417 if (right() == false) {
418 return;
419 }
420 if (lineNumber != line) {
421 // We wrapped a line. Here that counts as whitespace.
422 if (!Character.isWhitespace((char) getChar())) {
423 // We found a character immediately after the line.
424 // Done!
425 return;
426 }
427 // Still looking...
428 line = lineNumber;
429 }
430 }
431 assert (getChar() != -1);
432
433 if (!Character.isWhitespace((char) getChar())
434 && (getRawLine().length() > 0)
435 ) {
436 // Advance until at the end of the document or a whitespace is
437 // encountered.
438 while (!Character.isWhitespace((char) getChar())) {
439 line = lineNumber;
440 if (right() == false) {
441 // End of document, bail out.
442 return;
443 }
444 if (lineNumber != line) {
445 // We wrapped a line. Here that counts as whitespace.
446 if (!Character.isWhitespace((char) getChar())
447 && (getRawLine().length() > 0)
448 ) {
449 // We found a character immediately after the line.
450 // Done!
451 return;
452 }
453 break;
454 }
455 }
456 }
457
458 while ((getChar() == -1)
459 || (getRawLine().length() == 0)
460 ) {
461 if (right() == false) {
462 return;
463 }
464 if (lineNumber != line) {
465 // We wrapped a line. Here that counts as whitespace.
466 if (!Character.isWhitespace((char) getChar())) {
467 // We found a character immediately after the line.
468 // Done!
469 return;
470 }
471 // Still looking...
472 line = lineNumber;
473 }
474 }
475 assert (getChar() != -1);
476
477 if (Character.isWhitespace((char) getChar())) {
478 // Advance until at the end of the document or a non-whitespace
479 // is encountered.
480 while (Character.isWhitespace((char) getChar())) {
481 if (right() == false) {
482 // End of document, bail out.
483 return;
484 }
485 }
486 return;
487 }
488
489 // We wrapped the line to get here.
490 return;
491 }
492
493 /**
494 * Get the raw string that matches this line.
495 *
496 * @return the string
497 */
498 public String getRawLine() {
499 return lines.get(lineNumber).getRawString();
500 }
501
502 /**
503 * Go to the first column of this line.
504 *
505 * @return true if the cursor position changed
506 */
507 public boolean home() {
508 return lines.get(lineNumber).home();
509 }
510
511 /**
512 * Go to the last column of this line.
513 *
514 * @return true if the cursor position changed
515 */
516 public boolean end() {
517 return lines.get(lineNumber).end();
518 }
519
520 /**
521 * Delete the character under the cursor.
522 */
523 public void del() {
524 dirty = true;
525 int cursor = lines.get(lineNumber).getCursor();
526 if (cursor < lines.get(lineNumber).getDisplayLength() - 1) {
527 lines.get(lineNumber).del();
528 } else if (lineNumber < lines.size() - 2) {
529 // Join two lines
530 StringBuilder newLine = new StringBuilder(lines.
531 get(lineNumber).getRawString());
532 newLine.append(lines.get(lineNumber + 1).getRawString());
533 lines.set(lineNumber, new Line(newLine.toString(),
534 defaultColor, highlighter));
535 lines.get(lineNumber).setCursor(cursor);
536 lines.remove(lineNumber + 1);
537 }
538 }
539
540 /**
541 * Delete the character immediately preceeding the cursor.
542 */
543 public void backspace() {
544 dirty = true;
545 int cursor = lines.get(lineNumber).getCursor();
546 if (cursor > 0) {
547 lines.get(lineNumber).backspace();
548 } else if (lineNumber > 0) {
549 // Join two lines
550 lineNumber--;
551 String firstLine = lines.get(lineNumber).getRawString();
552 if (firstLine.length() > 0) {
553 // Backspacing combining two lines
554 StringBuilder newLine = new StringBuilder(firstLine);
555 newLine.append(lines.get(lineNumber + 1).getRawString());
556 lines.set(lineNumber, new Line(newLine.toString(),
557 defaultColor, highlighter));
558 lines.get(lineNumber).setCursor(firstLine.length());
559 lines.remove(lineNumber + 1);
560 } else {
561 // Backspacing an empty line
562 lines.remove(lineNumber);
563 lines.get(lineNumber).setCursor(0);
564 }
565 }
566 }
567
568 /**
569 * Split the current line into two, like pressing the enter key.
570 */
571 public void enter() {
572 dirty = true;
573 int cursor = lines.get(lineNumber).getRawCursor();
574 String original = lines.get(lineNumber).getRawString();
575 String firstLine = original.substring(0, cursor);
576 String secondLine = original.substring(cursor);
577 lines.add(lineNumber + 1, new Line(secondLine, defaultColor,
578 highlighter));
579 lines.set(lineNumber, new Line(firstLine, defaultColor, highlighter));
580 lineNumber++;
581 lines.get(lineNumber).home();
582 }
583
584 /**
585 * Replace or insert a character at the cursor, depending on overwrite
586 * flag.
587 *
588 * @param ch the character to replace or insert
589 */
590 public void addChar(final int ch) {
591 dirty = true;
592 if (overwrite) {
593 lines.get(lineNumber).replaceChar(ch);
594 } else {
595 lines.get(lineNumber).addChar(ch);
596 }
597 }
598
599 /**
600 * Get a (shallow) copy of the list of lines.
601 *
602 * @return the list of lines
603 */
604 public List<Line> getLines() {
605 return new ArrayList<Line>(lines);
606 }
607
608 /**
609 * Get the number of lines.
610 *
611 * @return the number of lines
612 */
613 public int getLineCount() {
614 return lines.size();
615 }
616
617 /**
618 * Compute the maximum line length for this document.
619 *
620 * @return the number of cells needed to display the longest line
621 */
622 public int getLineLengthMax() {
623 int n = 0;
624 for (Line line : lines) {
625 if (line.getDisplayLength() > n) {
626 n = line.getDisplayLength();
627 }
628 }
629 return n;
630 }
631
632 /**
633 * Get the current line length.
634 *
635 * @return the number of cells needed to display the current line
636 */
637 public int getLineLength() {
638 return lines.get(lineNumber).getDisplayLength();
639 }
640
641 }