Merge commit '77d3a60869e7a780c6ae069e51530e1eacece5e2'
[fanfix.git] / src / jexer / teditor / Line.java
index e36a6c9c45826b5e3b6c56d865a9aacba6106e21..b5c980a59f9c9812b6f6a84ce832fe9781051702 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"),
@@ -31,469 +31,468 @@ package jexer.teditor;
 import java.util.ArrayList;
 import java.util.List;
 
-import jexer.bits.Cell;
 import jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.bits.StringUtils;
 
 /**
- * A Line represents a single line of text on the screen.  Each character is
- * a Cell, so it can have color attributes in addition to the basic char.
+ * A Line represents a single line of text on the screen, as a collection of
+ * words.
  */
-public class Line implements Fragment {
+public class Line {
 
-    /**
-     * The cells of the line.
-     */
-    private List<Cell> cells;
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
 
     /**
-     * The line number.
+     * The list of words.
      */
-    private int lineNumber;
+    private ArrayList<Word> words = new ArrayList<Word>();
 
     /**
-     * The previous Fragment in the list.
+     * The default color for the TEditor class.
      */
-    private Fragment prevFrag;
+    private CellAttributes defaultColor = null;
 
     /**
-     * The next Fragment in the list.
+     * The text highlighter to use.
      */
-    private Fragment nextFrag;
+    private Highlighter highlighter = null;
 
     /**
-     * Construct a new Line from an existing text string.
+     * The current edition position on this line.
      */
-    public Line() {
-        this("");
-    }
+    private int position = 0;
 
     /**
-     * Construct a new Line from an existing text string.
-     *
-     * @param text the code points of the line
+     * The current editing position screen column number.
      */
-    public Line(final String text) {
-        cells = new ArrayList<Cell>(text.length());
-        for (int i = 0; i < text.length(); i++) {
-            cells.add(new Cell(text.charAt(i)));
-        }
-    }
+    private int screenPosition = 0;
 
     /**
-     * Reset all colors of this Line to white-on-black.
+     * The raw text of this line, what is passed to Word to determine
+     * highlighting behavior.
      */
-    public void resetColors() {
-        setColors(new CellAttributes());
-    }
+    private StringBuilder rawText;
 
-    /**
-     * Set all colors of this Line to one color.
-     *
-     * @param color the new color to use
-     */
-    public void setColors(final CellAttributes color) {
-        for (Cell cell: cells) {
-            cell.setTo(color);
-        }
-    }
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
 
     /**
-     * Set the color of one cell.
+     * Construct a new Line from an existing text string, and highlight
+     * certain strings.
      *
-     * @param index a cell number, between 0 and getCellCount()
-     * @param color the new color to use
-     * @throws IndexOutOfBoundsException if index is negative or not less
-     * than getCellCount()
+     * @param str the text string
+     * @param defaultColor the color for unhighlighted text
+     * @param highlighter the highlighter to use
      */
-    public void setColor(final int index, final CellAttributes color) {
-        cells.get(index).setTo(color);
-    }
+    public Line(final String str, final CellAttributes defaultColor,
+        final Highlighter highlighter) {
 
-    /**
-     * Get the raw text that will be rendered.
-     *
-     * @return the text
-     */
-    public String getText() {
-        char [] text = new char[cells.size()];
-        for (int i = 0; i < cells.size(); i++) {
-            text[i] = cells.get(i).getChar();
+        this.defaultColor = defaultColor;
+        this.highlighter = highlighter;
+
+        this.rawText = new StringBuilder();
+        int col = 0;
+        for (int i = 0; i < str.length(); i++) {
+            char ch = str.charAt(i);
+            if (ch == '\t') {
+                // Expand tabs
+                int j = col % 8;
+                do {
+                    rawText.append(' ');
+                    j++;
+                    col++;
+                } while ((j % 8) != 0);
+                continue;
+            }
+            if ((ch <= 0x20) || (ch == 0x7F)) {
+                // Replace all other C0 bytes with CP437 glyphs.
+                rawText.append(GraphicsChars.CP437[(int) ch]);
+                col++;
+                continue;
+            }
+
+            rawText.append(ch);
+            col++;
         }
-        return new String(text);
-    }
 
-    /**
-     * Get the attributes for a cell.
-     *
-     * @param index a cell number, between 0 and getCellCount()
-     * @return the attributes
-     * @throws IndexOutOfBoundsException if index is negative or not less
-     * than getCellCount()
-     */
-    public CellAttributes getColor(final int index) {
-        return cells.get(index);
+        scanLine();
     }
 
     /**
-     * Get the number of graphical cells represented by this text.  Note that
-     * a Unicode grapheme cluster can take any number of pixels, but this
-     * editor is intended to be used with a fixed-width font.  So this count
-     * returns the number of fixed-width cells, NOT the number of grapheme
-     * clusters.
+     * Construct a new Line from an existing text string.
      *
-     * @return the number of fixed-width cells this fragment's text will
-     * render to
+     * @param str the text string
+     * @param defaultColor the color for unhighlighted text
      */
-    public int getCellCount() {
-        return cells.size();
+    public Line(final String str, final CellAttributes defaultColor) {
+        this(str, defaultColor, null);
     }
 
     /**
-     * Get the text to render for a specific fixed-width cell.
-     *
-     * @param index a cell number, between 0 and getCellCount()
-     * @return the codepoints to render for this fixed-width cell
-     * @throws IndexOutOfBoundsException if index is negative or not less
-     * than getCellCount()
+     * Private constructor used by dup().
      */
-    public Cell getCell(final int index) {
-        return cells.get(index);
+    private Line() {
+        // NOP
     }
 
-    /**
-     * Get the text to render for several fixed-width cells.
-     *
-     * @param start a cell number, between 0 and getCellCount()
-     * @param length the number of cells to return
-     * @return the codepoints to render for this fixed-width cell
-     * @throws IndexOutOfBoundsException if start or (start + length) is
-     * negative or not less than getCellCount()
-     */
-    public String getCells(final int start, final int length) {
-        char [] text = new char[length];
-        for (int i = 0; i < length; i++) {
-            text[i] = cells.get(i + start).getChar();
-        }
-        return new String(text);
-    }
+    // ------------------------------------------------------------------------
+    // Line -------------------------------------------------------------------
+    // ------------------------------------------------------------------------
 
     /**
-     * Sets (replaces) the text to render for a specific fixed-width cell.
+     * Create a duplicate instance.
      *
-     * @param index a cell number, between 0 and getCellCount()
-     * @param ch the character for this fixed-width cell
-     * @throws IndexOutOfBoundsException if index is negative or not less
-     * than getCellCount()
+     * @return duplicate intance
      */
-    public void setCell(final int index, final char ch) {
-        cells.set(index, new Cell(ch));
+    public Line dup() {
+        Line other = new Line();
+        other.defaultColor = defaultColor;
+        other.highlighter = highlighter;
+        other.position = position;
+        other.screenPosition = screenPosition;
+        other.rawText = new StringBuilder(rawText);
+        other.scanLine();
+        return other;
     }
 
     /**
-     * Sets (replaces) the text to render for a specific fixed-width cell.
+     * Get a (shallow) copy of the words in this line.
      *
-     * @param index a cell number, between 0 and getCellCount()
-     * @param cell the new value for this fixed-width cell
-     * @throws IndexOutOfBoundsException if index is negative or not less
-     * than getCellCount()
+     * @return a copy of the word list
      */
-    public void setCell(final int index, final Cell cell) {
-        cells.set(index, cell);
+    public List<Word> getWords() {
+        return new ArrayList<Word>(words);
     }
 
     /**
-     * Inserts a char to render for a specific fixed-width cell.
+     * Get the current cursor position in the text.
      *
-     * @param index a cell number, between 0 and getCellCount() - 1
-     * @param ch the character for this fixed-width cell
-     * @throws IndexOutOfBoundsException if index is negative or not less
-     * than getCellCount()
+     * @return the cursor position
      */
-    public void insertCell(final int index, final char ch) {
-        cells.add(index, new Cell(ch));
+    public int getRawCursor() {
+        return position;
     }
 
     /**
-     * Inserts a Cell to render for a specific fixed-width cell.
+     * Get the current cursor position on screen.
      *
-     * @param index a cell number, between 0 and getCellCount() - 1
-     * @param cell the new value for this fixed-width cell
-     * @throws IndexOutOfBoundsException if index is negative or not less
-     * than getCellCount()
+     * @return the cursor position
      */
-    public void insertCell(final int index, final Cell cell) {
-        cells.add(index, cell);
+    public int getCursor() {
+        return screenPosition;
     }
 
     /**
-     * Delete a specific fixed-width cell.
+     * Set the current cursor position.
      *
-     * @param index a cell number, between 0 and getCellCount() - 1
-     * @throws IndexOutOfBoundsException if index is negative or not less
-     * than getCellCount()
+     * @param cursor the new cursor position
      */
-    public void deleteCell(final int index) {
-        cells.remove(index);
+    public void setCursor(final int cursor) {
+        if ((cursor < 0)
+            || ((cursor >= getDisplayLength())
+                && (getDisplayLength() > 0))
+        ) {
+            throw new IndexOutOfBoundsException("Max length is " +
+                getDisplayLength() + ", requested position " + cursor);
+        }
+        screenPosition = cursor;
+        position = screenToTextPosition(screenPosition);
     }
 
     /**
-     * Delete several fixed-width cells.
+     * Get the character at the current cursor position in the text.
      *
-     * @param start a cell number, between 0 and getCellCount() - 1
-     * @param length the number of cells to delete
-     * @throws IndexOutOfBoundsException if index is negative or not less
-     * than getCellCount()
+     * @return the character, or -1 if the cursor is at the end of the line
      */
-    public void deleteCells(final int start, final int length) {
-        for (int i = 0; i < length; i++) {
-            cells.remove(start);
+    public int getChar() {
+        if (position == rawText.length()) {
+            return -1;
         }
+        return rawText.codePointAt(position);
     }
 
     /**
-     * Appends a char to render for a specific fixed-width cell.
+     * Get the on-screen display length.
      *
-     * @param ch the character for this fixed-width cell
+     * @return the number of cells needed to display this line
      */
-    public void appendCell(final char ch) {
-        cells.add(new Cell(ch));
+    public int getDisplayLength() {
+        int n = StringUtils.width(rawText.toString());
+
+        if (n > 0) {
+            // If we have any visible characters, add one to the display so
+            // that the position is immediately after the data.
+            return n + 1;
+        }
+        return n;
     }
 
     /**
-     * Inserts a Cell to render for a specific fixed-width cell.
+     * Get the raw string that matches this line.
      *
-     * @param cell the new value for this fixed-width cell
+     * @return the string
      */
-    public void appendCell(final Cell cell) {
-        cells.add(cell);
+    public String getRawString() {
+        return rawText.toString();
     }
 
     /**
-     * Get the next Fragment in the list, or null if this Fragment is the
-     * last node.
+     * Get the raw length of this line.
      *
-     * @return the next Fragment, or null
+     * @return the length of this line in characters, which may be different
+     * from the number of cells needed to display it
      */
-    public Fragment next() {
-        return nextFrag;
+    public int length() {
+        return rawText.length();
     }
 
     /**
-     * Get the previous Fragment in the list, or null if this Fragment is the
-     * first node.
-     *
-     * @return the previous Fragment, or null
+     * Scan rawText and make words out of it.  Note package private access.
      */
-    public Fragment prev() {
-        return prevFrag;
+    void scanLine() {
+        words.clear();
+        Word word = new Word(this.defaultColor, this.highlighter);
+        words.add(word);
+        for (int i = 0; i < rawText.length();) {
+            int ch = rawText.codePointAt(i);
+            i += Character.charCount(ch);
+            Word newWord = word.addChar(ch);
+            if (newWord != word) {
+                words.add(newWord);
+                word = newWord;
+            }
+        }
+        for (Word w: words) {
+            w.applyHighlight();
+        }
     }
 
     /**
-     * See if this Fragment can be joined with the next Fragment in list.
+     * Decrement the cursor by one.  If at the first column, do nothing.
      *
-     * @return true if the join was possible, false otherwise
+     * @return true if the cursor position changed
      */
-    public boolean isNextJoinable() {
-        if ((nextFrag != null) && (nextFrag instanceof Line)) {
-            return true;
+    public boolean left() {
+        if (position == 0) {
+            return false;
         }
-        return false;
+        screenPosition -= StringUtils.width(rawText.codePointBefore(position));
+        position -= Character.charCount(rawText.codePointBefore(position));
+        return true;
     }
 
     /**
-     * Join this Fragment with the next Fragment in list.
+     * Increment the cursor by one.  If at the last column, do nothing.
      *
-     * @return true if the join was successful, false otherwise
+     * @return true if the cursor position changed
      */
-    public boolean joinNext() {
-        if ((nextFrag == null) || !(nextFrag instanceof Line)) {
+    public boolean right() {
+        if (getDisplayLength() == 0) {
+            return false;
+        }
+        if (screenPosition == getDisplayLength() - 1) {
             return false;
         }
-        Line q = (Line) nextFrag;
-        ArrayList<Cell> newCells = new ArrayList<Cell>(this.cells.size() +
-            q.cells.size());
-        newCells.addAll(this.cells);
-        newCells.addAll(q.cells);
-        this.cells = newCells;
-        ((Line) q.nextFrag).prevFrag = this;
-        nextFrag = q.nextFrag;
+        if (position < rawText.length()) {
+            screenPosition += StringUtils.width(rawText.codePointAt(position));
+            position += Character.charCount(rawText.codePointAt(position));
+        }
+        assert (position <= rawText.length());
         return true;
     }
 
     /**
-     * See if this Fragment can be joined with the previous Fragment in list.
+     * Go to the first column of this line.
      *
-     * @return true if the join was possible, false otherwise
+     * @return true if the cursor position changed
      */
-    public boolean isPrevJoinable() {
-        if ((prevFrag != null) && (prevFrag instanceof Line)) {
+    public boolean home() {
+        if (position > 0) {
+            position = 0;
+            screenPosition = 0;
             return true;
         }
         return false;
     }
 
     /**
-     * Join this Fragment with the previous Fragment in list.
+     * Go to the last column of this line.
      *
-     * @return true if the join was successful, false otherwise
+     * @return true if the cursor position changed
      */
-    public boolean joinPrev() {
-        if ((prevFrag == null) || !(prevFrag instanceof Line)) {
-            return false;
+    public boolean end() {
+        if (screenPosition != getDisplayLength() - 1) {
+            position = rawText.length();
+            screenPosition = StringUtils.width(rawText.toString());
+            return true;
         }
-        Line p = (Line) prevFrag;
-        ArrayList<Cell> newCells = new ArrayList<Cell>(this.cells.size() +
-            p.cells.size());
-        newCells.addAll(p.cells);
-        newCells.addAll(this.cells);
-        this.cells = newCells;
-        ((Line) p.prevFrag).nextFrag = this;
-        prevFrag = p.prevFrag;
-        return true;
+        return false;
     }
 
     /**
-     * Set the next Fragment in the list.  Note that this performs no sanity
-     * checking or modifications on fragment; this function can break
-     * connectivity in the list.
-     *
-     * @param fragment the next Fragment, or null
+     * Delete the character under the cursor.
      */
-    public void setNext(Fragment fragment) {
-        nextFrag = fragment;
-    }
+    public void del() {
+        assert (words.size() > 0);
 
-    /**
-     * Set the previous Fragment in the list.  Note that this performs no
-     * sanity checking or modifications on fragment; this function can break
-     * connectivity in the list.
-     *
-     * @param fragment the previous Fragment, or null
-     */
-    public void setPrev(Fragment fragment) {
-        prevFrag = fragment;
-    }
+        if (screenPosition < getDisplayLength()) {
+            int n = Character.charCount(rawText.codePointAt(position));
+            for (int i = 0; i < n; i++) {
+                rawText.deleteCharAt(position);
+            }
+        }
 
-    /**
-     * Split this Fragment into two.  'this' Fragment will contain length
-     * cells, 'this.next()' will contain (getCellCount() - length) cells.
-     *
-     * @param length the number of cells to leave in this Fragment
-     * @throws IndexOutOfBoundsException if length is negative, or 0, greater
-     * than (getCellCount() - 1)
-     */
-    public void split(final int length) {
-        // Create the next node
-        Line q = new Line();
-        q.nextFrag = nextFrag;
-        q.prevFrag = this;
-        ((Line) nextFrag).prevFrag = q;
-        nextFrag = q;
-
-        // Split cells
-        q.cells = new ArrayList<Cell>(cells.size() - length);
-        q.cells.addAll(cells.subList(length, cells.size()));
-        cells = cells.subList(0, length);
-    }
+        // Re-scan the line to determine the new word boundaries.
+        scanLine();
+    }
+
+    /**
+     * Delete the character immediately preceeding the cursor.
+     *
+     * @param tabSize the tab stop size
+     * @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 backspace(final int tabSize, final boolean backspaceUnindents) {
+        if ((backspaceUnindents == true)
+            && (tabSize > 0)
+            && (screenPosition > 0)
+            && (rawText.charAt(position - 1) == ' ')
+            && ((screenPosition % tabSize) == 0)
+        ) {
+            boolean doBackTab = true;
+            for (int i = 0; i < position; i++) {
+                if (rawText.charAt(i) != ' ') {
+                    doBackTab = false;
+                    break;
+                }
+            }
+            if (doBackTab) {
+                backTab(tabSize);
+                return;
+            }
+        }
 
-    /**
-     * Insert a new Fragment at a position, splitting the contents of this
-     * Fragment into two around it.  'this' Fragment will contain the cells
-     * between 0 and index, 'this.next()' will be the inserted fragment, and
-     * 'this.next().next()' will contain the cells between 'index' and
-     * getCellCount() - 1.
-     *
-     * @param index the number of cells to leave in this Fragment
-     * @param fragment the Fragment to insert
-     * @throws IndexOutOfBoundsException if length is negative, or 0, greater
-     * than (getCellCount() - 1)
-     */
-    public void split(final int index, Fragment fragment) {
-        // Create the next node and insert into the list.
-        Line q = new Line();
-        q.nextFrag = nextFrag;
-        q.nextFrag.setPrev(q);
-        q.prevFrag = fragment;
-        fragment.setNext(q);
-        fragment.setPrev(this);
-        nextFrag = fragment;
-
-        // Split cells
-        q.cells = new ArrayList<Cell>(cells.size() - index);
-        q.cells.addAll(cells.subList(index, cells.size()));
-        cells = cells.subList(0, index);
+        if (left()) {
+            del();
+        }
     }
 
     /**
-     * Insert a new Fragment before this one.
+     * Insert a character at the cursor.
      *
-     * @param fragment the Fragment to insert
+     * @param ch the character to insert
      */
-    public void insert(Fragment fragment) {
-        fragment.setNext(this);
-        fragment.setPrev(prevFrag);
-        prevFrag.setNext(fragment);
-        prevFrag = fragment;
+    public void addChar(final int ch) {
+        if (screenPosition < getDisplayLength() - 1) {
+            rawText.insert(position, Character.toChars(ch));
+        } else {
+            rawText.append(Character.toChars(ch));
+        }
+        position += Character.charCount(ch);
+        screenPosition += StringUtils.width(ch);
+        scanLine();
     }
 
     /**
-     * Append a new Fragment at the end of this one.
+     * Replace a character at the cursor.
      *
-     * @param fragment the Fragment to append
+     * @param ch the character to replace
      */
-    public void append(Fragment fragment) {
-        fragment.setNext(nextFrag);
-        fragment.setPrev(this);
-        nextFrag.setPrev(fragment);
-        nextFrag = fragment;
+    public void replaceChar(final int ch) {
+        if (screenPosition < getDisplayLength() - 1) {
+            // Replace character
+            String oldText = rawText.toString();
+            rawText = new StringBuilder(oldText.substring(0, position));
+            rawText.append(Character.toChars(ch));
+            rawText.append(oldText.substring(position + 1));
+            screenPosition += StringUtils.width(rawText.codePointAt(position));
+            position += Character.charCount(ch);
+        } else {
+            rawText.append(Character.toChars(ch));
+            position += Character.charCount(ch);
+            screenPosition += StringUtils.width(ch);
+        }
+        scanLine();
     }
 
     /**
-     * Delete this Fragment from the list, and return its next().
+     * Determine string position from screen position.
      *
-     * @return this Fragment's next(), or null if it was at the end of the
-     * list
+     * @param screenPosition the position on screen
+     * @return the equivalent position in text
      */
-    public Fragment deleteGetNext() {
-        Fragment result = nextFrag;
-        nextFrag.setPrev(prevFrag);
-        prevFrag.setNext(nextFrag);
-        prevFrag = null;
-        nextFrag = null;
-        return result;
+    private int screenToTextPosition(final int screenPosition) {
+        if (screenPosition == 0) {
+            return 0;
+        }
+
+        int n = 0;
+        for (int i = 0; i < rawText.length(); i++) {
+            n += StringUtils.width(rawText.codePointAt(i));
+            if (n >= screenPosition) {
+                return i + 1;
+            }
+        }
+        // screenPosition exceeds the available text length.
+        throw new IndexOutOfBoundsException("screenPosition " + screenPosition +
+            " exceeds available text length " + rawText.length());
     }
 
     /**
-     * Delete this Fragment from the list, and return its prev().
-     *
-     * @return this Fragment's next(), or null if it was at the beginning of
-     * the list
+     * Trim trailing whitespace from line, repositioning cursor if needed.
      */
-    public Fragment deleteGetPrev() {
-        Fragment result = prevFrag;
-        nextFrag.setPrev(prevFrag);
-        prevFrag.setNext(nextFrag);
-        prevFrag = null;
-        nextFrag = null;
-        return result;
+    public void trimRight() {
+        if (rawText.length() == 0) {
+            return;
+        }
+        if (!Character.isWhitespace(rawText.charAt(rawText.length() - 1))) {
+            return;
+        }
+        while ((rawText.length() > 0)
+            && Character.isWhitespace(rawText.charAt(rawText.length() - 1))
+        ) {
+            rawText.deleteCharAt(rawText.length() - 1);
+        }
+        if (position >= rawText.length()) {
+            end();
+        }
+        scanLine();
     }
 
     /**
-     * Get the anchor position.
+     * Handle the tab character.
      *
-     * @return the anchor number
+     * @param tabSize the tab stop size
      */
-    public int getAnchor() {
-        return lineNumber;
+    public void tab(final int tabSize) {
+        if (tabSize > 0) {
+            do {
+                addChar(' ');
+            } while ((screenPosition % tabSize) != 0);
+        }
     }
 
     /**
-     * Set the anchor position.
+     * Handle the backtab (shift-tab) character.
      *
-     * @param x the new anchor number
+     * @param tabSize the tab stop size
      */
-    public void setAnchor(final int x) {
-        lineNumber = x;
+    public void backTab(final int tabSize) {
+        if ((tabSize > 0) && (screenPosition > 0)
+            && (rawText.charAt(position - 1) == ' ')
+        ) {
+            do {
+                backspace(tabSize, false);
+            } while (((screenPosition % tabSize) != 0)
+                && (screenPosition > 0)
+                && (rawText.charAt(position - 1) == ' '));
+        }
     }
 
 }