Class TextEditModel

java.lang.Object
limn.components.text.TextEditModel

public final class TextEditModel extends Object
Pure, headless text-editing model shared by TextField/PasswordField (single line) and TextArea (multiline): cursor, anchor-based selection, line-aware Home/End and Up/Down with a sticky goal column, bounded undo/redo. Indices are char offsets into the text; all movement and deletion step by grapheme cluster (regex \X), so combining marks and ZWJ emoji are never split; a surrogate pair is the simplest such cluster.

Line lookups (lineOf/lineCount/lineText/…) are served from a line-start index that every edit keeps in step with itself, so neither a paint-time query nor the edit before it rescans the buffer.

The caret is an index and a side, caret(), because on a direction boundary one index is two points on the screen and only the side says which. The side lives here, beside the cursor, rather than in the widget: every method below that writes cursor would otherwise need a paired assignment at three call sites apiece, and undo() restores a cursor, so it has to restore a side with it — an undo that put a correct index back on yesterday's side draws the caret at the far end of the run, one keystroke later, with nothing to trace it to.

Two movement axes, and they are not interchangeable. moveVisualLeft(limn.graphics.ShapedText, int, boolean) and moveVisualRight(limn.graphics.ShapedText, int, boolean) are what the arrow keys press: a step left or right on the screen, which needs the shaped line to answer. Everything else here — Home, End, word movement, Backspace, Delete, Up and Down — is logical, a step through the string, because each of them has to name a contiguous range: Shift+Home makes a selection, and a selection is (anchor, cursor). The consequence is stated rather than discovered: in right-to-left text Left and Ctrl+Left move the caret in opposite directions, exactly as they do on Windows and in GTK.

Taking a ShapedText costs this package no dependency it did not have: the type is an immutable value whose accessors return nothing but primitives, Strings and records of those, so this class still draws nothing and knows no widget, and every case below is pinned by a test with no scene, no ruler and no window.

  • Nested Class Summary

    Nested Classes
    Modifier and Type
    Class
    Description
    static final record 
    The lines edits have replaced: lines [firstLine, oldLastLine] of the text as it stood at the last clearLineDamage() became lines [firstLine, newLastLine] of the text as it stands now.
  • Constructor Summary

    Constructors
    Constructor
    Description
    TextEditModel(boolean singleLine)
    A model for one line (newlines rejected on insert) or for many.
  • Method Summary

    Modifier and Type
    Method
    Description
    int
    alignToGrapheme(int index)
    Snaps index to the start of the cluster containing it (hit testing).
    int
    Snaps index forward to the cluster boundary at or after it (forward motion).
    void
    Deletes the selection, or the grapheme cluster before the cursor.
    boolean
    Whether a redo step is available; any new edit discards the redo stack.
    boolean
    Whether an undo step is available.
    The caret: the insertion index and which side of it the caret is on.
    void
    Forgets the recorded damage: the consumer has re-derived what it holds.
    void
    Drops the selection, leaving the cursor where it is.
    int
    Caret position as a char offset, always on a grapheme boundary.
    void
    Deletes the selection, or the grapheme cluster after the cursor.
    boolean
     
    void
    Deletes the selection, or from the cursor back to the previous word boundary.
    void
    Deletes the selection, or from the cursor forward to the next word boundary.
    boolean
    Whether a non-empty range is selected.
    void
    Inserts at the cursor, replacing any selection.
    void
    insertCodePoint(int codepoint)
    Committed keyboard input; consecutive calls coalesce into one undo step.
    int
    Buffer length in chars, not in grapheme clusters.
    int
    Number of lines; always at least 1, and 1 for a single-line model.
    What the edits since the last clearLineDamage() touched, or null when the text has not changed since.
    int
    lineEnd(int index)
     
    int
    lineOf(int index)
     
    int
    lineStart(int index)
     
    int
    lineStartOfLine(int line)
     
    lineText(int line)
     
    void
    moveDocumentEnd(boolean select)
    End of the whole text: Ctrl+End / Cmd+Down.
    void
    moveDocumentStart(boolean select)
    Start of the whole text: Ctrl+Home / Cmd+Up.
    void
    moveDown(boolean select)
    Down one line, keeping the sticky goal column.
    void
    moveEnd(boolean select)
    End of the current line (end of text on single-line models), in logical order: the mirror of moveHome(boolean), so in a right-to-left paragraph it moves the caret to the visual left.
    void
    moveHome(boolean select)
    Start of the current line (start of text on single-line models), in logical order — so in a right-to-left paragraph this moves the caret to the visual right.
    void
    moveLeft(boolean select)
    Moves the caret one grapheme cluster left in the string, extending the selection when select is set and collapsing it to the left edge when it is not.
    void
    moveRight(boolean select)
    Moves the caret one grapheme cluster right in the string, extending the selection when select is set and collapsing it to the right edge when it is not.
    void
    moveUp(boolean select)
    Up one line, keeping the sticky goal column.
    boolean
    moveVisualLeft(ShapedText line, int lineStart, boolean select)
    Moves the caret one step left on the screen, over line: the Left arrow, whatever direction the text under it runs.
    boolean
    moveVisualRight(ShapedText line, int lineStart, boolean select)
    Moves the caret one step right on the screen: the mirror of moveVisualLeft(limn.graphics.ShapedText, int, boolean) in every respect, collapsing a selection to selectionEnd() instead.
    void
    moveWordLeft(boolean select)
    Previous word boundary: Ctrl/Alt+Left.
    void
    moveWordRight(boolean select)
    Next word boundary: Ctrl/Alt+Right.
    int
    nextGrapheme(int index)
     
    int
    nextWordBoundary(int from)
     
    int
    previousGrapheme(int index)
     
    int
     
    boolean
     
    void
    Selects the whole buffer and leaves the cursor at its end.
    The selected text, or an empty string when there is no selection.
    int
    Upper selection bound; equals cursor() when nothing is selected.
    int
    Lower selection bound; equals cursor() when nothing is selected.
    void
    setCaret(ShapedText.Position position, boolean select)
    Places the caret, side included: what a click, a drag and a visual arrow all produce.
    void
    setCursor(int index, boolean select)
    Places the cursor; select extends/starts a selection from the old spot.
    void
    Replaces the whole content programmatically; clears the undo history.
    The whole buffer.
    textRange(int from, int to)
    Substring [from, to) without copying the whole buffer (hot paint paths).
    long
    Bumped by every mutation of the buffer, and by nothing else: a cursor move or a selection change leaves it alone.
    boolean
     

    Methods inherited from class java.lang.Object

    clone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait
  • Constructor Details

    • TextEditModel

      public TextEditModel(boolean singleLine)
      A model for one line (newlines rejected on insert) or for many.
  • Method Details

    • text

      public String text()
      The whole buffer.
    • textRange

      public String textRange(int from, int to)
      Substring [from, to) without copying the whole buffer (hot paint paths).
    • length

      public int length()
      Buffer length in chars, not in grapheme clusters.
    • setText

      public void setText(String text)
      Replaces the whole content programmatically; clears the undo history. The caret lands at the end of the buffer on its ShapedText.Affinity.DOWNSTREAM side, which is the paragraph's own end edge — the visual left for right-to-left content, where the next typed character goes.
    • insert

      public void insert(String raw)
      Inserts at the cursor, replacing any selection.
    • insertCodePoint

      public void insertCodePoint(int codepoint)
      Committed keyboard input; consecutive calls coalesce into one undo step.
    • backspace

      public void backspace()
      Deletes the selection, or the grapheme cluster before the cursor.
    • deleteForward

      public void deleteForward()
      Deletes the selection, or the grapheme cluster after the cursor.
    • hasSelection

      public boolean hasSelection()
      Whether a non-empty range is selected.
    • selectionStart

      public int selectionStart()
      Lower selection bound; equals cursor() when nothing is selected.
    • selectionEnd

      public int selectionEnd()
      Upper selection bound; equals cursor() when nothing is selected.
    • selectedText

      public String selectedText()
      The selected text, or an empty string when there is no selection.
    • deleteSelection

      public boolean deleteSelection()
      Returns:
      whether a selection existed and was removed
    • selectAll

      public void selectAll()
      Selects the whole buffer and leaves the cursor at its end.
    • clearSelection

      public void clearSelection()
      Drops the selection, leaving the cursor where it is.
    • undo

      public boolean undo()
      Returns:
      whether there was anything to undo
    • redo

      public boolean redo()
      Returns:
      whether there was anything to redo
    • canUndo

      public boolean canUndo()
      Whether an undo step is available.
    • canRedo

      public boolean canRedo()
      Whether a redo step is available; any new edit discards the redo stack.
    • cursor

      public int cursor()
      Caret position as a char offset, always on a grapheme boundary.
    • caret

      public ShapedText.Position caret()
      The caret: the insertion index and which side of it the caret is on. The index alone is what an edit, the clipboard and the IME need; the pair is what the line needs, because on a direction boundary one index is two points on the screen and a caret drawn at the wrong one tells the user something false about where their next character lands.
      Returns:
      where the caret is, index and side; never null
    • setCursor

      public void setCursor(int index, boolean select)
      Places the cursor; select extends/starts a selection from the old spot. The caret takes ShapedText.Affinity.DOWNSTREAM, the side a programmatic placement has nothing better to go on than. A caller that does know the side — a click, a drag, a visual arrow — calls setCaret(limn.graphics.ShapedText.Position, boolean) instead.
      Parameters:
      index - where the caret goes, clamped into the buffer
      select - whether this extends a selection
    • setCaret

      public void setCaret(ShapedText.Position position, boolean select)
      Places the caret, side included: what a click, a drag and a visual arrow all produce. select extends or starts a selection from the old spot, exactly as setCursor(int, boolean).
      Parameters:
      position - where the caret goes; its index is clamped into the buffer
      select - whether this extends a selection
      Throws:
      NullPointerException - if position is null
    • moveLeft

      public void moveLeft(boolean select)
      Moves the caret one grapheme cluster left in the string, extending the selection when select is set and collapsing it to the left edge when it is not. This is the logical step, which is not what the Left arrow key does once anything reorders: moveVisualLeft(limn.graphics.ShapedText, int, boolean) is that.
      Parameters:
      select - whether this extends a selection
    • moveRight

      public void moveRight(boolean select)
      Moves the caret one grapheme cluster right in the string, extending the selection when select is set and collapsing it to the right edge when it is not. The logical mirror of moveLeft(boolean), and not the Right arrow key; see moveVisualRight(limn.graphics.ShapedText, int, boolean).
      Parameters:
      select - whether this extends a selection
    • moveHome

      public void moveHome(boolean select)
      Start of the current line (start of text on single-line models), in logical order — so in a right-to-left paragraph this moves the caret to the visual right. That is what Windows edit controls, GTK's DISPLAY_LINE_ENDS movement and Cocoa's moveToBeginningOfLine: all do, and it is forced anyway: Shift+Home has to produce a selection, a selection is one contiguous range of the string, and the range from the caret to the visual left edge of a mixed line is not one.
      Parameters:
      select - whether this extends a selection
    • moveEnd

      public void moveEnd(boolean select)
      End of the current line (end of text on single-line models), in logical order: the mirror of moveHome(boolean), so in a right-to-left paragraph it moves the caret to the visual left.
      Parameters:
      select - whether this extends a selection
    • moveVisualLeft

      public boolean moveVisualLeft(ShapedText line, int lineStart, boolean select)
      Moves the caret one step left on the screen, over line: the Left arrow, whatever direction the text under it runs.

      line is the shaped form of the line the cursor sits on and lineStart is the buffer index that line begins at, so a single-line model passes 0. A cursor outside the line is clamped into it rather than rejected: a caret restored from a stale view has to produce a position, not an exception.

      With a selection and select == false this collapses to selectionStart() and moves no further, which is deliberately the LOGICAL end: a selection can span lines, its two ends can sit in different runs, and the visually left end of a multi-line range is not defined. On a line that reorders nothing the two answers coincide.

      Parameters:
      line - the shaped line the cursor is on
      lineStart - buffer index where that line starts
      select - whether this extends a selection
      Returns:
      whether anything moved; false means the caret was already at the line's left edge and a multi-line caller should change line
      Throws:
      NullPointerException - if line is null
    • moveVisualRight

      public boolean moveVisualRight(ShapedText line, int lineStart, boolean select)
      Moves the caret one step right on the screen: the mirror of moveVisualLeft(limn.graphics.ShapedText, int, boolean) in every respect, collapsing a selection to selectionEnd() instead.
      Parameters:
      line - the shaped line the cursor is on
      lineStart - buffer index where that line starts
      select - whether this extends a selection
      Returns:
      whether anything moved; false means the caret was already at the line's right edge and a multi-line caller should change line
      Throws:
      NullPointerException - if line is null
    • moveUp

      public void moveUp(boolean select)
      Up one line, keeping the sticky goal column. No-op on single-line models.
    • moveDown

      public void moveDown(boolean select)
      Down one line, keeping the sticky goal column.
    • moveWordLeft

      public void moveWordLeft(boolean select)
      Previous word boundary: Ctrl/Alt+Left. Extends the selection when select.

      Logical, and it stays logical: deleteWordBackward() has to remove a contiguous range of the string, so the boundary this lands on has to be the end of one. In right-to-left text that means Ctrl+Left and moveVisualLeft(limn.graphics.ShapedText, int, boolean) move the caret in opposite directions, which is what Windows and GTK do and the lesser of the two evils.

      Parameters:
      select - whether this extends a selection
    • moveWordRight

      public void moveWordRight(boolean select)
      Next word boundary: Ctrl/Alt+Right. Extends the selection when select. Logical, for the reason moveWordLeft(boolean) gives.
      Parameters:
      select - whether this extends a selection
    • moveDocumentStart

      public void moveDocumentStart(boolean select)
      Start of the whole text: Ctrl+Home / Cmd+Up. Logical, and so the visual right edge of a right-to-left first line; see moveHome(boolean).
      Parameters:
      select - whether this extends a selection
    • moveDocumentEnd

      public void moveDocumentEnd(boolean select)
      End of the whole text: Ctrl+End / Cmd+Down. Logical; see moveEnd(boolean).
      Parameters:
      select - whether this extends a selection
    • deleteWordBackward

      public void deleteWordBackward()
      Deletes the selection, or from the cursor back to the previous word boundary.
    • deleteWordForward

      public void deleteWordForward()
      Deletes the selection, or from the cursor forward to the next word boundary.
    • nextWordBoundary

      public int nextWordBoundary(int from)
      Returns:
      the next word boundary at or after from: skip whitespace, then one same-class run
    • previousWordBoundary

      public int previousWordBoundary(int from)
      Returns:
      the word boundary at or before from: skip whitespace back, then one same-class run
    • nextGrapheme

      public int nextGrapheme(int index)
      Returns:
      the grapheme boundary after index (≤ length())
    • previousGrapheme

      public int previousGrapheme(int index)
      Returns:
      the start of the grapheme cluster ending at or containing index (≥ 0)
    • alignToGraphemeForward

      public int alignToGraphemeForward(int index)
      Snaps index forward to the cluster boundary at or after it (forward motion).
    • alignToGrapheme

      public int alignToGrapheme(int index)
      Snaps index to the start of the cluster containing it (hit testing).
    • lineStart

      public int lineStart(int index)
      Returns:
      char index of the start of the line containing index
    • lineEnd

      public int lineEnd(int index)
      Returns:
      char index of the end of the line containing index (before the newline)
    • lineOf

      public int lineOf(int index)
      Returns:
      zero-based line number of index
    • lineCount

      public int lineCount()
      Number of lines; always at least 1, and 1 for a single-line model.
    • lineStartOfLine

      public int lineStartOfLine(int line)
      Returns:
      char index where the zero-based line starts
    • lineText

      public String lineText(int line)
      Returns:
      the text of the zero-based line (no trailing newline)
    • textVersion

      public long textVersion()
      Bumped by every mutation of the buffer, and by nothing else: a cursor move or a selection change leaves it alone. A view caching anything derived from the text (materialized lines, measured widths) compares this rather than re-deriving: it is the difference between a caret blink repainting and a caret blink rebuilding the screenful it repaints.
      Returns:
      a value that changes whenever the text does; meaningless except compared to itself
    • lineDamage

      public TextEditModel.LineDamage lineDamage()
      What the edits since the last clearLineDamage() touched, or null when the text has not changed since. One splice is held precisely; a second edit before the clear widens the answer to the whole document rather than composing, because the consumer this exists for syncs after every edit — the widget's change handler runs before the next one can land — so composition is the rare path, and a conservative whole-document answer there is a correct re-derivation, never a wrong splice. setText(java.lang.String) and undo()/redo() answer the whole document for the same reason: what they replace is unbounded.
    • clearLineDamage

      public void clearLineDamage()
      Forgets the recorded damage: the consumer has re-derived what it holds.