diff --git a/richtextfx/src/integrationTest/java/org/fxmisc/richtext/keyboard/NavigationTests.java b/richtextfx/src/integrationTest/java/org/fxmisc/richtext/keyboard/NavigationTests.java index 6f9e3e4de..20e7402f2 100644 --- a/richtextfx/src/integrationTest/java/org/fxmisc/richtext/keyboard/NavigationTests.java +++ b/richtextfx/src/integrationTest/java/org/fxmisc/richtext/keyboard/NavigationTests.java @@ -12,6 +12,7 @@ import org.testfx.util.WaitForAsyncUtils; import java.util.Arrays; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -248,6 +249,18 @@ private void moveCaretTo(int position) { area.moveTo(position); } + private void waitForMultiLineRegistration() throws TimeoutException { + // When the stage's width changes, TextFlow does not properly handle API calls to a + // multi-line paragraph immediately. So, wait until it correctly responds + // to the stage width change + Future textFlowIsReady = WaitForAsyncUtils.asyncFx(() -> { + while (area.getParagraphLinesCount(0) != lines.length) { + sleep(10); + } + }); + WaitForAsyncUtils.waitFor(5, TimeUnit.SECONDS, textFlowIsReady); + } + @Override public void start(Stage stage) throws Exception { super.start(stage); @@ -266,12 +279,7 @@ public class NoModifiers { @Before public void setup() throws TimeoutException { - // When the stage's width changes, TextFlow does not properly handle API calls to a - // multi-line paragraph immediately. So, wait until it correctly responds - // to the stage width change - WaitForAsyncUtils.waitFor(5, TimeUnit.SECONDS, - () -> area.getParagraphLinesCount(0) == lines.length - ); + waitForMultiLineRegistration(); } @Test @@ -324,12 +332,7 @@ public class ShortcutDown { @Before public void setup() throws TimeoutException { - // When the stage's width changes, TextFlow does not properly handle API calls to a - // multi-line paragraph immediately. So, wait until it correctly responds - // to the stage width change - WaitForAsyncUtils.waitFor(5, TimeUnit.SECONDS, - () -> area.getParagraphLinesCount(0) == lines.length - ); + waitForMultiLineRegistration(); press(SHORTCUT); } @@ -385,12 +388,7 @@ public class ShiftDown { @Before public void setup() throws TimeoutException { - // When the stage's width changes, TextFlow does not properly handle API calls to a - // multi-line paragraph immediately. So, wait until it correctly responds - // to the stage width change - WaitForAsyncUtils.waitFor(5, TimeUnit.SECONDS, - () -> area.getParagraphLinesCount(0) == lines.length - ); + waitForMultiLineRegistration(); press(SHIFT); } @@ -445,12 +443,7 @@ public class ShortcutShiftDown { @Before public void setup() throws TimeoutException { - // When the stage's width changes, TextFlow does not properly handle API calls to a - // multi-line paragraph immediately. So, wait until it correctly responds - // to the stage width change - WaitForAsyncUtils.waitFor(5, TimeUnit.SECONDS, - () -> area.getParagraphLinesCount(0) == lines.length - ); + waitForMultiLineRegistration(); press(SHORTCUT, SHIFT); } diff --git a/richtextfx/src/integrationTest/java/org/fxmisc/richtext/view/MiscellaneousAPITests.java b/richtextfx/src/integrationTest/java/org/fxmisc/richtext/view/MiscellaneousAPITests.java index 5ca855db3..4087535b0 100644 --- a/richtextfx/src/integrationTest/java/org/fxmisc/richtext/view/MiscellaneousAPITests.java +++ b/richtextfx/src/integrationTest/java/org/fxmisc/richtext/view/MiscellaneousAPITests.java @@ -6,8 +6,8 @@ import javafx.geometry.Point2D; import javafx.geometry.Pos; import javafx.stage.Stage; +import org.fxmisc.richtext.Caret; import org.fxmisc.richtext.InlineCssTextAreaAppTest; -import org.fxmisc.richtext.ViewActions.CaretVisibility; import org.fxmisc.richtext.model.NavigationActions; import org.junit.Before; import org.junit.Test; @@ -83,7 +83,7 @@ public void start(Stage stage) throws Exception { super.start(stage); // insure caret is always visible - area.setShowCaret(CaretVisibility.ON); + area.setShowCaret(Caret.CaretVisibility.ON); StringBuilder sb = new StringBuilder(); for (int i = 0; i < 50; i++) { diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/BoundedSelection.java b/richtextfx/src/main/java/org/fxmisc/richtext/BoundedSelection.java new file mode 100644 index 000000000..d1b9b23a9 --- /dev/null +++ b/richtextfx/src/main/java/org/fxmisc/richtext/BoundedSelection.java @@ -0,0 +1,90 @@ +package org.fxmisc.richtext; + +import javafx.beans.value.ObservableValue; +import org.fxmisc.richtext.model.StyledDocument; + +/** + * An object for encapsulating a selection in a given area that is bound to an underlying caret. In other words, + * {@link #selectRange(int, int) selecting some range in the area} will move a caret in the same call. + * + *

+ * "Position" refers to the place in-between characters. In other words, every {@code "|"} in + * {@code "|t|e|x|t|"} is a valid position. There are two kinds of positions used here:

+ *
    + *
  1. + * {@link #getStartPosition()}/{@link #getEndPosition()}, which refers to a position somewhere in the + * entire area's content. It's bounds are {@code 0 <= x < area.getLength()}. + *
  2. + *
  3. + * {@link #getStartColumnPosition()}/{@link #getEndColumnPosition()}, which refers to a position + * somewhere in the current paragraph. It's bounds are {@code 0 <= x < area.getParagraphLength(index)}. + *
  4. + *
+ * + * Note: when parameter names are "position" without the "column" prefix, they refer to the position in the entire area. + * + *

+ * The selection is typically made using the {@link #getAnchorPosition() anchor's position} and + * the underlying {@link Caret#getPosition() caret's position}. Hence, {@link #selectRange(int, int)} + * is the typical method to use, although {@link #selectRange0(int, int)} can also be used. + *

+ *

+ * Be careful about calling the underlying {@link Caret#moveTo(int)} method. This will displace the caret + * from the selection bounds and may lead to undesirable/unexpected behavior. If this is done, a + * {@link #selectRange(int, int)} call will reposition the caret, so that it is either the start or end + * bound of this selection. + *

+ * + *

+ * For type safety, {@link #getSelectedDocument()} requires the same generic types from {@link StyledDocument}. + * This means that one must write a lot of boilerplate for the generics: + * {@code BoundedSelection, StyledText>, Collection> selection}. + * However, this is only necessary if one is using {@link #getSelectedDocument()} or + * {@link #selectedDocumentProperty()}. If you are not going to use the "selectedDocument" getter or property, + * then just write the much simpler {@code BoundedSelection selection}. + *

+ * + * @param type for {@link StyledDocument}'s paragraph style; only necessary when using the "selectedDocument" + * getter or property + * @param type for {@link StyledDocument}'s segment type; only necessary when using the "selectedDocument" + * getter or property + * @param type for {@link StyledDocument}'s segment style; only necessary when using the "selectedDocument" + * getter or property + */ +public interface BoundedSelection extends UnboundedSelection { + + @Override + default boolean isBoundToCaret() { + return true; + } + + @Override + default BoundedSelection asBoundedSelection() { + return this; + } + + int getAnchorPosition(); + ObservableValue anchorPositionProperty(); + + int getAnchorParIndex(); + ObservableValue anchorParIndexProperty(); + + int getAnchorColPosition(); + ObservableValue anchorColPositionProperty(); + + /** + * Positions the anchor and caretPosition explicitly, + * effectively creating a selection. + * + *

Caution: see {@link org.fxmisc.richtext.model.TextEditingArea#getAbsolutePosition(int, int)} + * to know how the column index argument can affect the returned position.

+ */ + void selectRange(int anchorParagraph, int anchorColumn, int caretParagraph, int caretColumn); + + /** + * Positions the anchor and caretPosition explicitly, + * effectively creating a selection. + */ + void selectRange(int anchorPosition, int caretPosition); + +} diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/BoundedSelectionImpl.java b/richtextfx/src/main/java/org/fxmisc/richtext/BoundedSelectionImpl.java new file mode 100644 index 000000000..876fb491a --- /dev/null +++ b/richtextfx/src/main/java/org/fxmisc/richtext/BoundedSelectionImpl.java @@ -0,0 +1,210 @@ +package org.fxmisc.richtext; + +import javafx.beans.value.ObservableValue; +import javafx.geometry.Bounds; +import javafx.scene.control.IndexRange; +import org.fxmisc.richtext.model.StyledDocument; +import org.reactfx.EventStream; +import org.reactfx.Subscription; +import org.reactfx.Suspendable; +import org.reactfx.SuspendableNo; +import org.reactfx.util.Tuple3; +import org.reactfx.util.Tuples; +import org.reactfx.value.SuspendableVal; +import org.reactfx.value.Val; +import org.reactfx.value.Var; + +import java.util.Optional; + +public class BoundedSelectionImpl implements BoundedSelection { + + private final UnboundedSelection delegate; + @Override public ObservableValue rangeProperty() { return delegate.rangeProperty(); } + @Override public IndexRange getRange() { return delegate.getRange(); } + + @Override public ObservableValue lengthProperty() { return delegate.lengthProperty(); } + @Override public int getLength() { return delegate.getLength(); } + + @Override public ObservableValue paragraphSpanProperty() { return delegate.paragraphSpanProperty(); } + @Override public int getParagraphSpan() { return delegate.getParagraphSpan(); } + + @Override public final ObservableValue> selectedDocumentProperty() { return delegate.selectedDocumentProperty(); } + @Override public final StyledDocument getSelectedDocument() { return delegate.getSelectedDocument(); } + + @Override public ObservableValue selectedTextProperty() { return delegate.selectedTextProperty(); } + @Override public String getSelectedText() { return delegate.getSelectedText(); } + + + @Override public ObservableValue startPositionProperty() { return delegate.startPositionProperty(); } + @Override public int getStartPosition() { return delegate.getStartPosition(); } + + @Override public ObservableValue startParagraphIndexProperty() { return delegate.startParagraphIndexProperty(); } + @Override public int getStartParagraphIndex() { return delegate.getStartParagraphIndex(); } + + @Override public ObservableValue startColumnPositionProperty() { return delegate.startColumnPositionProperty(); } + @Override public int getStartColumnPosition() { return delegate.getStartColumnPosition(); } + + + @Override public ObservableValue endPositionProperty() { return delegate.endPositionProperty(); } + @Override public int getEndPosition() { return delegate.getEndPosition(); } + + @Override public ObservableValue endPararagraphIndexProperty() { return delegate.endPararagraphIndexProperty(); } + @Override public int getEndPararagraphIndex() { return delegate.getEndPararagraphIndex(); } + + @Override public ObservableValue endColumnPositionProperty() { return delegate.endColumnPositionProperty(); } + @Override public int getEndColumnPosition() { return delegate.getEndColumnPosition(); } + + + private final Val anchorPosition; + @Override public int getAnchorPosition() { return anchorPosition.getValue(); } + @Override public ObservableValue anchorPositionProperty() { return anchorPosition; } + + private final Val anchorParIndex; + @Override public int getAnchorParIndex() { return anchorParIndex.getValue(); } + @Override public ObservableValue anchorParIndexProperty() { return anchorParIndex; } + + private final Val anchorColPosition; + @Override public int getAnchorColPosition() { return anchorColPosition.getValue(); } + @Override public ObservableValue anchorColPositionProperty() { return anchorColPosition; } + + @Override public ObservableValue> boundsProperty() { return delegate.boundsProperty(); } + @Override public Optional getBounds() { return delegate.getBounds(); } + + @Override public EventStream dirtyEvents() { return delegate.dirtyEvents(); } + + private final SuspendableNo beingUpdated = new SuspendableNo(); + public final boolean isBeingUpdated() { return beingUpdated.get(); } + public final ObservableValue beingUpdatedProperty() { return beingUpdated; } + + private final Var internalStartedByAnchor = Var.newSimpleVar(true); + private final SuspendableVal startedByAnchor = internalStartedByAnchor.suspendable(); + private boolean anchorIsStart() { return startedByAnchor.getValue(); } + + private final GenericStyledArea area; + private final Caret caret; + + private Subscription subscription = () -> {}; + + BoundedSelectionImpl(GenericStyledArea area) { + this(area, area.getMainCaret()); + } + + BoundedSelectionImpl(GenericStyledArea area, Caret caret) { + this(area, caret, new IndexRange(0, 0)); + } + + BoundedSelectionImpl(GenericStyledArea area, Caret caret, IndexRange startingRange) { + this.area = area; + this.caret = caret; + + SuspendableNo delegateUpdater = new SuspendableNo(); + delegate = new UnboundedSelectionImpl<>(area, delegateUpdater, startingRange); + + Val> anchorPositions = startedByAnchor.flatMap(b -> + b + ? Val.constant(Tuples.t(getStartPosition(), getStartParagraphIndex(), getStartColumnPosition())) + : Val.constant(Tuples.t(getEndPosition(), getEndPararagraphIndex(), getEndColumnPosition())) + ); + + anchorPosition = anchorPositions.map(Tuple3::get1); + anchorParIndex = anchorPositions.map(Tuple3::get2); + anchorColPosition = anchorPositions.map(Tuple3::get3); + + Suspendable omniSuspendable = Suspendable.combine( + // first, so it's released last + beingUpdated, + + startedByAnchor, + + // last, so it's released before startedByAnchor, so that anchor's values are correct + delegateUpdater + ); + + subscription = omniSuspendable.suspendWhen(area.beingUpdatedProperty()); + } + + @Override + public void selectRange(int anchorParagraph, int anchorColumn, int caretParagraph, int caretColumn) { + selectRange(textPosition(anchorParagraph, anchorColumn), textPosition(caretParagraph, caretColumn)); + } + + @Override + public void selectRange(int anchorPosition, int caretPosition) { + if (anchorPosition <= caretPosition) { + doSelect(anchorPosition, caretPosition, true); + } else { + doSelect(caretPosition, anchorPosition, false); + } + } + + @Override + public void selectRange0(int startPosition, int endPosition) { + doSelect(startPosition, endPosition, anchorIsStart()); + } + + @Override + public void selectRange0(int startParagraphIndex, int startColPosition, int endParagraphIndex, int endColPosition) { + selectRange0(textPosition(startParagraphIndex, startColPosition), textPosition(endParagraphIndex, endColPosition)); + } + + @Override + public void moveStartBy(int amount, Direction direction) { + int updatedStart = direction == Direction.LEFT + ? getStartPosition() - amount + : getStartPosition() + amount; + selectRange0(updatedStart, getEndPosition()); + } + + @Override + public void moveEndBy(int amount, Direction direction) { + int updatedEnd = direction == Direction.LEFT + ? getEndPosition() - amount + : getEndPosition() + amount; + selectRange0(getStartPosition(), updatedEnd); + } + + @Override + public void moveStartTo(int position) { + selectRange0(position, getEndPosition()); + } + + @Override + public void moveStartTo(int paragraphIndex, int columnPosition) { + moveStartTo(textPosition(paragraphIndex, columnPosition)); + } + + @Override + public void moveEndTo(int position) { + selectRange0(getStartPosition(), position); + } + + @Override + public void moveEndTo(int paragraphIndex, int columnPosition) { + moveEndTo(textPosition(paragraphIndex, columnPosition)); + } + + @Override + public void dispose() { + subscription.unsubscribe(); + } + + private void doSelect(int startPosition, int endPosition, boolean anchorIsStart) { + Runnable updateRange = () -> { + delegate.selectRange0(startPosition, endPosition); + internalStartedByAnchor.setValue(anchorIsStart); + + caret.moveTo(anchorIsStart ? endPosition : startPosition); + }; + + if (area.isBeingUpdated()) { + updateRange.run(); + } else { + area.beingUpdatedProperty().suspendWhile(updateRange); + } + } + + private int textPosition(int paragraphIndex, int columnPosition) { + return area.position(paragraphIndex, columnPosition).toOffset(); + } + +} diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/Caret.java b/richtextfx/src/main/java/org/fxmisc/richtext/Caret.java new file mode 100644 index 000000000..d584effaa --- /dev/null +++ b/richtextfx/src/main/java/org/fxmisc/richtext/Caret.java @@ -0,0 +1,128 @@ +package org.fxmisc.richtext; + +import javafx.beans.value.ObservableValue; +import javafx.geometry.Bounds; +import org.reactfx.EventStream; +import org.reactfx.value.Var; + +import java.util.Optional; +import java.util.OptionalInt; + +/** + * An object for encapsulating a caret in a given area. + * + *

+ * "Position" refers to the place in-between characters. In other words, every {@code "|"} in + * {@code "|t|e|x|t|"} is a valid position. There are two kinds of positions used here:

+ *
    + *
  1. + * {@link #getPosition()}, which refers to a position somewhere in the entire area's content. + * It's bounds are {@code 0 <= x < area.getLength()}. + *
  2. + *
  3. + * {@link #getColumnPosition()}, which refers to a position somewhere in the current paragraph. + * It's bounds are {@code 0 <= x < area.getParagraphLength(index)}. + *
  4. + *
+ * + * Note: when parameter names are "position" without the "column" prefix, they refer to the position in the entire area. + * + *

+ * "Line" refers either to a single paragraph when {@link GenericStyledArea#isWrapText() the area does not wrap + * its text} or to a line on a multi-line paragraph when the area does wrap its text. + *

+ */ +public interface Caret { + + public static enum CaretVisibility { + /** Caret is displayed. */ + ON, + /** Caret is displayed when area is focused, enabled, and editable. */ + AUTO, + /** Caret is not displayed. */ + OFF + } + + /** The position of the caret within the text */ + public ObservableValue positionProperty(); + public int getPosition(); + + /** The paragraph index that contains this caret */ + public ObservableValue paragraphIndexProperty(); + public int getParagraphIndex(); + + /** The line index of a multi-line paragraph that contains this caret */ + public ObservableValue lineIndexProperty(); + public OptionalInt getLineIndex(); + + /** The column position of the caret on its given line */ + public ObservableValue columnPositionProperty(); + public int getColumnPosition(); + + /** + * Whether to display the caret or not. Default value is + * {@link CaretVisibility#AUTO}. + */ + public Var showCaretProperty(); + public CaretVisibility getShowCaret(); + public void setShowCaret(CaretVisibility value); + + /** Whether the caret is being shown in the viewport */ + public ObservableValue visibleProperty(); + public boolean isVisible(); + + /** + * The boundsProperty of the caret in the Screen's coordinate system or {@link Optional#empty()} if caret is not visible + * in the viewport. + */ + public ObservableValue> boundsProperty(); + public Optional getBounds(); + + /** + * Clears the caret's x offset + */ + void clearTargetOffset(); + + /** + * Stores the caret's current column position, so that moving the caret vertically will keep it close to its + * original offset in a line. + */ + ParagraphBox.CaretOffsetX getTargetOffset(); + + /** Emit an event whenever the caret's position becomes dirty */ + public EventStream dirtyEvents(); + + boolean isBeingUpdated(); + ObservableValue beingUpdatedProperty(); + + /** + * Moves the caret to the given position in the area. If this caret is bound to a {@link BoundedSelection}, + * it displaces the caret from the selection by positioning only the caret to the new location without + * also affecting the {@link BoundedSelection#getAnchorPosition()} bounded selection's anchor} or the + * {@link BoundedSelection#getRange()} selection}. + *
+ * This method can be used to achieve the special case of positioning the caret outside or inside the selection, + * as opposed to always being at the boundary. Use with care. + * + *

Caution: see {@link org.fxmisc.richtext.model.TextEditingArea#getAbsolutePosition(int, int)} to + * know how the column index argument can affect the returned position.

+ */ + public void moveTo(int paragraphIndex, int columnPosition); + + /** + * Moves the caret to the given position in the area. If this caret is bound to a {@link BoundedSelection}, + * it displaces the caret from the selection by positioning only the caret to the new location without + * also affecting the {@link BoundedSelection#getAnchorPosition()} bounded selection's anchor} or the + * {@link BoundedSelection#getRange()} selection}. + *
+ * This method can be used to achieve the special case of positioning the caret outside or inside the selection, + * as opposed to always being at the boundary. Use with care. + */ + public void moveTo(int position); + + /** + * Disposes the caret and prevents memory leaks + */ + public void dispose(); + +} diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/CaretImpl.java b/richtextfx/src/main/java/org/fxmisc/richtext/CaretImpl.java new file mode 100644 index 000000000..ec6011348 --- /dev/null +++ b/richtextfx/src/main/java/org/fxmisc/richtext/CaretImpl.java @@ -0,0 +1,214 @@ +package org.fxmisc.richtext; + +import javafx.beans.binding.Binding; +import javafx.beans.value.ObservableValue; +import javafx.geometry.Bounds; +import org.fxmisc.richtext.model.TwoDimensional; +import org.reactfx.EventStream; +import org.reactfx.EventStreams; +import org.reactfx.StateMachine; +import org.reactfx.Subscription; +import org.reactfx.Suspendable; +import org.reactfx.SuspendableNo; +import org.reactfx.value.SuspendableVal; +import org.reactfx.value.Val; +import org.reactfx.value.Var; + +import java.time.Duration; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.function.Consumer; + +import static javafx.util.Duration.ZERO; +import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward; +import static org.reactfx.EventStreams.invalidationsOf; +import static org.reactfx.EventStreams.merge; + +final class CaretImpl implements Caret { + + private final Var internalTextPosition; + + private final SuspendableVal position; + @Override public final int getPosition() { return position.getValue(); } + @Override public final ObservableValue positionProperty() { return position; } + + private final SuspendableVal paragraphIndex; + @Override public final int getParagraphIndex() { return paragraphIndex.getValue(); } + @Override public final ObservableValue paragraphIndexProperty() { return paragraphIndex; } + + private final SuspendableVal lineIndex; + @Override public final OptionalInt getLineIndex() { return lineIndex.getValue(); } + @Override public final ObservableValue lineIndexProperty() { return lineIndex; } + + private final SuspendableVal columnPosition; + @Override public final int getColumnPosition() { return columnPosition.getValue(); } + @Override public final ObservableValue columnPositionProperty() { return columnPosition; } + + private final Var showCaret = Var.newSimpleVar(CaretVisibility.AUTO); + @Override public final CaretVisibility getShowCaret() { return showCaret.getValue(); } + @Override public final void setShowCaret(CaretVisibility value) { showCaret.setValue(value); } + @Override public final Var showCaretProperty() { return showCaret; } + + private final Binding visible; + @Override public final boolean isVisible() { return visible.getValue(); } + @Override public final ObservableValue visibleProperty() { return visible; } + + private final Val> bounds; + @Override public final Optional getBounds() { return bounds.getValue(); } + @Override public final ObservableValue> boundsProperty() { return bounds; } + + private Optional targetOffset = Optional.empty(); + @Override public final void clearTargetOffset() { targetOffset = Optional.empty(); } + @Override public final ParagraphBox.CaretOffsetX getTargetOffset() { + if (!targetOffset.isPresent()) { + targetOffset = Optional.of(area.getCaretOffsetX(getParagraphIndex())); + } + return targetOffset.get(); + } + + private final EventStream dirty; + @Override public final EventStream dirtyEvents() { return dirty; } + + private final SuspendableNo beingUpdated = new SuspendableNo(); + @Override public final boolean isBeingUpdated() { return beingUpdated.get(); } + @Override public final ObservableValue beingUpdatedProperty() { return beingUpdated; } + + private final GenericStyledArea area; + private final SuspendableNo dependentBeingUpdated; + + private Subscription subscriptions = () -> {}; + + CaretImpl(GenericStyledArea area) { + this(area, 0); + } + + CaretImpl(GenericStyledArea area, int startingPosition) { + this(area, area.beingUpdatedProperty(), 0); + } + + CaretImpl(GenericStyledArea area, SuspendableNo dependentBeingUpdated, int startingPosition) { + this.area = area; + this.dependentBeingUpdated = dependentBeingUpdated; + internalTextPosition = Var.newSimpleVar(startingPosition); + position = internalTextPosition.suspendable(); + + Val caretPosition2D = Val.create( + () -> area.offsetToPosition(internalTextPosition.getValue(), Forward), + internalTextPosition, area.getParagraphs() + ); + paragraphIndex = caretPosition2D.map(TwoDimensional.Position::getMajor).suspendable(); + columnPosition = caretPosition2D.map(TwoDimensional.Position::getMinor).suspendable(); + + // when content is updated by an area, update the caret of all the other + // clones that also display the same document + manageSubscription(area.plainTextChanges(), (plainTextChange -> { + int changeLength = plainTextChange.getInserted().length() - plainTextChange.getRemoved().length(); + if (changeLength != 0) { + int indexOfChange = plainTextChange.getPosition(); + // in case of a replacement: "hello there" -> "hi." + int endOfChange = indexOfChange + Math.abs(changeLength); + + int caretPosition = getPosition(); + if (indexOfChange < caretPosition) { + // if caret is within the changed content, move it to indexOfChange + // otherwise offset it by changeLength + moveTo( + caretPosition < endOfChange + ? indexOfChange + : caretPosition + changeLength + ); + } + } + })); + + // whether or not to display the caret + EventStream blinkCaret = showCaret.values() + .flatMap(mode -> { + switch (mode) { + case ON: return Val.constant(true).values(); + case OFF: return Val.constant(false).values(); + default: + case AUTO: return area.autoCaretBlink(); + } + }); + + dirty = merge( + invalidationsOf(positionProperty()), + invalidationsOf(area.getParagraphs()) + ); + + // The caret is visible in periodic intervals, + // but only when blinkCaret is true. + visible = EventStreams.combine(blinkCaret, area.caretBlinkRateEvents()) + .flatMap(tuple -> { + Boolean blink = tuple.get1(); + javafx.util.Duration rate = tuple.get2(); + if(blink) { + return rate.lessThanOrEqualTo(ZERO) + ? Val.constant(true).values() + : booleanPulse(rate, dirty); + } else { + return Val.constant(false).values(); + } + }) + .toBinding(false); + manageBinding(visible); + + bounds = Val.create( + () -> area.getCaretBoundsOnScreen(getParagraphIndex()), + area.boundsDirtyFor(dirty) + ); + + lineIndex = Val.create( + () -> OptionalInt.of(area.lineIndex(getParagraphIndex(), getColumnPosition())), + dirty + ).suspendable(); + + Suspendable omniSuspendable = Suspendable.combine( + beingUpdated, + + paragraphIndex, + columnPosition, + position + ); + manageSubscription(omniSuspendable.suspendWhen(dependentBeingUpdated)); + } + + public void moveTo(int paragraphIndex, int columnPosition) { + moveTo(textPosition(paragraphIndex, columnPosition)); + } + + public void moveTo(int position) { + dependentBeingUpdated.suspendWhile(() -> internalTextPosition.setValue(position)); + } + + public void dispose() { + subscriptions.unsubscribe(); + } + + private int textPosition(int row, int col) { + return area.position(row, col).toOffset(); + } + + private void manageSubscription(EventStream stream, Consumer subscriber) { + manageSubscription(stream.subscribe(subscriber)); + } + + private void manageBinding(Binding binding) { + manageSubscription(binding::dispose); + } + + private void manageSubscription(Subscription s) { + subscriptions = subscriptions.and(s); + } + + private static EventStream booleanPulse(javafx.util.Duration javafxDuration, EventStream restartImpulse) { + Duration duration = Duration.ofMillis(Math.round(javafxDuration.toMillis())); + EventStream ticks = EventStreams.restartableTicks(duration, restartImpulse); + return StateMachine.init(false) + .on(restartImpulse.withDefaultEvent(null)).transition((state, impulse) -> true) + .on(ticks).transition((state, tick) -> !state) + .toStateStream(); + } + +} diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java b/richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java index c43b323db..e58062e1a 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java @@ -1,9 +1,6 @@ package org.fxmisc.richtext; -import static javafx.util.Duration.*; import static org.fxmisc.richtext.PopupAlignment.*; -import static org.fxmisc.richtext.model.TwoDimensional.Bias.Backward; -import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward; import static org.reactfx.EventStreams.*; import static org.reactfx.util.Tuples.*; @@ -20,7 +17,7 @@ import java.util.function.IntSupplier; import java.util.function.IntUnaryOperator; import java.util.function.UnaryOperator; -import java.util.stream.Stream; +import java.util.stream.Collectors; import javafx.beans.NamedArg; import javafx.beans.binding.Binding; @@ -31,7 +28,6 @@ import javafx.beans.property.Property; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; -import javafx.beans.value.ObservableBooleanValue; import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ObservableSet; @@ -88,7 +84,6 @@ import org.reactfx.EventStream; import org.reactfx.EventStreams; import org.reactfx.Guard; -import org.reactfx.StateMachine; import org.reactfx.Subscription; import org.reactfx.Suspendable; import org.reactfx.SuspendableEventStream; @@ -96,8 +91,6 @@ import org.reactfx.collection.LiveList; import org.reactfx.collection.SuspendableList; import org.reactfx.util.Tuple2; -import org.reactfx.value.SuspendableVal; -import org.reactfx.value.SuspendableVar; import org.reactfx.value.Val; import org.reactfx.value.Var; @@ -310,12 +303,6 @@ private static int clamp(int min, int val, int max) { @Override public final void setWrapText(boolean value) { wrapText.set(value); } @Override public final BooleanProperty wrapTextProperty() { return wrapText; } - // showCaret property - private final Var showCaret = Var.newSimpleVar(CaretVisibility.AUTO); - @Override public final CaretVisibility getShowCaret() { return showCaret.getValue(); } - @Override public final void setShowCaret(CaretVisibility value) { showCaret.setValue(value); } - @Override public final Var showCaretProperty() { return showCaret; } - // undo manager private UndoManager undoManager; @Override public UndoManager getUndoManager() { return undoManager; } @@ -449,52 +436,16 @@ public Optional, Codec>> getStyleCodecs() { // rich text @Override public final StyledDocument getDocument() { return content; } + private final Caret mainCaret; + @Override public final Caret getMainCaret() { return mainCaret; } + + private final BoundedSelection mainSelection; + @Override public final BoundedSelection getMainSelection() { return mainSelection; } + // length @Override public final int getLength() { return content.getLength(); } @Override public final ObservableValue lengthProperty() { return content.lengthProperty(); } - // caret position - private final Var internalCaretPosition = Var.newSimpleVar(0); - private final SuspendableVal caretPosition = internalCaretPosition.suspendable(); - @Override public final int getCaretPosition() { return caretPosition.getValue(); } - @Override public final ObservableValue caretPositionProperty() { return caretPosition; } - - // caret bounds - private final Val> caretBounds; - @Override public final Optional getCaretBounds() { return caretBounds.getValue(); } - @Override public final ObservableValue> caretBoundsProperty() { return caretBounds; } - - // selection anchor - private final SuspendableVar anchor = Var.newSimpleVar(0).suspendable(); - @Override public final int getAnchor() { return anchor.getValue(); } - @Override public final ObservableValue anchorProperty() { return anchor; } - - // selection - private final Var internalSelection = Var.newSimpleVar(EMPTY_RANGE); - private final SuspendableVal selection = internalSelection.suspendable(); - @Override public final IndexRange getSelection() { return selection.getValue(); } - @Override public final ObservableValue selectionProperty() { return selection; } - - // selected text - private final SuspendableVal selectedText; - @Override public final String getSelectedText() { return selectedText.getValue(); } - @Override public final ObservableValue selectedTextProperty() { return selectedText; } - - // selection bounds - private final Val> selectionBounds; - @Override public final Optional getSelectionBounds() { return selectionBounds.getValue(); } - @Override public final ObservableValue> selectionBoundsProperty() { return selectionBounds; } - - // current paragraph index - private final SuspendableVal currentParagraph; - @Override public final int getCurrentParagraph() { return currentParagraph.getValue(); } - @Override public final ObservableValue currentParagraphProperty() { return currentParagraph; } - - // caret column - private final SuspendableVal caretColumn; - @Override public final int getCaretColumn() { return caretColumn.getValue(); } - @Override public final ObservableValue caretColumnProperty() { return caretColumn; } - // paragraphs @Override public LiveList> getParagraphs() { return content.getParagraphs(); } @@ -503,8 +454,8 @@ public Optional, Codec>> getStyleCodecs() { // beingUpdated private final SuspendableNo beingUpdated = new SuspendableNo(); - public ObservableBooleanValue beingUpdatedProperty() { return beingUpdated; } - public boolean isBeingUpdated() { return beingUpdated.get(); } + public final SuspendableNo beingUpdatedProperty() { return beingUpdated; } + public final boolean isBeingUpdated() { return beingUpdated.get(); } // total width estimate @Override @@ -536,16 +487,11 @@ public Optional, Codec>> getStyleCodecs() { * * * ********************************************************************** */ - private Position selectionStart2D; - private Position selectionEnd2D; - private Subscription subscriptions = () -> {}; // Remembers horizontal position when traversing up / down. private Optional targetCaretOffset = Optional.empty(); - private final Binding caretVisible; - private final Val> _popupAnchorAdjustment; private final VirtualFlow, Cell, ParagraphBox>> virtualFlow; @@ -593,6 +539,16 @@ public Optional, Codec>> getStyleCodecs() { private final TextOps segmentOps; @Override public final SegmentOps getSegOps() { return segmentOps; } + private final EventStream autoCaretBlinksSteam; + final EventStream autoCaretBlink() { return autoCaretBlinksSteam; } + + private final EventStream caretBlinkRateStream; + final EventStream caretBlinkRateEvents() { return caretBlinkRateStream; } + + final EventStream boundsDirtyFor(EventStream dirtyStream) { + return EventStreams.merge(viewportDirty, dirtyStream).suppressWhen(beingUpdatedProperty()); + } + /* ********************************************************************** * * * * Constructors * @@ -666,77 +622,6 @@ public GenericStyledArea( ? createRichUndoManager(UndoManagerFactory.unlimitedHistoryFactory()) : createPlainUndoManager(UndoManagerFactory.unlimitedHistoryFactory()); - Val caretPosition2D = Val.create( - () -> content.offsetToPosition(internalCaretPosition.getValue(), Forward), - internalCaretPosition, getParagraphs()); - - currentParagraph = caretPosition2D.map(Position::getMajor).suspendable(); - caretColumn = caretPosition2D.map(Position::getMinor).suspendable(); - - selectionStart2D = position(0, 0); - selectionEnd2D = position(0, 0); - internalSelection.addListener(obs -> { - IndexRange sel = internalSelection.getValue(); - selectionStart2D = offsetToPosition(sel.getStart(), Forward); - selectionEnd2D = sel.getLength() == 0 - ? selectionStart2D - : selectionStart2D.offsetBy(sel.getLength(), Backward); - }); - - selectedText = Val.create( - () -> content.getText(internalSelection.getValue()), - internalSelection, content.getParagraphs()).suspendable(); - - // when content is updated by an area, update the caret - // and selection ranges of all the other - // clones that also share this document - subscribeTo(plainTextChanges(), plainTextChange -> { - int changeLength = plainTextChange.getInserted().length() - plainTextChange.getRemoved().length(); - if (changeLength != 0) { - int indexOfChange = plainTextChange.getPosition(); - // in case of a replacement: "hello there" -> "hi." - int endOfChange = indexOfChange + Math.abs(changeLength); - - // update caret - int caretPosition = getCaretPosition(); - if (indexOfChange < caretPosition) { - // if caret is within the changed content, move it to indexOfChange - // otherwise offset it by changeLength - displaceCaret( - caretPosition < endOfChange - ? indexOfChange - : caretPosition + changeLength - ); - } - // update selection - int selectionStart = getSelection().getStart(); - int selectionEnd = getSelection().getEnd(); - if (selectionStart != selectionEnd) { - // if start/end is within the changed content, move it to indexOfChange - // otherwise, offset it by changeLength - // Note: if both are moved to indexOfChange, selection is empty. - if (indexOfChange < selectionStart) { - selectionStart = selectionStart < endOfChange - ? indexOfChange - : selectionStart + changeLength; - } - if (indexOfChange < selectionEnd) { - selectionEnd = selectionEnd < endOfChange - ? indexOfChange - : selectionEnd + changeLength; - } - selectRange(selectionStart, selectionEnd); - } else { - // force-update internalSelection in case caret is - // at the end of area and a character was deleted - // (prevents a StringIndexOutOfBoundsException because - // selection's end is one char farther than area's length). - int internalCaretPos = internalCaretPosition.getValue(); - selectRange(internalCaretPos, internalCaretPos); - } - } - }); - // allow tab traversal into area setFocusTraversable(true); @@ -762,20 +647,6 @@ public GenericStyledArea( }); getChildren().add(virtualFlow); - visibleParagraphs = LiveList.map(virtualFlow.visibleCells(), c -> c.getNode().getParagraph()).suspendable(); - - final Suspendable omniSuspendable = Suspendable.combine( - beingUpdated, // must be first, to be the last one to release - - visibleParagraphs, - caretPosition, - anchor, - selection, - selectedText, - currentParagraph, - caretColumn); - manageSubscription(omniSuspendable.suspendWhen(content.beingUpdatedProperty())); - // initialize navigator IntSupplier cellCount = () -> getParagraphs().size(); IntUnaryOperator cellLength = i -> virtualFlow.getCell(i).getNode().getLineCount(); @@ -788,49 +659,6 @@ public GenericStyledArea( EventStream popupDirty = merge(popupAlignmentDirty, popupAnchorAdjustmentDirty, popupAnchorOffsetDirty); subscribeTo(popupDirty, x -> layoutPopup()); - // follow the caret every time the caret position or paragraphs change - EventStream caretPosDirty = invalidationsOf(caretPositionProperty()); - EventStream paragraphsDirty = invalidationsOf(getParagraphs()); - EventStream selectionDirty = invalidationsOf(selectionProperty()); - // need to reposition popup even when caret hasn't moved, but selection has changed (been deselected) - EventStream caretDirty = merge(caretPosDirty, paragraphsDirty, selectionDirty); - - // whether or not to display the caret - EventStream blinkCaret = EventStreams.valuesOf(showCaretProperty()) - .flatMap(mode -> { - switch (mode) { - case ON: - return EventStreams.valuesOf(Val.constant(true)); - case OFF: - return EventStreams.valuesOf(Val.constant(false)); - default: - case AUTO: - return EventStreams.valuesOf(focusedProperty() - .and(editableProperty()) - .and(disabledProperty().not())); - } - }); - - // the rate at which to display the caret - EventStream blinkRate = EventStreams.valuesOf(caretBlinkRate); - - // The caret is visible in periodic intervals, - // but only when blinkCaret is true. - caretVisible = EventStreams.combine(blinkCaret, blinkRate) - .flatMap(tuple -> { - Boolean blink = tuple.get1(); - javafx.util.Duration rate = tuple.get2(); - if(blink) { - return rate.lessThanOrEqualTo(ZERO) - ? EventStreams.valuesOf(Val.constant(true)) - : booleanPulse(rate, caretDirty); - } else { - return EventStreams.valuesOf(Val.constant(false)); - } - }) - .toBinding(false); - manageBinding(caretVisible); - viewportDirty = merge( // no need to check for width & height invalidations as scroll values update when these do @@ -842,14 +670,17 @@ public GenericStyledArea( invalidationsOf(estimatedScrollXProperty()), invalidationsOf(estimatedScrollYProperty()) ).suppressible(); - EventStream caretBoundsDirty = merge(viewportDirty, caretDirty) - .suppressWhen(beingUpdatedProperty()); - EventStream selectionBoundsDirty = merge(viewportDirty, invalidationsOf(selectionProperty())) - .suppressWhen(beingUpdatedProperty()); - // updates the bounds of the caret/selection - caretBounds = Val.create(this::getCaretBoundsOnScreen, caretBoundsDirty); - selectionBounds = Val.create(this::impl_bounds_getSelectionBoundsOnScreen, selectionBoundsDirty); + autoCaretBlinksSteam = EventStreams.valuesOf(focusedProperty() + .and(editableProperty()) + .and(disabledProperty().not()) + ); + caretBlinkRateStream = EventStreams.valuesOf(caretBlinkRate); + + mainCaret = new CaretImpl(this); + mainSelection = new BoundedSelectionImpl<>(this); + + visibleParagraphs = LiveList.map(virtualFlow.visibleCells(), c -> c.getNode().getParagraph()).suspendable(); // Adjust popup anchor by either a user-provided function, // or user-provided offset, or don't adjust at all. @@ -862,6 +693,13 @@ public GenericStyledArea( userOffset) .orElseConst(UnaryOperator.identity()); + final Suspendable omniSuspendable = Suspendable.combine( + beingUpdated, // must be first, to be the last one to release + + visibleParagraphs + ); + manageSubscription(omniSuspendable.suspendWhen(content.beingUpdatedProperty())); + // dispatch MouseOverTextEvents when mouseOverTextDelay is not null EventStreams.valuesOf(mouseOverTextDelayProperty()) .flatMap(delay -> delay != null @@ -895,9 +733,8 @@ Optional getCaretBoundsInViewport() { /** * Returns x coordinate of the caret in the current paragraph. */ - ParagraphBox.CaretOffsetX getCaretOffsetX() { - int idx = getCurrentParagraph(); - return getCell(idx).getCaretOffsetX(); + final ParagraphBox.CaretOffsetX getCaretOffsetX(int paragraphIndex) { + return getCell(paragraphIndex).getCaretOffsetX(); } double getViewportHeight() { @@ -963,6 +800,11 @@ TwoDimensional.Position currentLine() { return _position(parIdx, lineIdx); } + public final int lineIndex(int paragraphIndex, int column) { + Cell, ParagraphBox> cell = virtualFlow.getCell(paragraphIndex); + return cell.getNode().getCurrentLineIndex(column); + } + TwoDimensional.Position _position(int par, int line) { return navigator.position(par, line); } @@ -1037,6 +879,11 @@ public String getText(int paragraph) { return content.getText(paragraph); } + @Override + public String getText(IndexRange range) { + return content.getText(range); + } + public Paragraph getParagraph(int index) { return content.getParagraph(index); } @@ -1059,18 +906,22 @@ public StyledDocument subDocument(int paragraphIndex) { * Returns the selection range in the given paragraph. */ public IndexRange getParagraphSelection(int paragraph) { - int startPar = selectionStart2D.getMajor(); - int endPar = selectionEnd2D.getMajor(); + return getParagraphSelection(mainSelection, paragraph); + } + + public IndexRange getParagraphSelection(UnboundedSelection selection, int paragraph) { + int startPar = selection.getStartParagraphIndex(); + int endPar = selection.getEndPararagraphIndex(); if(paragraph < startPar || paragraph > endPar) { return EMPTY_RANGE; } - int start = paragraph == startPar ? selectionStart2D.getMinor() : 0; - int end = paragraph == endPar ? selectionEnd2D.getMinor() : getParagraphLenth(paragraph); + int start = paragraph == startPar ? selection.getStartColumnPosition() : 0; + int end = paragraph == endPar ? selection.getEndColumnPosition() : getParagraphLenth(paragraph); - // force selectionProperty() to be valid - getSelection(); + // force rangeProperty() to be valid + selection.getRange(); return new IndexRange(start, end); } @@ -1255,9 +1106,7 @@ public void nextPage(SelectionPolicy selectionPolicy) { * as opposed to always being at the boundary. Use with care. */ public void displaceCaret(int pos) { - try(Guard g = suspend(caretPosition, currentParagraph, caretColumn)) { - internalCaretPosition.setValue(pos); - } + mainCaret.moveTo(pos); } /** @@ -1314,18 +1163,6 @@ public void replace(int start, int end, StyledDocument replacement) selectRange(newCaretPos, newCaretPos); } - @Override - public void selectRange(int anchor, int caretPosition) { - try(Guard g = suspend( - this.caretPosition, currentParagraph, - caretColumn, this.anchor, - selection, selectedText)) { - this.internalCaretPosition.setValue(clamp(0, caretPosition, getLength())); - this.anchor.setValue(clamp(0, anchor, getLength())); - this.internalSelection.setValue(IndexRange.normalize(getAnchor(), getCaretPosition())); - } - } - /* ********************************************************************** * * * * Public API * @@ -1396,7 +1233,7 @@ private Cell, ParagraphBox> createCell( ).subscribe(in -> in.exec((i, n) -> box.pseudoClassStateChanged(LAST_PAR, i == n-1))); // caret is visible only in the paragraph with the caret - Val cellCaretVisible = hasCaret.flatMap(x -> x ? caretVisible : Val.constant(false)); + Val cellCaretVisible = hasCaret.flatMap(x -> x ? mainCaret.visibleProperty() : Val.constant(false)); box.caretVisibleProperty().bind(cellCaretVisible); // bind cell's caret position to area's caret column, @@ -1506,6 +1343,11 @@ private Optional getCaretBoundsOnScreen() { .map(c -> c.getNode().getCaretBoundsOnScreen()); } + public final Optional getCaretBoundsOnScreen(int paragraphIndex) { + return virtualFlow.getCellIfVisible(paragraphIndex) + .map(c -> c.getNode().getCaretBoundsOnScreen()); + } + private Optional impl_popup_getSelectionBoundsOnScreen() { IndexRange selection = getSelection(); if(selection.getLength() == 0) { @@ -1523,20 +1365,49 @@ private Optional impl_bounds_getSelectionBoundsOnScreen() { return impl_getSelectionBoundsOnScreen(); } + final Optional impl_bounds_getSelectionBoundsOnScreen(UnboundedSelection selection) { + if (selection.getLength() == 0) { + return Optional.empty(); + } + return impl_getSelectionBoundsOnScreen(selection); + } + + private Optional impl_getSelectionBoundsOnScreen(UnboundedSelection selection) { + if (selection.getLength() == 0) { + return Optional.empty(); + } + + List bounds = new ArrayList<>(selection.getParagraphSpan()); + for (int i = selection.getStartParagraphIndex(); i <= selection.getEndPararagraphIndex(); i++) { + final int i0 = i; + virtualFlow.getCellIfVisible(i).ifPresent(c -> { + IndexRange rangeWithinPar = getParagraphSelection(selection, i0); + Bounds b = c.getNode().getRangeBoundsOnScreen(rangeWithinPar); + bounds.add(b); + }); + } + + return reduceBoundsList(bounds); + } + private Optional impl_getSelectionBoundsOnScreen() { - Bounds[] bounds = virtualFlow.visibleCells().stream() + List bounds = virtualFlow.visibleCells().stream() .map(c -> c.getNode().getSelectionBoundsOnScreen()) .filter(Optional::isPresent) .map(Optional::get) - .toArray(Bounds[]::new); + .collect(Collectors.toCollection(ArrayList::new)); - if(bounds.length == 0) { + return reduceBoundsList(bounds); + } + + private Optional reduceBoundsList(List bounds) { + if(bounds.size() == 0) { return Optional.empty(); } - double minX = Stream.of(bounds).mapToDouble(Bounds::getMinX).min().getAsDouble(); - double maxX = Stream.of(bounds).mapToDouble(Bounds::getMaxX).max().getAsDouble(); - double minY = Stream.of(bounds).mapToDouble(Bounds::getMinY).min().getAsDouble(); - double maxY = Stream.of(bounds).mapToDouble(Bounds::getMaxY).max().getAsDouble(); + double minX = bounds.stream().mapToDouble(Bounds::getMinX).min().getAsDouble(); + double maxX = bounds.stream().mapToDouble(Bounds::getMaxX).max().getAsDouble(); + double minY = bounds.stream().mapToDouble(Bounds::getMinY).min().getAsDouble(); + double maxY = bounds.stream().mapToDouble(Bounds::getMaxY).max().getAsDouble(); return Optional.of(new BoundingBox(minX, minY, maxX-minX, maxY-minY)); } @@ -1590,10 +1461,6 @@ private UndoManager createRichUndoManager(UndoManagerFactory factory) { return factory.create(richChanges(), RichTextChange::invert, apply, merge, TextChange::isIdentity); } - private Guard suspend(Suspendable... suspendables) { - return Suspendable.combine(beingUpdated, Suspendable.combine(suspendables)).suspend(); - } - private void suspendVisibleParsWhile(Runnable runnable) { Suspendable.combine(beingUpdated, visibleParagraphs).suspendWhile(runnable); } @@ -1603,18 +1470,7 @@ void clearTargetCaretOffset() { } ParagraphBox.CaretOffsetX getTargetCaretOffset() { - if(!targetCaretOffset.isPresent()) - targetCaretOffset = Optional.of(getCaretOffsetX()); - return targetCaretOffset.get(); - } - - private static EventStream booleanPulse(javafx.util.Duration javafxDuration, EventStream restartImpulse) { - Duration duration = Duration.ofMillis(Math.round(javafxDuration.toMillis())); - EventStream ticks = EventStreams.restartableTicks(duration, restartImpulse); - return StateMachine.init(false) - .on(restartImpulse.withDefaultEvent(null)).transition((state, impulse) -> true) - .on(ticks).transition((state, tick) -> !state) - .toStateStream(); + return mainCaret.getTargetOffset(); } /* ********************************************************************** * diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphBox.java b/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphBox.java index 04b010e10..bfcdfd852 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphBox.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphBox.java @@ -165,6 +165,11 @@ public int getCurrentLineIndex() { return text.currentLineIndex(); } + public int getCurrentLineIndex(int position) { + layout(); // ensure layout, is a no-op if not dirty + return text.currentLineIndex(position); + } + public Bounds getCaretBounds() { layout(); // ensure layout, is a no-op if not dirty Bounds b = text.getCaretBounds(); @@ -186,6 +191,10 @@ public Bounds getRangeBoundsOnScreen(int from, int to) { return text.getRangeBoundsOnScreen(from, to); } + public Bounds getRangeBoundsOnScreen(IndexRange range) { + return getRangeBoundsOnScreen(range.getStart(), range.getEnd()); + } + @Override protected double computeMinWidth(double ignoredHeight) { return computePrefWidth(-1); diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphText.java b/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphText.java index ac689a70b..ecbeda2fd 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphText.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphText.java @@ -188,6 +188,10 @@ public int currentLineIndex() { return getLineOfCharacter(clampedCaretPosition.getValue()); } + public int currentLineIndex(int position) { + return getLineOfCharacter(position); + } + private void updateCaretShape() { PathElement[] shape = getCaretShape(clampedCaretPosition.getValue(), true); caretShape.getElements().setAll(shape); diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/UnboundedSelection.java b/richtextfx/src/main/java/org/fxmisc/richtext/UnboundedSelection.java new file mode 100644 index 000000000..5a7196df0 --- /dev/null +++ b/richtextfx/src/main/java/org/fxmisc/richtext/UnboundedSelection.java @@ -0,0 +1,166 @@ +package org.fxmisc.richtext; + +import javafx.beans.value.ObservableValue; +import javafx.geometry.Bounds; +import javafx.scene.control.IndexRange; +import org.fxmisc.richtext.model.StyledDocument; +import org.reactfx.EventStream; + +import java.util.Optional; + +/** + * An object for encapsulating a selection in a given area that is not bound to any caret. In other words, + * {@link #selectRange0(int, int) selecting some range in the area} will not also move a caret in the same call. + * + *

+ * "Position" refers to the place in-between characters. In other words, every {@code "|"} in + * {@code "|t|e|x|t|"} is a valid position. There are two kinds of positions used here:

+ *
    + *
  1. + * {@link #getStartPosition()}/{@link #getEndPosition()}, which refers to a position somewhere in the + * entire area's content. It's bounds are {@code 0 <= x < area.getLength()}. + *
  2. + *
  3. + * {@link #getStartColumnPosition()}/{@link #getEndColumnPosition()}, which refers to a position + * somewhere in the current paragraph. It's bounds are {@code 0 <= x < area.getParagraphLength(index)}. + *
  4. + *
+ * + * Note: when parameter names are "position" without the "column" prefix, they refer to the position in the entire area. + * + *

+ * For type safety, {@link #getSelectedDocument()} requires the same generic types from {@link StyledDocument}. + * This means that one must write a lot of boilerplate for the generics: + * {@code UnboundedSelection, StyledText>, Collection> selection}. + * However, this is only necessary if one is using {@link #getSelectedDocument()} or + * {@link #selectedDocumentProperty()}. If you are not going to use the "selectedDocument" getter or property, + * then just write the much simpler {@code UnboundedSelection selection}. + *

+ * + * @see BoundedSelection + * @see Caret + * + * @param type for {@link StyledDocument}'s paragraph style; only necessary when using the "selectedDocument" + * getter or property + * @param type for {@link StyledDocument}'s segment type; only necessary when using the "selectedDocument" + * getter or property + * @param type for {@link StyledDocument}'s segment style; only necessary when using the "selectedDocument" + * getter or property + */ +public interface UnboundedSelection { + + public static enum Direction { + LEFT, + RIGHT + } + + /** + * Returns true if this is an {@link UnboundedSelection} and true if this is an {@link BoundedSelection}. + */ + default boolean isBoundToCaret() { + return false; + } + + /** + * If {@link #isBoundToCaret()} returns true, then casts this object to a {@link BoundedSelection}. Otherwise, + * throws an {@link IllegalStateException}. + */ + default BoundedSelection asBoundedSelection() { + throw new IllegalStateException("An UnboundedSelection cannot be cast to a BoundedSelection"); + } + + /** + * The start and end positions in the area as an {@link IndexRange}. + */ + ObservableValue rangeProperty(); + IndexRange getRange(); + + /** + * The length of the selection + */ + ObservableValue lengthProperty(); + int getLength(); + + /** + * The number of paragraphs the selection spans + */ + ObservableValue paragraphSpanProperty(); + int getParagraphSpan(); + + ObservableValue> selectedDocumentProperty(); + StyledDocument getSelectedDocument(); + + ObservableValue selectedTextProperty(); + String getSelectedText(); + + + ObservableValue startPositionProperty(); + int getStartPosition(); + + ObservableValue startParagraphIndexProperty(); + int getStartParagraphIndex(); + + ObservableValue startColumnPositionProperty(); + int getStartColumnPosition(); + + + ObservableValue endPositionProperty(); + int getEndPosition(); + + ObservableValue endPararagraphIndexProperty(); + int getEndPararagraphIndex(); + + ObservableValue endColumnPositionProperty(); + int getEndColumnPosition(); + + + /** + * The boundsProperty of the selection in the Screen's coordinate system if something is selected and visible in the + * viewport, or {@link Optional#empty()} if selection is not visible in the viewport. + */ + ObservableValue> boundsProperty(); + Optional getBounds(); + + /** + * Emits an event every time the selection becomes dirty: the start/end positions change or the area's paragraphs + * change. + */ + EventStream dirtyEvents(); + + boolean isBeingUpdated(); + ObservableValue beingUpdatedProperty(); + + + /** + * Selects the given range. Note: this method's "0" suffix distinguishes it's signature from + * {@link BoundedSelection#selectRange(int, int, int, int)}. + * + *

Caution: see {@link org.fxmisc.richtext.model.TextEditingArea#getAbsolutePosition(int, int)} to + * know how the column index argument can affect the returned position.

+ */ + void selectRange0(int startParagraphIndex, int startColPosition, int endParagraphIndex, int endColPosition); + + /** + * Selects the given range. Note: this method's "0" suffix distinguishes it's signature from + * {@link BoundedSelection#selectRange(int, int)}. + */ + void selectRange0(int startPosition, int endPosition); + + void moveStartBy(int amount, Direction direction); + + void moveEndBy(int amount, Direction direction); + + void moveStartTo(int position); + + void moveStartTo(int paragraphIndex, int columnPosition); + + void moveEndTo(int position); + + void moveEndTo(int paragraphIndex, int columnPosition); + + /** + * Disposes the selection and prevents memory leaks + */ + void dispose(); + +} diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/UnboundedSelectionImpl.java b/richtextfx/src/main/java/org/fxmisc/richtext/UnboundedSelectionImpl.java new file mode 100644 index 000000000..12293ac67 --- /dev/null +++ b/richtextfx/src/main/java/org/fxmisc/richtext/UnboundedSelectionImpl.java @@ -0,0 +1,321 @@ +package org.fxmisc.richtext; + +import javafx.beans.value.ObservableValue; +import javafx.geometry.Bounds; +import javafx.scene.control.IndexRange; +import org.fxmisc.richtext.model.StyledDocument; +import org.fxmisc.richtext.model.TwoDimensional.Position; +import org.reactfx.EventStream; +import org.reactfx.Subscription; +import org.reactfx.Suspendable; +import org.reactfx.SuspendableNo; +import org.reactfx.util.Tuple2; +import org.reactfx.util.Tuples; +import org.reactfx.value.SuspendableVal; +import org.reactfx.value.Val; +import org.reactfx.value.Var; + +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.IntSupplier; + +import static org.fxmisc.richtext.model.TwoDimensional.Bias.Backward; +import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward; +import static org.reactfx.EventStreams.invalidationsOf; +import static org.reactfx.EventStreams.merge; + +public final class UnboundedSelectionImpl implements UnboundedSelection { + + private final SuspendableVal range; + @Override public final IndexRange getRange() { return range.getValue(); } + @Override public final ObservableValue rangeProperty() { return range; } + + private final SuspendableVal length; + @Override public final int getLength() { return length.getValue(); } + @Override public final ObservableValue lengthProperty() { return length; } + + private final SuspendableVal paragraphSpan; + @Override public final int getParagraphSpan() { return paragraphSpan.getValue(); } + @Override public final ObservableValue paragraphSpanProperty() { return paragraphSpan; } + + private final SuspendableVal> selectedDocument; + @Override public final ObservableValue> selectedDocumentProperty() { return selectedDocument; } + @Override public final StyledDocument getSelectedDocument() { return selectedDocument.getValue(); } + + private final SuspendableVal selectedText; + @Override public final String getSelectedText() { return selectedText.getValue(); } + @Override public final ObservableValue selectedTextProperty() { return selectedText; } + + + private final SuspendableVal startPosition; + @Override public final int getStartPosition() { return startPosition.getValue(); } + @Override public final ObservableValue startPositionProperty() { return startPosition; } + + private final SuspendableVal startParagraphIndex; + @Override public final int getStartParagraphIndex() { return startParagraphIndex.getValue(); } + @Override public final ObservableValue startParagraphIndexProperty() { return startParagraphIndex; } + + private final SuspendableVal startColumnPosition; + @Override public final int getStartColumnPosition() { return startColumnPosition.getValue(); } + @Override public final ObservableValue startColumnPositionProperty() { return startColumnPosition; } + + + private final SuspendableVal endPosition; + @Override public final int getEndPosition() { return endPosition.getValue(); } + @Override public final ObservableValue endPositionProperty() { return endPosition; } + + private final SuspendableVal endPararagraphIndex; + @Override public final int getEndPararagraphIndex() { return endPararagraphIndex.getValue(); } + @Override public final ObservableValue endPararagraphIndexProperty() { return endPararagraphIndex; } + + private final SuspendableVal endColumnPosition; + @Override public final int getEndColumnPosition() { return endColumnPosition.getValue(); } + @Override public final ObservableValue endColumnPositionProperty() { return endColumnPosition; } + + + private final Val> bounds; + @Override public final Optional getBounds() { return bounds.getValue(); } + @Override public final ObservableValue> boundsProperty() { return bounds; } + + private final EventStream dirty; + @Override public final EventStream dirtyEvents() { return dirty; } + + private final SuspendableNo beingUpdated = new SuspendableNo(); + @Override public final boolean isBeingUpdated() { return beingUpdated.get(); } + @Override public final ObservableValue beingUpdatedProperty() { return beingUpdated; } + + private final GenericStyledArea area; + private final SuspendableNo dependentBeingUpdated; + private final Var internalRange; + + private Subscription subscription = () -> {}; + + public UnboundedSelectionImpl(GenericStyledArea area) { + this(area, 0, 0); + } + + public UnboundedSelectionImpl(GenericStyledArea area, int startPosition, int endPosition) { + this(area, area.beingUpdatedProperty(), new IndexRange(startPosition, endPosition)); + } + + public UnboundedSelectionImpl(GenericStyledArea area, SuspendableNo dependentBeingUpdated, int startPosition, int endPosition) { + this(area, dependentBeingUpdated, new IndexRange(startPosition, endPosition)); + } + + public UnboundedSelectionImpl(GenericStyledArea area, SuspendableNo dependentBeingUpdated, IndexRange range) { + this.area = area; + this.dependentBeingUpdated = dependentBeingUpdated; + internalRange = Var.newSimpleVar(range); + + this.range = internalRange.suspendable(); + length = internalRange.map(IndexRange::getLength).suspendable(); + + selectedText = Val.create(() -> area.getText(internalRange.getValue()), + internalRange, area.getParagraphs() + ).suspendable(); + + selectedDocument = Val.create(() -> area.subDocument(internalRange.getValue()), + internalRange, area.getParagraphs() + ).suspendable(); + + Val> positions = internalRange.map(sel -> { + Position start2D = area.offsetToPosition(sel.getStart(), Forward); + Position end2D = sel.getLength() == 0 + ? start2D + : start2D.offsetBy(sel.getLength(), Backward); + return Tuples.t(start2D, end2D); + }); + + startPosition = internalRange.map(IndexRange::getStart).suspendable(); + + Val start2D = positions.map(Tuple2::get1); + Val startPar = start2D.map(Position::getMajor); + startParagraphIndex = startPar.suspendable(); + startColumnPosition = start2D.map(Position::getMinor).suspendable(); + + endPosition = internalRange.map(IndexRange::getEnd).suspendable(); + + Val end2D = positions.map(Tuple2::get2); + Val endPar = end2D.map(Position::getMajor); + endPararagraphIndex = endPar.suspendable(); + endColumnPosition = end2D.map(Position::getMinor).suspendable(); + + paragraphSpan = Val.create( + () -> getEndPararagraphIndex() - getStartParagraphIndex() + 1, + startPar, endPar + ).suspendable(); + + dirty = merge( + invalidationsOf(rangeProperty()), + invalidationsOf(area.getParagraphs()) + ); + + bounds = Val.create( + () -> area.impl_bounds_getSelectionBoundsOnScreen(this), + area.boundsDirtyFor(dirty) + ); + + manageSubscription(area.plainTextChanges(), plainTextChange -> { + int changeLength = plainTextChange.getInserted().length() - plainTextChange.getRemoved().length(); + if (changeLength != 0) { + int indexOfChange = plainTextChange.getPosition(); + // in case of a replacement: "hello there" -> "hi." + int endOfChange = indexOfChange + Math.abs(changeLength); + + if (getLength() != 0) { + int selectionStart = getStartPosition(); + int selectionEnd = getEndPosition(); + + // if start/end is within the changed content, move it to indexOfChange + // otherwise, offset it by changeLength + // Note: if both are moved to indexOfChange, selection is empty. + if (indexOfChange < selectionStart) { + selectionStart = selectionStart < endOfChange + ? indexOfChange + : selectionStart + changeLength; + } + if (indexOfChange < selectionEnd) { + selectionEnd = selectionEnd < endOfChange + ? indexOfChange + : selectionEnd + changeLength; + } + selectRange0(selectionStart, selectionEnd); + } else { + // force-update internalSelection in case empty selection is + // at the end of area and a character was deleted + // (prevents a StringIndexOutOfBoundsException because + // end is one char farther than area's length). + + if (getLength() < getEndPosition()) { + selectRange0(getLength(), getLength()); + } + } + } + }); + + Suspendable omniSuspendable = Suspendable.combine( + // first, so it's released last + beingUpdated, + + paragraphSpan, + + endColumnPosition, + endPararagraphIndex, + endPosition, + + startColumnPosition, + startParagraphIndex, + startPosition, + + selectedText, + selectedDocument, + length, + this.range + ); + manageSubscription(omniSuspendable.suspendWhen(dependentBeingUpdated)); + } + + @Override + public void selectRange0(int startParagraphIndex, int startColPosition, int endParagraphIndex, int endColPosition) { + selectRange0(textPosition(startParagraphIndex, startColPosition), textPosition(endParagraphIndex, endColPosition)); + } + + @Override + public void selectRange0(int startPosition, int endPosition) { + selectRange(new IndexRange(startPosition, endPosition)); + } + + private void selectRange(IndexRange range) { + Runnable updateRange = () -> internalRange.setValue(range); + if (dependentBeingUpdated.get()) { + updateRange.run(); + } else { + dependentBeingUpdated.suspendWhile(updateRange); + } + } + + @Override + public void moveStartBy(int amount, Direction direction) { + moveBoundary(direction, amount, getStartPosition(), + newStartTextPos -> IndexRange.normalize(newStartTextPos, getEndPosition()) + ); + } + + @Override + public void moveEndBy(int amount, Direction direction) { + moveBoundary( + direction, amount, getEndPosition(), + newEndTextPos -> IndexRange.normalize(getStartPosition(), newEndTextPos) + ); + } + + @Override + public void moveStartTo(int position) { + selectRange0(position, getEndPosition()); + } + + @Override + public void moveStartTo(int paragraphIndex, int columnPosition) { + selectRange0(textPosition(paragraphIndex, columnPosition), getEndPosition()); + } + + @Override + public void moveEndTo(int position) { + selectRange0(getStartPosition(), position); + } + + @Override + public void moveEndTo(int paragraphIndex, int columnPosition) { + selectRange0(getStartPosition(), textPosition(paragraphIndex, columnPosition)); + } + + @Override + public void dispose() { + subscription.unsubscribe(); + } + + private void manageSubscription(EventStream stream, Consumer consumer) { + manageSubscription(stream.subscribe(consumer)); + } + + private void manageSubscription(Subscription s) { + subscription = subscription.and(s); + } + + private Position position(int row, int col) { + return area.position(row, col); + } + + private int textPosition(int row, int col) { + return position(row, col).toOffset(); + } + + private void moveBoundary(Direction direction, int amount, int oldBoundaryPosition, + Function updatedRange) { + switch (direction) { + case LEFT: + moveBoundary( + () -> oldBoundaryPosition - amount, + (pos) -> 0 <= pos, + updatedRange + ); + break; + default: case RIGHT: + moveBoundary( + () -> oldBoundaryPosition + amount, + (pos) -> pos <= area.getLength(), + updatedRange + ); + } + } + + private void moveBoundary(IntSupplier textPosition, Function boundsCheckPasses, + Function updatedRange) { + int newTextPosition = textPosition.getAsInt(); + if (boundsCheckPasses.apply(newTextPosition)) { + selectRange(updatedRange.apply(newTextPosition)); + } + } + +} diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/ViewActions.java b/richtextfx/src/main/java/org/fxmisc/richtext/ViewActions.java index 718ee268f..d8c3f1748 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/ViewActions.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/ViewActions.java @@ -2,7 +2,6 @@ import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; -import javafx.beans.value.ObservableValue; import javafx.geometry.Bounds; import javafx.geometry.Point2D; import javafx.scene.Node; @@ -40,22 +39,6 @@ public interface ViewActions { void setWrapText(boolean value); BooleanProperty wrapTextProperty(); - public static enum CaretVisibility { - /** Caret is displayed. */ - ON, - /** Caret is displayed when area is focused, enabled, and editable. */ - AUTO, - /** Caret is not displayed. */ - OFF - } - - /** - * Indicates when this text area should display a caret. - */ - CaretVisibility getShowCaret(); - void setShowCaret(CaretVisibility value); - Var showCaretProperty(); - /** * Defines how long the mouse has to stay still over the text before a * {@link MouseOverTextEvent} of type {@code MOUSE_OVER_TEXT_BEGIN} is @@ -206,20 +189,6 @@ public static enum CaretVisibility { double getContextMenuYOffset(); void setContextMenuYOffset(double offset); - /** - * Gets the bounds of the caret in the Screen's coordinate system or {@link Optional#empty()} - * if caret is not visible in the viewport. - */ - Optional getCaretBounds(); - ObservableValue> caretBoundsProperty(); - - /** - * Gets the bounds of the selection in the Screen's coordinate system if something is selected and visible in the - * viewport or {@link Optional#empty()} if selection is not visible in the viewport. - */ - Optional getSelectionBounds(); - ObservableValue> selectionBoundsProperty(); - /** * Gets the estimated scrollX value. This can be set in order to scroll the content. * Value is only accurate when area does not wrap lines and uses the same font size diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/TextEditingArea.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/TextEditingArea.java index 584107460..da2363a02 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/TextEditingArea.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/TextEditingArea.java @@ -2,9 +2,15 @@ import javafx.beans.value.ObservableValue; import javafx.collections.ObservableList; +import javafx.geometry.Bounds; import javafx.scene.control.IndexRange; +import org.fxmisc.richtext.BoundedSelection; +import org.fxmisc.richtext.Caret; import org.reactfx.EventStream; +import org.reactfx.value.Var; + +import java.util.Optional; /** * Interface for a text editing control. @@ -41,6 +47,11 @@ public interface TextEditingArea { */ StyledDocument getDocument(); + /** + * Gets the area's main caret + */ + Caret getMainCaret(); + /** * The current position of the caret, as a character offset in the text. * @@ -50,15 +61,46 @@ public interface TextEditingArea { * user is dragging the selected text, the caret moves with the cursor * to point at the position where the selected text moves upon release. */ - int getCaretPosition(); - ObservableValue caretPositionProperty(); + default int getCaretPosition() { return getMainCaret().getPosition(); } + default ObservableValue caretPositionProperty() { return getMainCaret().positionProperty(); } + + /** + * Index of the current paragraph, i.e. the paragraph with the caret. + */ + default int getCurrentParagraph() { return getMainCaret().getParagraphIndex(); } + default ObservableValue currentParagraphProperty() { return getMainCaret().paragraphIndexProperty(); } + + /** + * The caret position within the current paragraph. + */ + default int getCaretColumn() { return getMainCaret().getColumnPosition(); } + default ObservableValue caretColumnProperty() { return getMainCaret().columnPositionProperty(); } + + /** + * Gets the bounds of the caret in the Screen's coordinate system or {@link Optional#empty()} + * if caret is not visible in the viewport. + */ + default Optional getCaretBounds() { return getMainCaret().getBounds(); } + default ObservableValue> caretBoundsProperty() { return getMainCaret().boundsProperty(); } + + /** + * Indicates when this text area should display a caret. + */ + default Caret.CaretVisibility getShowCaret() { return getMainCaret().getShowCaret(); } + default void setShowCaret(Caret.CaretVisibility value) { getMainCaret().setShowCaret(value); } + default Var showCaretProperty() { return getMainCaret().showCaretProperty(); } + + /** + * Gets the area's main selection + */ + BoundedSelection getMainSelection(); /** * The anchor of the selection. * If there is no selection, this is the same as caret position. */ - int getAnchor(); - ObservableValue anchorProperty(); + default int getAnchor() { return getMainSelection().getAnchorPosition(); } + default ObservableValue anchorProperty() { return getMainSelection().anchorPositionProperty(); } /** * The selection range. @@ -66,26 +108,21 @@ public interface TextEditingArea { * One boundary is always equal to anchor, and the other one is most * of the time equal to caret position. */ - IndexRange getSelection(); - ObservableValue selectionProperty(); + default IndexRange getSelection() { return getMainSelection().getRange(); } + default ObservableValue selectionProperty() { return getMainSelection().rangeProperty(); } /** * The selected text. */ - String getSelectedText(); - ObservableValue selectedTextProperty(); + default String getSelectedText() { return getMainSelection().getSelectedText(); } + default ObservableValue selectedTextProperty() { return getMainSelection().selectedTextProperty(); } /** - * Index of the current paragraph, i.e. the paragraph with the caret. + * Gets the bounds of the selection in the Screen's coordinate system if something is selected and visible in the + * viewport or {@link Optional#empty()} if selection is not visible in the viewport. */ - int getCurrentParagraph(); - ObservableValue currentParagraphProperty(); - - /** - * The caret position within the current paragraph. - */ - int getCaretColumn(); - ObservableValue caretColumnProperty(); + default Optional getSelectionBounds() { return getMainSelection().getBounds(); } + default ObservableValue> selectionBoundsProperty() { return getMainSelection().boundsProperty(); } /** * Unmodifiable observable list of paragraphs in this text area. @@ -126,6 +163,11 @@ public interface TextEditingArea { */ String getText(int start, int end); + /** + * Returns text content of the given character range. + */ + String getText(IndexRange range); + /** * Returns text content of the given character range. * @@ -143,6 +185,13 @@ default String getText(int startParagraph, int startColumn, int endParagraph, in */ StyledDocument subDocument(int paragraphIndex); + /** + * Returns rich-text content of the given character range. + */ + default StyledDocument subDocument(IndexRange range) { + return subDocument(range.getStart(), range.getEnd()); + }; + /** * Returns rich-text content of the given character range. */ @@ -170,7 +219,9 @@ default StyledDocument subDocument(int startParagraph, int startColu * Positions the anchor and caretPosition explicitly, * effectively creating a selection. */ - void selectRange(int anchor, int caretPosition); + default void selectRange(int anchor, int caretPosition) { + getMainSelection().selectRange(anchor, caretPosition); + } /** * Positions the anchor and caretPosition explicitly,