Merge commit 'e6bb1700749980e69b5e913acbfd276f129c24dc'
[nikiroo-utils.git] / src / jexer / teditor / Document.java
index 42082e55ad21a670b31a91e8ba6e9b7e0950344e..b4a9a3bfb6b3ab476d29b45b3a01b4fbdca77816 100644 (file)
@@ -3,7 +3,7 @@
  *
  * The MIT License (MIT)
  *
- * Copyright (C) 2017 Kevin Lamonte
+ * Copyright (C) 2019 Kevin Lamonte
  *
  * Permission is hereby granted, free of charge, to any person obtaining a
  * copy of this software and associated documentation files (the "Software"),
@@ -41,6 +41,10 @@ import jexer.bits.CellAttributes;
  */
 public class Document {
 
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
     /**
      * The list of lines.
      */
@@ -72,12 +76,81 @@ public class Document {
      */
     private Highlighter highlighter = new Highlighter();
 
+    /**
+     * The tab stop size.
+     */
+    private int tabSize = 8;
+
+    /**
+     * If true, backspace at an indent level goes back a full indent level.
+     * If false, backspace always goes back one column.
+     */
+    private boolean backspaceUnindents = false;
+
+    /**
+     * If true, save files with tab characters.  If false, convert tabs to
+     * spaces when saving files.
+     */
+    private boolean saveWithTabs = false;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Construct a new Document from an existing text string.
+     *
+     * @param str the text string
+     * @param defaultColor the color for unhighlighted text
+     */
+    public Document(final String str, final CellAttributes defaultColor) {
+        this.defaultColor = defaultColor;
+
+        // Set colors to resemble the Borland IDE colors, but for Java
+        // language keywords.
+        highlighter.setJavaColors();
+
+        String [] rawLines = str.split("\n");
+        for (int i = 0; i < rawLines.length; i++) {
+            lines.add(new Line(rawLines[i], this.defaultColor, highlighter));
+        }
+    }
+
+    /**
+     * Private constructor used by dup().
+     */
+    private Document() {
+        // NOP
+    }
+
+    // ------------------------------------------------------------------------
+    // Document ---------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Create a duplicate instance.
+     *
+     * @return duplicate intance
+     */
+    public Document dup() {
+        Document other = new Document();
+        for (Line line: lines) {
+            other.lines.add(line.dup());
+        }
+        other.lineNumber = lineNumber;
+        other.overwrite = overwrite;
+        other.dirty = dirty;
+        other.defaultColor = defaultColor;
+        other.highlighter.setTo(highlighter);
+        return other;
+    }
+
     /**
      * Get the overwrite flag.
      *
      * @return true if addChar() overwrites data, false if it inserts
      */
-    public boolean getOverwrite() {
+    public boolean isOverwrite() {
         return overwrite;
     }
 
@@ -90,6 +163,13 @@ public class Document {
         return dirty;
     }
 
+    /**
+     * Unset the dirty flag.
+     */
+    public void setNotDirty() {
+        dirty = false;
+    }
+
     /**
      * Save contents to file.
      *
@@ -103,7 +183,11 @@ public class Document {
                 "UTF-8");
 
             for (Line line: lines) {
-                output.write(line.getRawString());
+                if (saveWithTabs) {
+                    output.write(convertSpacesToTabs(line.getRawString()));
+                } else {
+                    output.write(line.getRawString());
+                }
                 output.write("\n");
             }
 
@@ -180,29 +264,24 @@ public class Document {
     }
 
     /**
-     * Set the current cursor position of the editing line.  0-based.
+     * Get the character at the current cursor position in the text.
      *
-     * @param cursor the new cursor position
+     * @return the character, or -1 if the cursor is at the end of the line
      */
-    public void setCursor(final int cursor) {
-        lines.get(lineNumber).setCursor(cursor);
+    public int getChar() {
+        return lines.get(lineNumber).getChar();
     }
 
     /**
-     * Construct a new Document from an existing text string.
+     * Set the current cursor position of the editing line.  0-based.
      *
-     * @param str the text string
-     * @param defaultColor the color for unhighlighted text
+     * @param cursor the new cursor position
      */
-    public Document(final String str, final CellAttributes defaultColor) {
-        this.defaultColor = defaultColor;
-
-        // TODO: set different colors based on file extension
-        highlighter.setJavaColors();
-
-        String [] rawLines = str.split("\n");
-        for (int i = 0; i < rawLines.length; i++) {
-            lines.add(new Line(rawLines[i], this.defaultColor, highlighter));
+    public void setCursor(final int cursor) {
+        if (cursor >= lines.get(lineNumber).getDisplayLength()) {
+            lines.get(lineNumber).end();
+        } else {
+            lines.get(lineNumber).setCursor(cursor);
         }
     }
 
@@ -215,7 +294,7 @@ public class Document {
         if (lineNumber < lines.size() - 1) {
             int x = lines.get(lineNumber).getCursor();
             lineNumber++;
-            if (x > lines.get(lineNumber).getDisplayLength()) {
+            if (x >= lines.get(lineNumber).getDisplayLength()) {
                 lines.get(lineNumber).end();
             } else {
                 lines.get(lineNumber).setCursor(x);
@@ -239,7 +318,7 @@ public class Document {
             if (lineNumber > lines.size() - 1) {
                 lineNumber = lines.size() - 1;
             }
-            if (x > lines.get(lineNumber).getDisplayLength()) {
+            if (x >= lines.get(lineNumber).getDisplayLength()) {
                 lines.get(lineNumber).end();
             } else {
                 lines.get(lineNumber).setCursor(x);
@@ -258,7 +337,7 @@ public class Document {
         if (lineNumber > 0) {
             int x = lines.get(lineNumber).getCursor();
             lineNumber--;
-            if (x > lines.get(lineNumber).getDisplayLength()) {
+            if (x >= lines.get(lineNumber).getDisplayLength()) {
                 lines.get(lineNumber).end();
             } else {
                 lines.get(lineNumber).setCursor(x);
@@ -282,7 +361,7 @@ public class Document {
             if (lineNumber < 0) {
                 lineNumber = 0;
             }
-            if (x > lines.get(lineNumber).getDisplayLength()) {
+            if (x >= lines.get(lineNumber).getDisplayLength()) {
                 lines.get(lineNumber).end();
             } else {
                 lines.get(lineNumber).setCursor(x);
@@ -293,21 +372,184 @@ public class Document {
     }
 
     /**
-     * Decrement the cursor by one.  If at the first column, do nothing.
+     * Decrement the cursor by one.  If at the first column on the first
+     * line, do nothing.
      *
      * @return true if the cursor position changed
      */
     public boolean left() {
-        return lines.get(lineNumber).left();
+        if (!lines.get(lineNumber).left()) {
+            // We are on the leftmost column, wrap
+            if (up()) {
+                end();
+            } else {
+                return false;
+            }
+        }
+        return true;
     }
 
     /**
-     * Increment the cursor by one.  If at the last column, do nothing.
+     * Increment the cursor by one.  If at the last column on the last line,
+     * do nothing.
      *
      * @return true if the cursor position changed
      */
     public boolean right() {
-        return lines.get(lineNumber).right();
+        if (!lines.get(lineNumber).right()) {
+            // We are on the rightmost column, wrap
+            if (down()) {
+                home();
+            } else {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Go back to the beginning of this word if in the middle, or the
+     * beginning of the previous word.
+     */
+    public void backwardsWord() {
+
+        // If at the beginning of a word already, push past it.
+        if ((getChar() != -1)
+            && (getRawLine().length() > 0)
+            && !Character.isWhitespace((char) getChar())
+        ) {
+            left();
+        }
+
+        // int line = lineNumber;
+        while ((getChar() == -1)
+            || (getRawLine().length() == 0)
+            || Character.isWhitespace((char) getChar())
+        ) {
+            if (left() == false) {
+                return;
+            }
+        }
+
+
+        assert (getChar() != -1);
+
+        if (!Character.isWhitespace((char) getChar())
+            && (getRawLine().length() > 0)
+        ) {
+            // Advance until at the beginning of the document or a whitespace
+            // is encountered.
+            while (!Character.isWhitespace((char) getChar())) {
+                int line = lineNumber;
+                if (left() == false) {
+                    // End of document, bail out.
+                    return;
+                }
+                if (lineNumber != line) {
+                    // We wrapped a line.  Here that counts as whitespace.
+                    right();
+                    return;
+                }
+            }
+        }
+
+        // We went one past the word, push back to the first character of
+        // that word.
+        right();
+        return;
+    }
+
+    /**
+     * Go to the beginning of the next word.
+     */
+    public void forwardsWord() {
+        int line = lineNumber;
+        while ((getChar() == -1)
+            || (getRawLine().length() == 0)
+        ) {
+            if (right() == false) {
+                return;
+            }
+            if (lineNumber != line) {
+                // We wrapped a line.  Here that counts as whitespace.
+                if (!Character.isWhitespace((char) getChar())) {
+                    // We found a character immediately after the line.
+                    // Done!
+                    return;
+                }
+                // Still looking...
+                line = lineNumber;
+            }
+        }
+        assert (getChar() != -1);
+
+        if (!Character.isWhitespace((char) getChar())
+            && (getRawLine().length() > 0)
+        ) {
+            // Advance until at the end of the document or a whitespace is
+            // encountered.
+            while (!Character.isWhitespace((char) getChar())) {
+                line = lineNumber;
+                if (right() == false) {
+                    // End of document, bail out.
+                    return;
+                }
+                if (lineNumber != line) {
+                    // We wrapped a line.  Here that counts as whitespace.
+                    if (!Character.isWhitespace((char) getChar())
+                        && (getRawLine().length() > 0)
+                    ) {
+                        // We found a character immediately after the line.
+                        // Done!
+                        return;
+                    }
+                    break;
+                }
+            }
+        }
+
+        while ((getChar() == -1)
+            || (getRawLine().length() == 0)
+        ) {
+            if (right() == false) {
+                return;
+            }
+            if (lineNumber != line) {
+                // We wrapped a line.  Here that counts as whitespace.
+                if (!Character.isWhitespace((char) getChar())) {
+                    // We found a character immediately after the line.
+                    // Done!
+                    return;
+                }
+                // Still looking...
+                line = lineNumber;
+            }
+        }
+        assert (getChar() != -1);
+
+        if (Character.isWhitespace((char) getChar())) {
+            // Advance until at the end of the document or a non-whitespace
+            // is encountered.
+            while (Character.isWhitespace((char) getChar())) {
+                if (right() == false) {
+                    // End of document, bail out.
+                    return;
+                }
+            }
+            return;
+        }
+
+        // We wrapped the line to get here.
+        return;
+    }
+
+    /**
+     * Get the raw string that matches this line.
+     *
+     * @return the string
+     */
+    public String getRawLine() {
+        return lines.get(lineNumber).getRawString();
     }
 
     /**
@@ -333,7 +575,19 @@ public class Document {
      */
     public void del() {
         dirty = true;
-        lines.get(lineNumber).del();
+        int cursor = lines.get(lineNumber).getCursor();
+        if (cursor < lines.get(lineNumber).getDisplayLength() - 1) {
+            lines.get(lineNumber).del();
+        } else if (lineNumber < lines.size() - 2) {
+            // Join two lines
+            StringBuilder newLine = new StringBuilder(lines.
+                get(lineNumber).getRawString());
+            newLine.append(lines.get(lineNumber + 1).getRawString());
+            lines.set(lineNumber, new Line(newLine.toString(),
+                    defaultColor, highlighter));
+            lines.get(lineNumber).setCursor(cursor);
+            lines.remove(lineNumber + 1);
+        }
     }
 
     /**
@@ -341,7 +595,43 @@ public class Document {
      */
     public void backspace() {
         dirty = true;
-        lines.get(lineNumber).backspace();
+        int cursor = lines.get(lineNumber).getCursor();
+        if (cursor > 0) {
+            lines.get(lineNumber).backspace(tabSize, backspaceUnindents);
+        } else if (lineNumber > 0) {
+            // Join two lines
+            lineNumber--;
+            String firstLine = lines.get(lineNumber).getRawString();
+            if (firstLine.length() > 0) {
+                // Backspacing combining two lines
+                StringBuilder newLine = new StringBuilder(firstLine);
+                newLine.append(lines.get(lineNumber + 1).getRawString());
+                lines.set(lineNumber, new Line(newLine.toString(),
+                        defaultColor, highlighter));
+                lines.get(lineNumber).setCursor(firstLine.length());
+                lines.remove(lineNumber + 1);
+            } else {
+                // Backspacing an empty line
+                lines.remove(lineNumber);
+                lines.get(lineNumber).setCursor(0);
+            }
+        }
+    }
+
+    /**
+     * Split the current line into two, like pressing the enter key.
+     */
+    public void enter() {
+        dirty = true;
+        int cursor = lines.get(lineNumber).getRawCursor();
+        String original = lines.get(lineNumber).getRawString();
+        String firstLine = original.substring(0, cursor);
+        String secondLine = original.substring(cursor);
+        lines.add(lineNumber + 1, new Line(secondLine, defaultColor,
+                highlighter));
+        lines.set(lineNumber, new Line(firstLine, defaultColor, highlighter));
+        lineNumber++;
+        lines.get(lineNumber).home();
     }
 
     /**
@@ -350,7 +640,7 @@ public class Document {
      *
      * @param ch the character to replace or insert
      */
-    public void addChar(final char ch) {
+    public void addChar(final int ch) {
         dirty = true;
         if (overwrite) {
             lines.get(lineNumber).replaceChar(ch);
@@ -359,6 +649,62 @@ public class Document {
         }
     }
 
+    /**
+     * Get the tab stop size.
+     *
+     * @return the tab stop size
+     */
+    public int getTabSize() {
+        return tabSize;
+    }
+
+    /**
+     * Set the tab stop size.
+     *
+     * @param tabSize the new tab stop size
+     */
+    public void setTabSize(final int tabSize) {
+        this.tabSize = tabSize;
+    }
+
+    /**
+     * Set the backspace unindent option.
+     *
+     * @param backspaceUnindents If true, backspace at an indent level goes
+     * back a full indent level.  If false, backspace always goes back one
+     * column.
+     */
+    public void setBackspaceUnindents(final boolean backspaceUnindents) {
+        this.backspaceUnindents = backspaceUnindents;
+    }
+
+    /**
+     * Set the save with tabs option.
+     *
+     * @param saveWithTabs If true, save files with tab characters.  If
+     * false, convert tabs to spaces when saving files.
+     */
+    public void setSaveWithTabs(final boolean saveWithTabs) {
+        this.saveWithTabs = saveWithTabs;
+    }
+
+    /**
+     * Handle the tab character.
+     */
+    public void tab() {
+        if (overwrite) {
+            del();
+        }
+        lines.get(lineNumber).tab(tabSize);
+    }
+
+    /**
+     * Handle the backtab (shift-tab) character.
+     */
+    public void backTab() {
+        lines.get(lineNumber).backTab(tabSize);
+    }
+
     /**
      * Get a (shallow) copy of the list of lines.
      *
@@ -392,4 +738,86 @@ public class Document {
         return n;
     }
 
+    /**
+     * Get the current line length.
+     *
+     * @return the number of cells needed to display the current line
+     */
+    public int getLineLength() {
+        return lines.get(lineNumber).getDisplayLength();
+    }
+
+    /**
+     * Get the entire contents of the document as one string.
+     *
+     * @return the document contents
+     */
+    public String getText() {
+        StringBuilder sb = new StringBuilder();
+        for (Line line: getLines()) {
+            sb.append(line.getRawString());
+            sb.append("\n");
+        }
+        return sb.toString();
+    }
+
+    /**
+     * Trim trailing whitespace from lines and trailing empty
+     * lines from the document.
+     */
+    public void cleanWhitespace() {
+        for (Line line: getLines()) {
+            line.trimRight();
+        }
+        if (lines.size() == 0) {
+            return;
+        }
+        while (lines.get(lines.size() - 1).length() == 0) {
+            lines.remove(lines.size() - 1);
+        }
+        if (lineNumber > lines.size() - 1) {
+            lineNumber = lines.size() - 1;
+        }
+    }
+
+    /**
+     * Set keyword highlighting.
+     *
+     * @param enabled if true, enable keyword highlighting
+     */
+    public void setHighlighting(final boolean enabled) {
+        highlighter.setEnabled(enabled);
+        for (Line line: getLines()) {
+            line.scanLine();
+        }
+    }
+
+    /**
+     * Convert a string with leading spaces to a mix of tabs and spaces.
+     *
+     * @param string the string to convert
+     */
+    private String convertSpacesToTabs(final String string) {
+        if (string.length() == 0) {
+            return string;
+        }
+
+        int start = 0;
+        while (string.charAt(start) == ' ') {
+            start++;
+        }
+        int tabCount = start / 8;
+        if (tabCount == 0) {
+            return string;
+        }
+
+        StringBuilder sb = new StringBuilder(string.length());
+
+        for (int i = 0; i < tabCount; i++) {
+            sb.append('\t');
+        }
+        sb.append(string.substring(tabCount * 8));
+        return sb.toString();
+    }
+
 }