diff --git a/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/lineindicator/LineIndicatorDemo.java b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/lineindicator/LineIndicatorDemo.java index 3587434f5..1cd72ca8d 100644 --- a/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/lineindicator/LineIndicatorDemo.java +++ b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/lineindicator/LineIndicatorDemo.java @@ -34,7 +34,7 @@ public void start(Stage primaryStage) { CodeArea codeArea = new CodeArea(); IntFunction numberFactory = LineNumberFactory.get(codeArea); - IntFunction arrowFactory = new ArrowFactory(codeArea.currentParagraphProperty()); + IntFunction arrowFactory = new ArrowFactory(codeArea.caretParagraphProperty()); IntFunction graphicFactory = line -> { HBox hbox = new HBox( numberFactory.apply(line), diff --git a/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/richtext/RichText.java b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/richtext/RichText.java index 7341ec050..3dd6dbd66 100644 --- a/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/richtext/RichText.java +++ b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/richtext/RichText.java @@ -169,7 +169,7 @@ protected boolean computeValue() { Color[] backgrounds = styles.styleStream().map(s -> s.backgroundColor.orElse(null)).distinct().toArray(i -> new Color[i]); backgroundColor = backgrounds.length == 1 ? backgrounds[0] : null; } else { - int p = area.getCurrentParagraph(); + int p = area.getCaretParagraph(); int col = area.getCaretColumn(); TextStyle style = area.getStyleAtPosition(p, col); bold = style.bold.orElse(false); 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..9a8aee397 --- /dev/null +++ b/richtextfx/src/main/java/org/fxmisc/richtext/Caret.java @@ -0,0 +1,33 @@ +package org.fxmisc.richtext; + +import javafx.beans.value.ObservableValue; +import javafx.scene.control.IndexRange; + + +public interface Caret { + + int getCaretPosition(); + + ObservableValue caretPositionProperty(); + + int getAnchor(); + + ObservableValue anchorProperty(); + + IndexRange getSelection(); + + ObservableValue selectionProperty(); + + String getSelectedText(); + + ObservableValue selectedTextProperty(); + + int getCaretParagraph(); + + ObservableValue caretParagraphProperty(); + + int getCaretColumn(); + + ObservableValue caretColumnProperty(); + +} diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/CaretSelectionView.java b/richtextfx/src/main/java/org/fxmisc/richtext/CaretSelectionView.java new file mode 100644 index 000000000..1ff610081 --- /dev/null +++ b/richtextfx/src/main/java/org/fxmisc/richtext/CaretSelectionView.java @@ -0,0 +1,138 @@ +package org.fxmisc.richtext; + +import javafx.beans.binding.Binding; +import javafx.beans.value.ObservableValue; +import javafx.geometry.Bounds; +import javafx.scene.control.IndexRange; +import org.fxmisc.richtext.GenericStyledArea.CaretVisibility; +import org.fxmisc.richtext.model.CaretSelectionModel; +import org.reactfx.EventStream; +import org.reactfx.EventStreams; +import org.reactfx.StateMachine; +import org.reactfx.Subscription; +import org.reactfx.value.Val; +import org.reactfx.value.Var; + +import java.time.Duration; +import java.util.Optional; + +import static javafx.util.Duration.ZERO; + +public class CaretSelectionView implements Caret, Selection { + + private final CaretSelectionModel model; + + public final int getCaretPosition() { return model.getCaretPosition(); } + public final ObservableValue caretPositionProperty() { return model.caretPositionProperty(); } + + public final int getAnchor() { return model.getAnchor(); } + public final ObservableValue anchorProperty() { return model.anchorProperty(); } + + public final IndexRange getSelection() { return model.getSelection(); } + public final ObservableValue selectionProperty() { return model.selectionProperty(); } + + public final String getSelectedText() { return model.getSelectedText(); } + public final ObservableValue selectedTextProperty() { return model.selectedTextProperty(); } + + public final int getCaretParagraph() { return model.getCaretParagraph(); } + public final ObservableValue caretParagraphProperty() { return model.caretParagraphProperty(); } + + public final int getCaretColumn() { return model.getCaretColumn(); } + public final ObservableValue caretColumnProperty() { return model.caretColumnProperty(); } + +// private final Val caretLine; +// public final int getCaretLine() { return caretLine.getValue(); } +// public final ObservableValue caretLineProperty() { return caretLine; } + + private final Var showCaret = Var.newSimpleVar(CaretVisibility.AUTO); + public final CaretVisibility getShowCaret() { return showCaret.getValue(); } + public final void setShowCaret(CaretVisibility value) { showCaret.setValue(value); } + public final Var showCaretProperty() { return showCaret; } + + private final Binding caretVisible; + final boolean isCaretVisible() { return caretVisible.getValue(); } + final ObservableValue caretVisibleProperty() { return caretVisible; } + + /** + * The bounds of the caret in the Screen's coordinate system or {@link Optional#empty()} if caret is not visible + * in the viewport. + */ + private final Val> caretBounds; + public final Optional getCaretBounds() { return caretBounds.getValue(); } + public final ObservableValue> caretBoundsProperty() { return caretBounds; } + + private Subscription subscriptions; + + /** + * The bounds of the selection in the Screen's coordinate system if something is selected and visible in the + * viewport, {@link #caretBounds} if nothing is selected and caret is visible in the viewport, or + * {@link Optional#empty()} if selection is not visible in the viewport. + */ + private final Val> selectionBounds; + public final Optional getSelectionBounds() { return selectionBounds.getValue(); } + public final ObservableValue> selectionBoundsProperty() { return selectionBounds; } + + CaretSelectionView(CaretSelectionModel model, GenericStyledArea area) { + this.model = model; + + // 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 EventStreams.valuesOf(area.focusedProperty() + .and(area.editableProperty()) + .and(area.disabledProperty().not())); + } + }); + + // The caret is visible in periodic intervals, + // but only when blinkCaret is true. + caretVisible = 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, model.caretDirtyEvents()); + } else { + return Val.constant(false).values(); + } + }) + .toBinding(false); + manageBinding(caretVisible); + + caretBounds = Val.create( + () -> area.getCaretBoundsOnScreen(getCaretParagraph()), + area.boundsDirtyFor(model.caretDirtyEvents()) + ); + selectionBounds = Val.create( + area::impl_bounds_getSelectionBoundsOnScreen, + area.boundsDirtyFor(model.selectionDirtyEvents()) + ); + } + + public void dispose() { + subscriptions.unsubscribe(); + } + + private void manageBinding(Binding binding) { + subscriptions = subscriptions.and(binding::dispose); + } + + 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 d122c337f..b0a1a5fe5 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java @@ -2,6 +2,7 @@ import static javafx.util.Duration.*; import static org.fxmisc.richtext.PopupAlignment.*; +import static org.fxmisc.richtext.util.Utilities.EMPTY_RANGE; import static org.reactfx.EventStreams.*; import static org.reactfx.util.Tuples.*; @@ -159,11 +160,6 @@ public class GenericStyledArea extends Region TwoDimensional, Virtualized { - /** - * Index range [0, 0). - */ - public static final IndexRange EMPTY_RANGE = new IndexRange(0, 0); - private static final PseudoClass HAS_CARET = PseudoClass.getPseudoClass("has-caret"); private static final PseudoClass FIRST_PAR = PseudoClass.getPseudoClass("first-paragraph"); private static final PseudoClass LAST_PAR = PseudoClass.getPseudoClass("last-paragraph"); @@ -199,6 +195,8 @@ public class GenericStyledArea extends Region */ private final StyleableObjectProperty caretBlinkRate = new CssProperties.CaretBlinkRateProperty(this, javafx.util.Duration.millis(500)); + final EventStream caretBlinkRateEvents() { return EventStreams.valuesOf(caretBlinkRate); } + // editable property /** @@ -344,13 +342,8 @@ public Optional, Codec>> getStyleCodecs() { @Override public final ObservableValue caretPositionProperty() { return model.caretPositionProperty(); } // caret bounds - /** - * The bounds of the caret in the Screen's coordinate system or {@link Optional#empty()} if caret is not visible - * in the viewport. - */ - private final Val> caretBounds; - public final Optional getCaretBounds() { return caretBounds.getValue(); } - public final ObservableValue> caretBoundsProperty() { return caretBounds; } + public final Optional getCaretBounds() { return mainCaret.getCaretBounds(); } + public final ObservableValue> caretBoundsProperty() { return mainCaret.caretBoundsProperty(); } // selection anchor @Override public final int getAnchor() { return model.getAnchor(); } @@ -365,18 +358,12 @@ public Optional, Codec>> getStyleCodecs() { @Override public final ObservableValue selectedTextProperty() { return model.selectedTextProperty(); } // selection bounds - /** - * The bounds of the selection in the Screen's coordinate system if something is selected and visible in the - * viewport, {@link #caretBounds} if nothing is selected and caret is visible in the viewport, or - * {@link Optional#empty()} if selection is not visible in the viewport. - */ - private final Val> selectionBounds; - public final Optional getSelectionBounds() { return selectionBounds.getValue(); } - public final ObservableValue> selectionBoundsProperty() { return selectionBounds; } + public final Optional getSelectionBounds() { return mainCaret.getSelectionBounds(); } + public final ObservableValue> selectionBoundsProperty() { return mainCaret.selectionBoundsProperty(); } // current paragraph index - @Override public final int getCurrentParagraph() { return model.getCurrentParagraph(); } - @Override public final ObservableValue currentParagraphProperty() { return model.currentParagraphProperty(); } + @Override public final int getCaretParagraph() { return model.getCaretParagraph(); } + @Override public final ObservableValue caretParagraphProperty() { return model.caretParagraphProperty(); } // caret column @Override public final int getCaretColumn() { return model.getCaretColumn(); } @@ -421,6 +408,10 @@ public Optional, Codec>> getStyleCodecs() { // rich text changes @Override public final EventStream> richChanges() { return model.richChanges(); } + private final CaretSelectionView mainCaret; + public final Caret getMainCaret() { return mainCaret; } + public final Selection getMainSelection() { return mainCaret; } + /* ********************************************************************** * * * * Private fields * @@ -432,8 +423,6 @@ public Optional, Codec>> getStyleCodecs() { // 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; @@ -445,6 +434,7 @@ public Optional, Codec>> getStyleCodecs() { private boolean followCaretRequested = false; private final SuspendableEventStream viewportDirty; + final EventStream viewportDirtyEvents() { return viewportDirty; } /** * model @@ -599,49 +589,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 @@ -653,14 +600,10 @@ public GenericStyledArea( invalidationsOf(estimatedScrollXProperty()), invalidationsOf(estimatedScrollYProperty()) ).suppressible(); - EventStream caretBoundsDirty = merge(viewportDirty, caretDirty) - .suppressWhen(model.beingUpdatedProperty()); - EventStream selectionBoundsDirty = merge(viewportDirty, invalidationsOf(selectionProperty())) - .suppressWhen(model.beingUpdatedProperty()); - // updates the bounds of the caret/selection - caretBounds = Val.create(this::getCaretBoundsOnScreen, caretBoundsDirty); - selectionBounds = Val.create(this::impl_bounds_getSelectionBoundsOnScreen, selectionBoundsDirty); + // initialize viewportDirty before mainCaret as constructor uses it in "boundsDirtyFor(EventStream)" + mainCaret = new CaretSelectionView(model.getMainCaret(), this); + manageSubscription(mainCaret::dispose); // Adjust popup anchor by either a user-provided function, // or user-provided offset, or don't adjust at all. @@ -714,7 +657,7 @@ public GenericStyledArea( * of the embedded VirtualFlow. */ Optional getCaretBoundsInViewport() { - return virtualFlow.getCellIfVisible(getCurrentParagraph()) + return virtualFlow.getCellIfVisible(getCaretParagraph()) .map(c -> { Bounds cellBounds = c.getNode().getCaretBounds(); return virtualFlow.cellToViewport(c, cellBounds); @@ -725,7 +668,7 @@ Optional getCaretBoundsInViewport() { * Returns x coordinate of the caret in the current paragraph. */ ParagraphBox.CaretOffsetX getCaretOffsetX() { - int idx = getCurrentParagraph(); + int idx = getCaretParagraph(); return getCell(idx).getCaretOffsetX(); } @@ -795,7 +738,7 @@ public CharacterHit hit(double x, double y) { * paragraph to the viewport if it is not already visible. */ TwoDimensional.Position currentLine() { - int parIdx = getCurrentParagraph(); + int parIdx = getCaretParagraph(); Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx); int lineIdx = cell.getNode().getCurrentLineIndex(); return _position(parIdx, lineIdx); @@ -1012,7 +955,7 @@ public void showParagraphAtBottom(int paragraphIndex) { } void showCaretAtBottom() { - int parIdx = getCurrentParagraph(); + int parIdx = getCaretParagraph(); Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx); Bounds caretBounds = cell.getNode().getCaretBounds(); double y = caretBounds.getMaxY(); @@ -1020,7 +963,7 @@ void showCaretAtBottom() { } void showCaretAtTop() { - int parIdx = getCurrentParagraph(); + int parIdx = getCaretParagraph(); Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx); Bounds caretBounds = cell.getNode().getCaretBounds(); double y = caretBounds.getMinY(); @@ -1038,7 +981,7 @@ public void requestFollowCaret() { } private void followCaret() { - int parIdx = getCurrentParagraph(); + int parIdx = getCaretParagraph(); Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx); Bounds caretBounds = cell.getNode().getCaretBounds(); double graphicWidth = cell.getNode().getGraphicPrefWidth(); @@ -1053,8 +996,8 @@ private void followCaret() { * @param policy */ public void lineStart(SelectionPolicy policy) { - int columnPos = virtualFlow.getCell(getCurrentParagraph()).getNode().getCurrentLineStartPosition(); - moveTo(getCurrentParagraph(), columnPos, policy); + int columnPos = virtualFlow.getCell(getCaretParagraph()).getNode().getCurrentLineStartPosition(); + moveTo(getCaretParagraph(), columnPos, policy); } /** @@ -1064,8 +1007,8 @@ public void lineStart(SelectionPolicy policy) { * @param policy */ public void lineEnd(SelectionPolicy policy) { - int columnPos = virtualFlow.getCell(getCurrentParagraph()).getNode().getCurrentLineEndPosition(); - moveTo(getCurrentParagraph(), columnPos, policy); + int columnPos = virtualFlow.getCell(getCaretParagraph()).getNode().getCurrentLineEndPosition(); + moveTo(getCaretParagraph(), columnPos, policy); } /** @@ -1253,7 +1196,7 @@ private Cell, ParagraphBox> createCell( Val hasCaret = Val.combine( box.indexProperty(), - currentParagraphProperty(), + caretParagraphProperty(), (bi, cp) -> bi.intValue() == cp.intValue()); Subscription hasCaretPseudoClass = hasCaret.values().subscribe(value -> box.pseudoClassStateChanged(HAS_CARET, value)); @@ -1264,7 +1207,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.caretVisibleProperty() : Val.constant(false)); box.caretVisibleProperty().bind(cellCaretVisible); // bind cell's caret position to area's caret column, @@ -1278,7 +1221,7 @@ private Cell, ParagraphBox> createCell( int idx = box.getIndex(); return idx != -1 ? getParagraphSelection(idx) - : StyledTextArea.EMPTY_RANGE; + : EMPTY_RANGE; }, selectionProperty(), box.indexProperty()); box.selectionProperty().bind(cellSelection); @@ -1368,8 +1311,13 @@ private void positionPopup( }); } + Optional getCaretBoundsOnScreen(int paragraphIndex) { + return virtualFlow.getCellIfVisible(paragraphIndex) + .map(c -> c.getNode().getCaretBoundsOnScreen()); + } + private Optional getCaretBoundsOnScreen() { - return virtualFlow.getCellIfVisible(getCurrentParagraph()) + return virtualFlow.getCellIfVisible(getCaretParagraph()) .map(c -> c.getNode().getCaretBoundsOnScreen()); } @@ -1382,7 +1330,7 @@ private Optional impl_popup_getSelectionBoundsOnScreen() { return impl_getSelectionBoundsOnScreen(); } - private Optional impl_bounds_getSelectionBoundsOnScreen() { + Optional impl_bounds_getSelectionBoundsOnScreen() { IndexRange selection = getSelection(); if (selection.getLength() == 0) { return Optional.empty(); @@ -1448,6 +1396,10 @@ private static EventStream booleanPulse(javafx.util.Duration javafxDura .toStateStream(); } + EventStream boundsDirtyFor(EventStream caretOrSelectionDirtyEvents) { + return merge(viewportDirty, caretOrSelectionDirtyEvents).suppressWhen(model.beingUpdatedProperty()); + } + /* ********************************************************************** * * * * Deprecated Popup API (Originally a part of "Properties" section * diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphText.java b/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphText.java index 6ecf7ab8f..ed14ae4e9 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphText.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphText.java @@ -22,6 +22,8 @@ import org.reactfx.value.Val; import org.reactfx.value.Var; +import static org.fxmisc.richtext.util.Utilities.EMPTY_RANGE; + class ParagraphText extends TextFlowExt { // FIXME: changing it currently has not effect, because @@ -37,7 +39,7 @@ public ObjectProperty highlightTextFillProperty() { public void setCaretPosition(int pos) { caretPosition.setValue(pos); } private final Val clampedCaretPosition; - private final ObjectProperty selection = new SimpleObjectProperty<>(StyledTextArea.EMPTY_RANGE); + private final ObjectProperty selection = new SimpleObjectProperty<>(EMPTY_RANGE); public ObjectProperty selectionProperty() { return selection; } public void setSelection(IndexRange sel) { selection.set(sel); } diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/Selection.java b/richtextfx/src/main/java/org/fxmisc/richtext/Selection.java new file mode 100644 index 000000000..031ad0631 --- /dev/null +++ b/richtextfx/src/main/java/org/fxmisc/richtext/Selection.java @@ -0,0 +1,23 @@ +package org.fxmisc.richtext; + +import javafx.beans.value.ObservableValue; +import javafx.geometry.Bounds; +import javafx.scene.control.IndexRange; + +import java.util.Optional; + +public interface Selection { + + IndexRange getSelection(); + + ObservableValue selectionProperty(); + + String getSelectedText(); + + ObservableValue selectedTextProperty(); + + Optional getSelectionBounds(); + + ObservableValue> selectionBoundsProperty(); + +} diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/CaretSelectionModel.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/CaretSelectionModel.java new file mode 100644 index 000000000..4191e743b --- /dev/null +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/CaretSelectionModel.java @@ -0,0 +1,232 @@ +package org.fxmisc.richtext.model; + +import javafx.beans.value.ObservableValue; +import javafx.scene.control.IndexRange; +import org.fxmisc.richtext.model.TwoDimensional.Bias; +import org.fxmisc.richtext.model.TwoDimensional.Position; +import org.reactfx.EventStream; +import org.reactfx.Guard; +import org.reactfx.Subscription; +import org.reactfx.Suspendable; +import org.reactfx.value.SuspendableVal; +import org.reactfx.value.SuspendableVar; +import org.reactfx.value.Val; +import org.reactfx.value.Var; + +import static org.fxmisc.richtext.util.Utilities.EMPTY_RANGE; +import static org.fxmisc.richtext.util.Utilities.clamp; +import static org.fxmisc.richtext.model.TwoDimensional.Bias.Backward; +import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward; +import static org.reactfx.EventStreams.merge; +import static org.reactfx.EventStreams.invalidationsOf; + +/** + * Encapsulates all caret and selection related information for {@link StyledTextAreaModel} and utilizes + * {@link Suspendable suspendable properties} to insure that changes to these values don't propagate until + * changes have been finished to the underlying {@link EditableStyledDocument}. + * + *

Terms

+ *
    + *
  • Position: the position in-between letters of a text (e.g. if "|" represents the caret and our text is + * "text", then all possible positions are "|t|e|x|t|"
  • + *
+ * + */ +public final class CaretSelectionModel { + + // caret position + private final Var internalCaretPosition = Var.newSimpleVar(0); + /** + * The position of the caret within the text + */ + private final SuspendableVal caretPosition = internalCaretPosition.suspendable(); + public final int getCaretPosition() { return caretPosition.getValue(); } + public final ObservableValue caretPositionProperty() { return caretPosition; } + + // selection anchor + private final SuspendableVar anchor = Var.newSimpleVar(0).suspendable(); + public final int getAnchor() { return anchor.getValue(); } + public final ObservableValue anchorProperty() { return anchor; } + + // selection + private final Var internalSelection = Var.newSimpleVar(EMPTY_RANGE); + private final SuspendableVal selection = internalSelection.suspendable(); + public final IndexRange getSelection() { return selection.getValue(); } + public final ObservableValue selectionProperty() { return selection; } + + // selected text + private final SuspendableVal selectedText; + public final String getSelectedText() { return selectedText.getValue(); } + public final ObservableValue selectedTextProperty() { return selectedText; } + + // caret paragraph index + private final SuspendableVal caretParagraph; + public final int getCaretParagraph() { return caretParagraph.getValue(); } + public final ObservableValue caretParagraphProperty() { return caretParagraph; } + + // caret column position + private final SuspendableVal caretColumn; + public final int getCaretColumn() { return caretColumn.getValue(); } + public final ObservableValue caretColumnProperty() { return caretColumn; } + + private final EventStream caretDirty; + public final EventStream caretDirtyEvents() { return caretDirty; } + + private final EventStream selectionDirty; + public final EventStream selectionDirtyEvents() { return selectionDirty; } + + private Position selectionStart2D; + private Position selectionEnd2D; + + private final StyledTextAreaModel model; + private final Suspendable modelBeingUpdated; + private final Subscription subscription; + + CaretSelectionModel(StyledTextAreaModel model, Suspendable modelBeingUpdated) { + this.model = model; + this.modelBeingUpdated = modelBeingUpdated; + Val caretPosition2D = Val.create( + () -> offsetToPosition(internalCaretPosition.getValue(), Forward), + internalCaretPosition, model.getParagraphs() + ); + caretParagraph = 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( + () -> model.getText(internalSelection.getValue()), + internalSelection, model.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 + subscription = model.plainTextChanges().subscribe(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 + positionCaret( + 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); + } + } + }); + + caretDirty = merge( + // follow the caret every time the caret position or paragraphs change + invalidationsOf(caretPositionProperty()), + invalidationsOf(model.getParagraphs()), + // need to reposition popup even when caret hasn't moved, but selection has changed (been deselected) + invalidationsOf(selectionProperty()) + ); + selectionDirty = invalidationsOf(selectionProperty()); + } + + Suspendable omniSuspendable() { + return Suspendable.combine(caretPosition, anchor, selection, selectedText, caretParagraph, caretColumn); + } + + Position position(int row, int col) { + return model.position(row, col); + } + + Position offsetToPosition(int charOffset, Bias bias) { + return model.offsetToPosition(charOffset, bias); + } + + /** + * Returns the selection range in the given paragraph. + */ + public IndexRange getParagraphSelection(int paragraph) { + int startPar = selectionStart2D.getMajor(); + int endPar = selectionEnd2D.getMajor(); + + if(paragraph < startPar || paragraph > endPar) { + return EMPTY_RANGE; + } + + int start = paragraph == startPar ? selectionStart2D.getMinor() : 0; + int end = paragraph == endPar ? selectionEnd2D.getMinor() : model.getParagraph(paragraph).length(); + + // force selectionProperty() to be valid + getSelection(); + + return new IndexRange(start, end); + } + + public void selectRange(int anchor, int caretPosition) { + try(Guard g = suspend( + this.caretPosition, caretParagraph, + caretColumn, this.anchor, + selection, selectedText)) { + this.internalCaretPosition.setValue(clamp(0, caretPosition, model.getLength())); + this.anchor.setValue(clamp(0, anchor, model.getLength())); + this.internalSelection.setValue(IndexRange.normalize(getAnchor(), getCaretPosition())); + } + } + + /** + * Positions only the caret. Doesn't move the anchor and doesn't change + * the selection. 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 positionCaret(int pos) { + try(Guard g = suspend(caretPosition, caretParagraph, caretColumn)) { + internalCaretPosition.setValue(pos); + } + } + + protected void dispose() { + subscription.unsubscribe(); + } + + private Guard suspend(Suspendable... suspendables) { + return Suspendable.combine(modelBeingUpdated, Suspendable.combine(suspendables)).suspend(); + } + +} diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/NavigationActions.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/NavigationActions.java index bc75204d5..cd441a9a2 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/NavigationActions.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/NavigationActions.java @@ -176,7 +176,7 @@ default void paragraphStart(SelectionPolicy selectionPolicy) { * Moves the caret to the end of the current paragraph. */ default void paragraphEnd(SelectionPolicy selectionPolicy) { - int lineLen = getText(getCurrentParagraph()).length(); + int lineLen = getText(getCaretParagraph()).length(); int newPos = getCaretPosition() - getCaretColumn() + lineLen; moveTo(newPos, selectionPolicy); } diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledTextAreaModel.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledTextAreaModel.java index 9680195f3..4108a8305 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledTextAreaModel.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledTextAreaModel.java @@ -1,6 +1,6 @@ package org.fxmisc.richtext.model; -import static org.fxmisc.richtext.model.TwoDimensional.Bias.*; +import static org.fxmisc.richtext.util.Utilities.clamp; import java.util.Optional; import java.util.function.BiFunction; @@ -23,9 +23,7 @@ import org.reactfx.collection.LiveList; import org.reactfx.collection.SuspendableList; import org.reactfx.value.SuspendableVal; -import org.reactfx.value.SuspendableVar; import org.reactfx.value.Val; -import org.reactfx.value.Var; /** * Model for {@link org.fxmisc.richtext.GenericStyledArea} @@ -40,21 +38,6 @@ public class StyledTextAreaModel UndoActions, TwoDimensional { - /** - * Index range [0, 0). - */ - public static final IndexRange EMPTY_RANGE = new IndexRange(0, 0); - - /** - * Private helper method. - */ - private static int clamp(int min, int val, int max) { - return val < min ? min - : val > max ? max - : val; - } - - /* ********************************************************************** * * * * Properties * @@ -110,37 +93,32 @@ private static int clamp(int min, int val, int max) { @Override public final int getLength() { return length.getValue(); } @Override public final ObservableValue lengthProperty() { return length; } + private final CaretSelectionModel mainCaret; + public final CaretSelectionModel getMainCaret() { return mainCaret; } + // 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; } + @Override public final int getCaretPosition() { return mainCaret.getCaretPosition(); } + @Override public final ObservableValue caretPositionProperty() { return mainCaret.caretPositionProperty(); } // 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; } + @Override public final int getAnchor() { return mainCaret.getAnchor(); } + @Override public final ObservableValue anchorProperty() { return mainCaret.anchorProperty(); } // 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; } + @Override public final IndexRange getSelection() { return mainCaret.getSelection(); } + @Override public final ObservableValue selectionProperty() { return mainCaret.selectionProperty(); } // selected text - private final SuspendableVal selectedText; - @Override public final String getSelectedText() { return selectedText.getValue(); } - @Override public final ObservableValue selectedTextProperty() { return selectedText; } + @Override public final String getSelectedText() { return mainCaret.getSelectedText(); } + @Override public final ObservableValue selectedTextProperty() { return mainCaret.selectedTextProperty(); } - // current paragraph index - private final SuspendableVal currentParagraph; - @Override public final int getCurrentParagraph() { return currentParagraph.getValue(); } - @Override public final ObservableValue currentParagraphProperty() { return currentParagraph; } + // caret paragraph + @Override public final int getCaretParagraph() { return mainCaret.getCaretParagraph(); } + @Override public final ObservableValue caretParagraphProperty() { return mainCaret.caretParagraphProperty(); } // caret column - private final SuspendableVal caretColumn; - @Override public final int getCaretColumn() { return caretColumn.getValue(); } - @Override public final ObservableValue caretColumnProperty() { return caretColumn; } + @Override public final int getCaretColumn() { return mainCaret.getCaretColumn(); } + @Override public final ObservableValue caretColumnProperty() { return mainCaret.caretColumnProperty(); } // paragraphs private final SuspendableList> paragraphs; @@ -177,9 +155,6 @@ private static int clamp(int min, int val, int max) { private Subscription subscriptions = () -> {}; - private Position selectionStart2D; - private Position selectionEnd2D; - /** * content model */ @@ -269,91 +244,18 @@ public StyledTextAreaModel( plainTextChanges = content.plainChanges().pausable(); richTextChanges = content.richChanges().pausable(); - // when content is updated by an area, update the caret - // and selection ranges of all the other - // clones that also share this document - subscribeTo(content.plainChanges(), 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 - positionCaret( - 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); - } - } - }); + mainCaret = new CaretSelectionModel(this, beingUpdated); + manageSubscription(mainCaret::dispose); undoManager = preserveStyle ? createRichUndoManager(UndoManagerFactory.unlimitedHistoryFactory()) : createPlainUndoManager(UndoManagerFactory.unlimitedHistoryFactory()); - Val caretPosition2D = Val.create( - () -> content.offsetToPosition(internalCaretPosition.getValue(), Forward), - internalCaretPosition, paragraphs); - - 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(); - final Suspendable omniSuspendable = Suspendable.combine( beingUpdated, // must be first, to be the last one to release text, length, - caretPosition, - anchor, - selection, - selectedText, - currentParagraph, - caretColumn, + mainCaret.omniSuspendable(), // add streams after properties, to be released before them plainTextChanges, @@ -373,6 +275,10 @@ public StyledTextAreaModel( * * * ********************************************************************** */ + final String getText(IndexRange range) { + return content.getText(range); + } + @Override public final String getText(int start, int end) { return content.getText(start, end); @@ -401,20 +307,7 @@ 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(); - - if(paragraph < startPar || paragraph > endPar) { - return EMPTY_RANGE; - } - - int start = paragraph == startPar ? selectionStart2D.getMinor() : 0; - int end = paragraph == endPar ? selectionEnd2D.getMinor() : paragraphs.get(paragraph).length(); - - // force selectionProperty() to be valid - getSelection(); - - return new IndexRange(start, end); + return mainCaret.getParagraphSelection(paragraph); } /** @@ -647,14 +540,7 @@ public void replace(int start, int end, StyledDocument replacement) @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())); - } + mainCaret.selectRange(anchor, caretPosition); } /** @@ -664,9 +550,7 @@ public void selectRange(int anchor, int caretPosition) { * at the boundary. Use with care. */ public void positionCaret(int pos) { - try(Guard g = suspend(caretPosition, currentParagraph, caretColumn)) { - internalCaretPosition.setValue(pos); - } + mainCaret.positionCaret(pos); } /* ********************************************************************** * @@ -701,10 +585,6 @@ private PS getParagraphStyleForInsertionAt(int pos) { } } - private void subscribeTo(EventStream src, Consumer consumer) { - manageSubscription(src.subscribe(consumer)); - } - private void manageSubscription(Subscription subscription) { subscriptions = subscriptions.and(subscription); } @@ -721,7 +601,4 @@ private UndoManager createRichUndoManager(UndoManagerFactory factory) { return factory.create(richChanges(), RichTextChange::invert, apply, merge); } - private Guard suspend(Suspendable... suspendables) { - return Suspendable.combine(beingUpdated, Suspendable.combine(suspendables)).suspend(); - } } 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..7627994c8 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/TextEditingArea.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/TextEditingArea.java @@ -78,8 +78,8 @@ public interface TextEditingArea { /** * Index of the current paragraph, i.e. the paragraph with the caret. */ - int getCurrentParagraph(); - ObservableValue currentParagraphProperty(); + int getCaretParagraph(); + ObservableValue caretParagraphProperty(); /** * The caret position within the current paragraph. diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/util/Utilities.java b/richtextfx/src/main/java/org/fxmisc/richtext/util/Utilities.java new file mode 100644 index 000000000..dd9490b9a --- /dev/null +++ b/richtextfx/src/main/java/org/fxmisc/richtext/util/Utilities.java @@ -0,0 +1,23 @@ +package org.fxmisc.richtext.util; + +import javafx.scene.control.IndexRange; + +/** + * A class of static methods or properties used throughout the project. + */ +public class Utilities { + + /** + * Index range [0, 0). + */ + public static final IndexRange EMPTY_RANGE = new IndexRange(0, 0); + + /** + * Clamps the given {@code val} to insure it is within the bounds of {@code min} or {@code max}. + */ + public static int clamp(int min, int val, int max) { + return val < min ? min + : val > max ? max + : val; + } +}