From 88767e0b1820b3cb0a44480356934c5638643a0d Mon Sep 17 00:00:00 2001 From: Jordan Martinez Date: Thu, 23 Mar 2017 13:21:47 -0700 Subject: [PATCH 1/3] Move model API into EditableStyledDocument --- .../model/EditableStyledDocument.java | 3 +- .../GenericEditableStyledDocumentBase.java | 30 ++++++++--- .../fxmisc/richtext/model/StyledDocument.java | 12 +++++ .../richtext/model/StyledTextAreaModel.java | 54 +++++++------------ 4 files changed, 57 insertions(+), 42 deletions(-) diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/EditableStyledDocument.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/EditableStyledDocument.java index f932e377e..9873a5afc 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/EditableStyledDocument.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/EditableStyledDocument.java @@ -5,6 +5,7 @@ import org.reactfx.EventStream; import org.reactfx.SuspendableNo; +import org.reactfx.collection.LiveList; import org.reactfx.value.Val; /** @@ -30,7 +31,7 @@ public interface EditableStyledDocument extends StyledDocument lengthProperty(); @Override - ObservableList> getParagraphs(); + LiveList> getParagraphs(); /** * Read-only snapshot of the current state of this document. diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/GenericEditableStyledDocumentBase.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/GenericEditableStyledDocumentBase.java index 2260b8105..365cf27dd 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/GenericEditableStyledDocumentBase.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/GenericEditableStyledDocumentBase.java @@ -6,17 +6,22 @@ import java.util.Collections; import java.util.List; +import javafx.collections.ObservableList; import org.reactfx.EventSource; import org.reactfx.EventStream; import org.reactfx.Subscription; +import org.reactfx.Suspendable; +import org.reactfx.SuspendableEventStream; import org.reactfx.SuspendableNo; import org.reactfx.collection.LiveList; import org.reactfx.collection.LiveListBase; import org.reactfx.collection.MaterializedListModification; import org.reactfx.collection.QuasiListModification; +import org.reactfx.collection.SuspendableList; import org.reactfx.collection.UnmodifiableByDefaultLiveList; import org.reactfx.util.BiIndex; import org.reactfx.util.Lists; +import org.reactfx.value.SuspendableVal; import org.reactfx.value.Val; /** @@ -51,15 +56,18 @@ protected Subscription observeInputs() { private ReadOnlyStyledDocument doc; - private final EventSource> richChanges = new EventSource<>(); + private final EventSource> internalRichChanges = new EventSource<>(); + private final SuspendableEventStream> richChanges = internalRichChanges.pausable(); @Override public EventStream> richChanges() { return richChanges; } - private final Val text = Val.create(() -> doc.getText(), richChanges); + private final Val internalText = Val.create(() -> doc.getText(), internalRichChanges); + private final SuspendableVal text = internalText.suspendable(); @Override public String getText() { return text.getValue(); } @Override public Val textProperty() { return text; } - private final Val length = Val.create(() -> doc.length(), richChanges); + private final Val internalLength = Val.create(() -> doc.length(), internalRichChanges); + private final SuspendableVal length = internalLength.suspendable(); @Override public int getLength() { return length.getValue(); } @Override public Val lengthProperty() { return length; } @Override public int length() { return length.getValue(); } @@ -67,8 +75,7 @@ protected Subscription observeInputs() { private final EventSource>> parChanges = new EventSource<>(); - private final LiveList> paragraphs = new ParagraphList(); - + private final SuspendableList> paragraphs = new ParagraphList().suspendable(); @Override public LiveList> getParagraphs() { return paragraphs; @@ -85,6 +92,17 @@ public ReadOnlyStyledDocument snapshot() { GenericEditableStyledDocumentBase(Paragraph initialParagraph/*, SegmentOps segmentOps*/) { this.doc = new ReadOnlyStyledDocument<>(Collections.singletonList(initialParagraph)); + + final Suspendable omniSuspendable = Suspendable.combine( + text, + length, + + // add streams after properties, to be released before them + richChanges, + + // paragraphs to be released first + paragraphs); + omniSuspendable.suspendWhen(beingUpdated); } /** @@ -202,7 +220,7 @@ private void update( MaterializedListModification> parChange) { this.doc = newValue; beingUpdated.suspendWhile(() -> { - richChanges.push(change); + internalRichChanges.push(change); parChanges.push(parChange); }); } diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledDocument.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledDocument.java index 07a507358..be2520764 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledDocument.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledDocument.java @@ -28,6 +28,18 @@ default String getText(int start, int end) { return subSequence(start, end).getText(); } + default String getText(int paragraphIndex) { + return getParagraph(paragraphIndex).getText(); + } + + default Paragraph getParagraph(int index) { + return getParagraphs().get(index); + } + + default int getParagraphLength(int paragraphIndex) { + return getParagraph(paragraphIndex).length(); + } + default StyledDocument subSequence(IndexRange range) { return subSequence(range.getStart(), range.getEnd()); } 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 84bcfefe2..a712506e3 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledTextAreaModel.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledTextAreaModel.java @@ -97,17 +97,15 @@ private static int clamp(int min, int val, int max) { * ********************************************************************** */ // text - private final SuspendableVal text; - @Override public final String getText() { return text.getValue(); } - @Override public final ObservableValue textProperty() { return text; } + @Override public final String getText() { return content.getText(); } + @Override public final ObservableValue textProperty() { return content.textProperty(); } // rich text @Override public final StyledDocument getDocument() { return content.snapshot(); } // length - private final SuspendableVal length; - @Override public final int getLength() { return length.getValue(); } - @Override public final ObservableValue lengthProperty() { return 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); @@ -142,8 +140,7 @@ private static int clamp(int min, int val, int max) { @Override public final ObservableValue caretColumnProperty() { return caretColumn; } // paragraphs - private final SuspendableList> paragraphs; - @Override public LiveList> getParagraphs() { return paragraphs; } + @Override public LiveList> getParagraphs() { return content.getParagraphs(); } // beingUpdated private final SuspendableNo beingUpdated = new SuspendableNo(); @@ -157,14 +154,12 @@ private static int clamp(int min, int val, int max) { * ********************************************************************** */ // text changes - private final SuspendableEventStream plainTextChanges; @Override - public final EventStream plainTextChanges() { return plainTextChanges; } + public final EventStream plainTextChanges() { return content.plainChanges(); } // rich text changes - private final SuspendableEventStream> richTextChanges; @Override - public final EventStream> richChanges() { return richTextChanges; } + public final EventStream> richChanges() { return content.richChanges(); } /* ********************************************************************** * * * @@ -249,19 +244,12 @@ public StyledTextAreaModel( this.initialTextStyle = initialTextStyle; this.initialParagraphStyle = initialParagraphStyle; this.preserveStyle = preserveStyle; - - content = document; - paragraphs = LiveList.suspendable(content.getParagraphs()); - - text = Val.suspendable(content.textProperty()); - length = Val.suspendable(content.lengthProperty()); - plainTextChanges = content.plainChanges().pausable(); - richTextChanges = content.richChanges().pausable(); + this.content = document; // 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 -> { + subscribeTo(plainTextChanges(), plainTextChange -> { int changeLength = plainTextChange.getInserted().length() - plainTextChange.getRemoved().length(); if (changeLength != 0) { int indexOfChange = plainTextChange.getPosition(); @@ -314,7 +302,7 @@ public StyledTextAreaModel( Val caretPosition2D = Val.create( () -> content.offsetToPosition(internalCaretPosition.getValue(), Forward), - internalCaretPosition, paragraphs); + internalCaretPosition, getParagraphs()); currentParagraph = caretPosition2D.map(Position::getMajor).suspendable(); caretColumn = caretPosition2D.map(Position::getMinor).suspendable(); @@ -335,21 +323,13 @@ public StyledTextAreaModel( final Suspendable omniSuspendable = Suspendable.combine( beingUpdated, // must be first, to be the last one to release - text, - length, + caretPosition, anchor, selection, selectedText, currentParagraph, - caretColumn, - - // add streams after properties, to be released before them - plainTextChanges, - richTextChanges, - - // paragraphs to be released first - paragraphs); + caretColumn); manageSubscription(omniSuspendable.suspendWhen(content.beingUpdatedProperty())); } @@ -369,11 +349,15 @@ public final String getText(int start, int end) { @Override public String getText(int paragraph) { - return paragraphs.get(paragraph).getText(); + return content.getText(paragraph); } public Paragraph getParagraph(int index) { - return paragraphs.get(index); + return content.getParagraph(index); + } + + public int getParagraphLenth(int index) { + return content.getParagraphLength(index); } @Override @@ -398,7 +382,7 @@ public IndexRange getParagraphSelection(int paragraph) { } int start = paragraph == startPar ? selectionStart2D.getMinor() : 0; - int end = paragraph == endPar ? selectionEnd2D.getMinor() : paragraphs.get(paragraph).length(); + int end = paragraph == endPar ? selectionEnd2D.getMinor() : getParagraphLenth(paragraph); // force selectionProperty() to be valid getSelection(); From 50f32af7d98af173bc111bd151eba9ba414da9f9 Mon Sep 17 00:00:00 2001 From: Jordan Martinez Date: Thu, 23 Mar 2017 13:42:20 -0700 Subject: [PATCH 2/3] Move view API into GenericStyledArea --- .../fxmisc/richtext/GenericStyledArea.java | 345 +++++++++++++----- .../richtext/StyledTextAreaBehavior.java | 104 +++--- .../org/fxmisc/richtext/model/AreaTest.java | 45 +++ 3 files changed, 356 insertions(+), 138 deletions(-) diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java b/richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java index 2fad203e7..948ba907c 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java @@ -2,6 +2,8 @@ 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.*; @@ -10,6 +12,7 @@ import java.util.List; import java.util.Optional; import java.util.function.BiConsumer; +import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.IntConsumer; @@ -63,8 +66,8 @@ import org.fxmisc.richtext.model.EditableStyledDocument; import org.fxmisc.richtext.model.GenericEditableStyledDocument; import org.fxmisc.richtext.model.Paragraph; +import org.fxmisc.richtext.model.ReadOnlyStyledDocument; import org.fxmisc.richtext.model.StyleActions; -import org.fxmisc.richtext.model.StyledTextAreaModel; import org.fxmisc.richtext.model.NavigationActions; import org.fxmisc.richtext.model.PlainTextChange; import org.fxmisc.richtext.model.RichTextChange; @@ -83,9 +86,14 @@ import org.reactfx.Guard; import org.reactfx.StateMachine; import org.reactfx.Subscription; +import org.reactfx.Suspendable; import org.reactfx.SuspendableEventStream; +import org.reactfx.SuspendableNo; 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; @@ -168,6 +176,15 @@ public class GenericStyledArea extends Region */ 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; + } + 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"); @@ -223,9 +240,13 @@ public class GenericStyledArea extends Region @Override public final Var showCaretProperty() { return showCaret; } // undo manager - @Override public UndoManager getUndoManager() { return model.getUndoManager(); } + private UndoManager undoManager; + @Override public UndoManager getUndoManager() { return undoManager; } @Override public void setUndoManager(UndoManagerFactory undoManagerFactory) { - model.setUndoManager(undoManagerFactory); + undoManager.close(); + undoManager = preserveStyle + ? createRichUndoManager(undoManagerFactory) + : createPlainUndoManager(undoManagerFactory); } private final ObjectProperty mouseOverTextDelay = new SimpleObjectProperty<>(null); @@ -259,12 +280,13 @@ public class GenericStyledArea extends Region @Override public final double getContextMenuYOffset() { return contextMenuYOffset; } @Override public final void setContextMenuYOffset(double offset) { contextMenuYOffset = offset; } + private final BooleanProperty useInitialStyleForInsertion = new SimpleBooleanProperty(); @Override - public BooleanProperty useInitialStyleForInsertionProperty() { return model.useInitialStyleForInsertionProperty(); } + public BooleanProperty useInitialStyleForInsertionProperty() { return useInitialStyleForInsertion; } @Override - public void setUseInitialStyleForInsertion(boolean value) { model.setUseInitialStyleForInsertion(value); } + public void setUseInitialStyleForInsertion(boolean value) { useInitialStyleForInsertion.set(value); } @Override - public boolean getUseInitialStyleForInsertion() { return model.getUseInitialStyleForInsertion(); } + public boolean getUseInitialStyleForInsertion() { return useInitialStyleForInsertion.get(); } private Optional, Codec>> styleCodecs = Optional.empty(); @Override @@ -302,19 +324,21 @@ public Optional, Codec>> getStyleCodecs() { * ********************************************************************** */ // text - @Override public final String getText() { return model.getText(); } - @Override public final ObservableValue textProperty() { return model.textProperty(); } + @Override public final String getText() { return content.getText(); } + @Override public final ObservableValue textProperty() { return content.textProperty(); } // rich text - @Override public final StyledDocument getDocument() { return model.getDocument(); } + @Override public final StyledDocument getDocument() { return content; } // length - @Override public final int getLength() { return model.getLength(); } - @Override public final ObservableValue lengthProperty() { return model.lengthProperty(); } + @Override public final int getLength() { return content.getLength(); } + @Override public final ObservableValue lengthProperty() { return content.lengthProperty(); } // caret position - @Override public final int getCaretPosition() { return model.getCaretPosition(); } - @Override public final ObservableValue caretPositionProperty() { return model.caretPositionProperty(); } + 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; @@ -322,16 +346,20 @@ public Optional, Codec>> getStyleCodecs() { @Override public final ObservableValue> caretBoundsProperty() { return caretBounds; } // selection anchor - @Override public final int getAnchor() { return model.getAnchor(); } - @Override public final ObservableValue anchorProperty() { return model.anchorProperty(); } + private final SuspendableVar anchor = Var.newSimpleVar(0).suspendable(); + @Override public final int getAnchor() { return anchor.getValue(); } + @Override public final ObservableValue anchorProperty() { return anchor; } // selection - @Override public final IndexRange getSelection() { return model.getSelection(); } - @Override public final ObservableValue selectionProperty() { return model.selectionProperty(); } + 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 - @Override public final String getSelectedText() { return model.getSelectedText(); } - @Override public final ObservableValue selectedTextProperty() { return model.selectedTextProperty(); } + 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; @@ -339,19 +367,22 @@ public Optional, Codec>> getStyleCodecs() { @Override public final ObservableValue> selectionBoundsProperty() { return selectionBounds; } // current paragraph index - @Override public final int getCurrentParagraph() { return model.getCurrentParagraph(); } - @Override public final ObservableValue currentParagraphProperty() { return model.currentParagraphProperty(); } + private final SuspendableVal currentParagraph; + @Override public final int getCurrentParagraph() { return currentParagraph.getValue(); } + @Override public final ObservableValue currentParagraphProperty() { return currentParagraph; } // caret column - @Override public final int getCaretColumn() { return model.getCaretColumn(); } - @Override public final ObservableValue caretColumnProperty() { return model.caretColumnProperty(); } + 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 model.getParagraphs(); } + @Override public LiveList> getParagraphs() { return content.getParagraphs(); } // beingUpdated - public ObservableBooleanValue beingUpdatedProperty() { return model.beingUpdatedProperty(); } - public boolean isBeingUpdated() { return model.isBeingUpdated(); } + private final SuspendableNo beingUpdated = new SuspendableNo(); + public ObservableBooleanValue beingUpdatedProperty() { return beingUpdated; } + public boolean isBeingUpdated() { return beingUpdated.get(); } // total width estimate @Override @@ -372,10 +403,10 @@ public Optional, Codec>> getStyleCodecs() { * ********************************************************************** */ // text changes - @Override public final EventStream plainTextChanges() { return model.plainTextChanges(); } + @Override public final EventStream plainTextChanges() { return content.plainChanges(); } // rich text changes - @Override public final EventStream> richChanges() { return model.richChanges(); } + @Override public final EventStream> richChanges() { return content.richChanges(); } /* ********************************************************************** * * * @@ -383,6 +414,9 @@ public Optional, Codec>> getStyleCodecs() { * * * ********************************************************************** */ + private Position selectionStart2D; + private Position selectionEnd2D; + private Subscription subscriptions = () -> {}; // Remembers horizontal position when traversing up / down. @@ -402,42 +436,31 @@ public Optional, Codec>> getStyleCodecs() { private final SuspendableEventStream viewportDirty; - /** - * model - */ - private final StyledTextAreaModel model; - - /** - * @return this area's {@link StyledTextAreaModel} - */ - final StyledTextAreaModel getModel() { - return model; - } - /* ********************************************************************** * * * * Fields necessary for Cloning * * * * ********************************************************************** */ + private final EditableStyledDocument content; /** * The underlying document that can be displayed by multiple {@code StyledTextArea}s. */ - public final EditableStyledDocument getContent() { return model.getContent(); } + public final EditableStyledDocument getContent() { return content; } - @Override - public final S getInitialTextStyle() { return model.getInitialTextStyle(); } + private final S initialTextStyle; + @Override public final S getInitialTextStyle() { return initialTextStyle; } - @Override - public final PS getInitialParagraphStyle() { return model.getInitialParagraphStyle(); } + private final PS initialParagraphStyle; + @Override public final PS getInitialParagraphStyle() { return initialParagraphStyle; } private final BiConsumer applyParagraphStyle; @Override public final BiConsumer getApplyParagraphStyle() { return applyParagraphStyle; } // TODO: Currently, only undo/redo respect this flag. - @Override - public final boolean isPreserveStyle() { return model.isPreserveStyle(); } + private final boolean preserveStyle; + @Override public final boolean isPreserveStyle() { return preserveStyle; } /* ********************************************************************** * * * @@ -505,10 +528,99 @@ public GenericStyledArea( TextOps textOps, boolean preserveStyle, Function nodeFactory) { - this.model = new StyledTextAreaModel<>(initialParagraphStyle, initialTextStyle, document, textOps, preserveStyle); + this.initialTextStyle = initialTextStyle; + this.initialParagraphStyle = initialParagraphStyle; + this.preserveStyle = preserveStyle; + this.content = document; this.applyParagraphStyle = applyParagraphStyle; this.segmentOps = textOps; + undoManager = preserveStyle + ? 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(); + + final Suspendable omniSuspendable = Suspendable.combine( + beingUpdated, // must be first, to be the last one to release + + caretPosition, + anchor, + selection, + selectedText, + currentParagraph, + caretColumn); + manageSubscription(omniSuspendable.suspendWhen(content.beingUpdatedProperty())); + + // 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 + 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); + } + } + }); + // allow tab traversal into area setFocusTraversable(true); @@ -601,9 +713,9 @@ public GenericStyledArea( invalidationsOf(estimatedScrollYProperty()) ).suppressible(); EventStream caretBoundsDirty = merge(viewportDirty, caretDirty) - .suppressWhen(model.beingUpdatedProperty()); + .suppressWhen(beingUpdatedProperty()); EventStream selectionBoundsDirty = merge(viewportDirty, invalidationsOf(selectionProperty())) - .suppressWhen(model.beingUpdatedProperty()); + .suppressWhen(beingUpdatedProperty()); // updates the bounds of the caret/selection caretBounds = Val.create(this::getCaretBoundsOnScreen, caretBoundsDirty); @@ -801,93 +913,110 @@ public Optional getCharacterBoundsOnScreen(int from, int to) { @Override public final String getText(int start, int end) { - return model.getText(start, end); + return content.getText(start, end); } @Override public String getText(int paragraph) { - return model.getText(paragraph); + return content.getText(paragraph); } public Paragraph getParagraph(int index) { - return model.getParagraph(index); + return content.getParagraph(index); + } + + public int getParagraphLenth(int index) { + return content.getParagraphLength(index); } @Override public StyledDocument subDocument(int start, int end) { - return model.subDocument(start, end); + return content.subSequence(start, end); } @Override public StyledDocument subDocument(int paragraphIndex) { - return model.subDocument(paragraphIndex); + return content.subDocument(paragraphIndex); } /** * Returns the selection range in the given paragraph. */ public IndexRange getParagraphSelection(int paragraph) { - return model.getParagraphSelection(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() : getParagraphLenth(paragraph); + + // force selectionProperty() to be valid + getSelection(); + + return new IndexRange(start, end); } @Override public S getStyleOfChar(int index) { - return model.getStyleOfChar(index); + return content.getStyleOfChar(index); } @Override public S getStyleAtPosition(int position) { - return model.getStyleAtPosition(position); + return content.getStyleAtPosition(position); } @Override public IndexRange getStyleRangeAtPosition(int position) { - return model.getStyleRangeAtPosition(position); + return content.getStyleRangeAtPosition(position); } @Override public StyleSpans getStyleSpans(int from, int to) { - return model.getStyleSpans(from, to); + return content.getStyleSpans(from, to); } @Override public S getStyleOfChar(int paragraph, int index) { - return model.getStyleOfChar(paragraph, index); + return content.getStyleOfChar(paragraph, index); } @Override public S getStyleAtPosition(int paragraph, int position) { - return model.getStyleAtPosition(paragraph, position); + return content.getStyleAtPosition(paragraph, position); } @Override public IndexRange getStyleRangeAtPosition(int paragraph, int position) { - return model.getStyleRangeAtPosition(paragraph, position); + return content.getStyleRangeAtPosition(paragraph, position); } @Override public StyleSpans getStyleSpans(int paragraph) { - return model.getStyleSpans(paragraph); + return content.getStyleSpans(paragraph); } @Override public StyleSpans getStyleSpans(int paragraph, int from, int to) { - return model.getStyleSpans(paragraph, from, to); + return content.getStyleSpans(paragraph, from, to); } @Override public int getAbsolutePosition(int paragraphIndex, int columnIndex) { - return model.getAbsolutePosition(paragraphIndex, columnIndex); + return content.getAbsolutePosition(paragraphIndex, columnIndex); } @Override public Position position(int row, int col) { - return model.position(row, col); + return content.position(row, col); } @Override public Position offsetToPosition(int charOffset, Bias bias) { - return model.offsetToPosition(charOffset, bias); + return content.offsetToPosition(charOffset, bias); } @@ -978,58 +1107,67 @@ public void selectLine() { public void prevPage(SelectionPolicy selectionPolicy) { showCaretAtBottom(); CharacterHit hit = hit(getTargetCaretOffset(), 1.0); - model.moveTo(hit.getInsertionIndex(), selectionPolicy); + moveTo(hit.getInsertionIndex(), selectionPolicy); } @Override public void nextPage(SelectionPolicy selectionPolicy) { showCaretAtTop(); CharacterHit hit = hit(getTargetCaretOffset(), getViewportHeight() - 1.0); - model.moveTo(hit.getInsertionIndex(), selectionPolicy); + moveTo(hit.getInsertionIndex(), selectionPolicy); } @Override public void setStyle(int from, int to, S style) { - model.setStyle(from, to, style); + content.setStyle(from, to, style); } @Override public void setStyle(int paragraph, S style) { - model.setStyle(paragraph, style); + content.setStyle(paragraph, style); } @Override public void setStyle(int paragraph, int from, int to, S style) { - model.setStyle(paragraph, from, to, style); + content.setStyle(paragraph, from, to, style); } @Override public void setStyleSpans(int from, StyleSpans styleSpans) { - model.setStyleSpans(from, styleSpans); + content.setStyleSpans(from, styleSpans); } @Override public void setStyleSpans(int paragraph, int from, StyleSpans styleSpans) { - model.setStyleSpans(paragraph, from, styleSpans); + content.setStyleSpans(paragraph, from, styleSpans); } @Override public void setParagraphStyle(int paragraph, PS paragraphStyle) { - model.setParagraphStyle(paragraph, paragraphStyle); + content.setParagraphStyle(paragraph, paragraphStyle); } @Override public void replaceText(int start, int end, String text) { - model.replaceText(start, end, text); + StyledDocument doc = ReadOnlyStyledDocument.fromString( + text, getParagraphStyleForInsertionAt(start), getStyleForInsertionAt(start), segmentOps); + replace(start, end, doc); } @Override public void replace(int start, int end, StyledDocument replacement) { - model.replace(start, end, replacement); + content.replace(start, end, replacement); } @Override public void selectRange(int anchor, int caretPosition) { - model.selectRange(anchor, 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())); + } } /* ********************************************************************** * @@ -1040,7 +1178,6 @@ public void selectRange(int anchor, int caretPosition) { public void dispose() { subscriptions.unsubscribe(); - model.dispose(); virtualFlow.dispose(); } @@ -1146,10 +1283,6 @@ public void dispose() { }; } - private void positionCaret(int pos) { - model.positionCaret(pos); - } - private ParagraphBox getCell(int index) { return virtualFlow.getCell(index).getNode(); } @@ -1266,6 +1399,50 @@ private static Bounds extendLeft(Bounds b, double w) { } } + private S getStyleForInsertionAt(int pos) { + if(useInitialStyleForInsertion.get()) { + return initialTextStyle; + } else { + return content.getStyleAtPosition(pos); + } + } + + private PS getParagraphStyleForInsertionAt(int pos) { + if(useInitialStyleForInsertion.get()) { + return initialParagraphStyle; + } else { + return content.getParagraphStyleAtPosition(pos); + } + } + + private UndoManager createPlainUndoManager(UndoManagerFactory factory) { + Consumer apply = change -> replaceText(change.getPosition(), change.getPosition() + change.getRemoved().length(), change.getInserted()); + BiFunction> merge = PlainTextChange::mergeWith; + return factory.create(plainTextChanges(), PlainTextChange::invert, apply, merge); + } + + private UndoManager createRichUndoManager(UndoManagerFactory factory) { + Consumer> apply = change -> replace(change.getPosition(), change.getPosition() + change.getRemoved().length(), change.getInserted()); + BiFunction, RichTextChange, Optional>> merge = RichTextChange::mergeWith; + return factory.create(richChanges(), RichTextChange::invert, apply, merge); + } + + private Guard suspend(Suspendable... suspendables) { + return Suspendable.combine(beingUpdated, Suspendable.combine(suspendables)).suspend(); + } + + /** + * 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. + */ + void positionCaret(int pos) { + try(Guard g = suspend(caretPosition, currentParagraph, caretColumn)) { + internalCaretPosition.setValue(pos); + } + } + void clearTargetCaretOffset() { targetCaretOffset = Optional.empty(); } diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/StyledTextAreaBehavior.java b/richtextfx/src/main/java/org/fxmisc/richtext/StyledTextAreaBehavior.java index f9fb44845..24ee9944c 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/StyledTextAreaBehavior.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/StyledTextAreaBehavior.java @@ -20,7 +20,6 @@ import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; -import org.fxmisc.richtext.model.StyledTextAreaModel; import org.fxmisc.richtext.model.NavigationActions.SelectionPolicy; import org.fxmisc.richtext.model.TwoDimensional.Position; import org.fxmisc.wellbehaved.event.EventPattern; @@ -64,13 +63,13 @@ class StyledTextAreaBehavior { anyOf(keyPressed(PASTE), keyPressed(V, SHORTCUT_DOWN), keyPressed(INSERT, SHIFT_DOWN)), (b, e) -> b.view.paste()), // tab & newline - consume(keyPressed(ENTER), (b, e) -> b.model.replaceSelection("\n")), - consume(keyPressed(TAB), (b, e) -> b.model.replaceSelection("\t")), + consume(keyPressed(ENTER), (b, e) -> b.view.replaceSelection("\n")), + consume(keyPressed(TAB), (b, e) -> b.view.replaceSelection("\t")), // undo/redo - consume(keyPressed(Z, SHORTCUT_DOWN), (b, e) -> b.model.undo()), + consume(keyPressed(Z, SHORTCUT_DOWN), (b, e) -> b.view.undo()), consume( anyOf(keyPressed(Y, SHORTCUT_DOWN), keyPressed(Z, SHORTCUT_DOWN, SHIFT_DOWN)), - (b, e) -> b.model.redo()) + (b, e) -> b.view.redo()) ); InputMapTemplate edits = when(b -> b.view.isEditable(), editsBase); @@ -111,8 +110,8 @@ class StyledTextAreaBehavior { keyPressed(LEFT, SHORTCUT_DOWN), keyPressed(KP_LEFT, SHORTCUT_DOWN) ), (b, e) -> b.skipToPrevWord(SelectionPolicy.CLEAR)), - consume(keyPressed(HOME, SHORTCUT_DOWN), (b, e) -> b.model.start(SelectionPolicy.CLEAR)), - consume(keyPressed(END, SHORTCUT_DOWN), (b, e) -> b.model.end(SelectionPolicy.CLEAR)), + consume(keyPressed(HOME, SHORTCUT_DOWN), (b, e) -> b.view.start(SelectionPolicy.CLEAR)), + consume(keyPressed(END, SHORTCUT_DOWN), (b, e) -> b.view.end(SelectionPolicy.CLEAR)), // selection consume( anyOf( @@ -126,8 +125,8 @@ class StyledTextAreaBehavior { ), StyledTextAreaBehavior::selectLeft), consume(keyPressed(HOME, SHIFT_DOWN), (b, e) -> b.view.lineStart(selPolicy)), consume(keyPressed(END, SHIFT_DOWN), (b, e) -> b.view.lineEnd(selPolicy)), - consume(keyPressed(HOME, SHIFT_DOWN, SHORTCUT_DOWN), (b, e) -> b.model.start(selPolicy)), - consume(keyPressed(END, SHIFT_DOWN, SHORTCUT_DOWN), (b, e) -> b.model.end(selPolicy)), + consume(keyPressed(HOME, SHIFT_DOWN, SHORTCUT_DOWN), (b, e) -> b.view.start(selPolicy)), + consume(keyPressed(END, SHIFT_DOWN, SHORTCUT_DOWN), (b, e) -> b.view.end(selPolicy)), consume( anyOf( keyPressed(RIGHT, SHIFT_DOWN, SHORTCUT_DOWN), @@ -138,7 +137,7 @@ class StyledTextAreaBehavior { keyPressed(LEFT, SHIFT_DOWN, SHORTCUT_DOWN), keyPressed(KP_LEFT, SHIFT_DOWN, SHORTCUT_DOWN) ), (b, e) -> b.skipToPrevWord(selPolicy)), - consume(keyPressed(A, SHORTCUT_DOWN), (b, e) -> b.model.selectAll()) + consume(keyPressed(A, SHORTCUT_DOWN), (b, e) -> b.view.selectAll()) ); InputMapTemplate copyAction = consume( @@ -214,8 +213,6 @@ private enum DragState { private final GenericStyledArea view; - private final StyledTextAreaModel model; - /** * Indicates whether selection is being dragged by the user. */ @@ -229,7 +226,6 @@ private enum DragState { StyledTextAreaBehavior(GenericStyledArea area) { this.view = area; - this.model = area.getModel(); InputMapTemplate.installFallback(EVENT_TEMPLATE, this, b -> b.view); @@ -266,7 +262,7 @@ private void keyTyped(KeyEvent event) { return; } - model.replaceSelection(text); + view.replaceSelection(text); } private static boolean isLegal(String text) { @@ -280,71 +276,71 @@ private static boolean isLegal(String text) { } private void deleteBackward(KeyEvent ignore) { - IndexRange selection = model.getSelection(); + IndexRange selection = view.getSelection(); if(selection.getLength() == 0) { - model.deletePreviousChar(); + view.deletePreviousChar(); } else { - model.replaceSelection(""); + view.replaceSelection(""); } } private void deleteForward(KeyEvent ignore) { - IndexRange selection = model.getSelection(); + IndexRange selection = view.getSelection(); if(selection.getLength() == 0) { - model.deleteNextChar(); + view.deleteNextChar(); } else { - model.replaceSelection(""); + view.replaceSelection(""); } } private void left(KeyEvent ignore) { - IndexRange sel = model.getSelection(); + IndexRange sel = view.getSelection(); if(sel.getLength() == 0) { - model.previousChar(SelectionPolicy.CLEAR); + view.previousChar(SelectionPolicy.CLEAR); } else { - model.moveTo(sel.getStart(), SelectionPolicy.CLEAR); + view.moveTo(sel.getStart(), SelectionPolicy.CLEAR); } } private void right(KeyEvent ignore) { - IndexRange sel = model.getSelection(); + IndexRange sel = view.getSelection(); if(sel.getLength() == 0) { - model.nextChar(SelectionPolicy.CLEAR); + view.nextChar(SelectionPolicy.CLEAR); } else { - model.moveTo(sel.getEnd(), SelectionPolicy.CLEAR); + view.moveTo(sel.getEnd(), SelectionPolicy.CLEAR); } } private void selectLeft(KeyEvent ignore) { - model.previousChar(SelectionPolicy.ADJUST); + view.previousChar(SelectionPolicy.ADJUST); } private void selectRight(KeyEvent ignore) { - model.nextChar(SelectionPolicy.ADJUST); + view.nextChar(SelectionPolicy.ADJUST); } private void selectWord() { - model.wordBreaksBackwards(1, SelectionPolicy.CLEAR); - model.wordBreaksForwards(1, SelectionPolicy.ADJUST); + view.wordBreaksBackwards(1, SelectionPolicy.CLEAR); + view.wordBreaksForwards(1, SelectionPolicy.ADJUST); } private void deletePrevWord(KeyEvent ignore) { - int end = model.getCaretPosition(); + int end = view.getCaretPosition(); if (end > 0) { - model.wordBreaksBackwards(2, SelectionPolicy.CLEAR); - int start = model.getCaretPosition(); - model.replaceText(start, end, ""); + view.wordBreaksBackwards(2, SelectionPolicy.CLEAR); + int start = view.getCaretPosition(); + view.replaceText(start, end, ""); } } private void deleteNextWord(KeyEvent ignore) { - int start = model.getCaretPosition(); + int start = view.getCaretPosition(); - if (start < model.getLength()) { - model.wordBreaksForwards(2, SelectionPolicy.CLEAR); - int end = model.getCaretPosition(); - model.replaceText(start, end, ""); + if (start < view.getLength()) { + view.wordBreaksForwards(2, SelectionPolicy.CLEAR); + int end = view.getCaretPosition(); + view.replaceText(start, end, ""); } } @@ -356,7 +352,7 @@ private void downLines(SelectionPolicy selectionPolicy, int nLines) { CharacterHit hit = view.hit(view.getTargetCaretOffset(), targetLine); // update model - model.moveTo(hit.getInsertionIndex(), selectionPolicy); + view.moveTo(hit.getInsertionIndex(), selectionPolicy); } } @@ -369,23 +365,23 @@ private void nextLine(SelectionPolicy selectionPolicy) { } private void skipToPrevWord(SelectionPolicy selectionPolicy) { - int caretPos = model.getCaretPosition(); + int caretPos = view.getCaretPosition(); // if (0 == caretPos), do nothing as can't move to the left anyway if (1 <= caretPos ) { - boolean prevCharIsWhiteSpace = isWhitespace(model.getText(caretPos - 1, caretPos).charAt(0)); - model.wordBreaksBackwards(prevCharIsWhiteSpace ? 2 : 1, selectionPolicy); + boolean prevCharIsWhiteSpace = isWhitespace(view.getText(caretPos - 1, caretPos).charAt(0)); + view.wordBreaksBackwards(prevCharIsWhiteSpace ? 2 : 1, selectionPolicy); } } private void skipToNextWord(SelectionPolicy selectionPolicy) { - int caretPos = model.getCaretPosition(); - int length = model.getLength(); + int caretPos = view.getCaretPosition(); + int length = view.getLength(); // if (caretPos == length), do nothing as can't move to the right anyway if (caretPos <= length - 1) { - boolean nextCharIsWhiteSpace = isWhitespace(model.getText(caretPos, caretPos + 1).charAt(0)); - model.wordBreaksForwards(nextCharIsWhiteSpace ? 2 : 1, selectionPolicy); + boolean nextCharIsWhiteSpace = isWhitespace(view.getText(caretPos, caretPos + 1).charAt(0)); + view.wordBreaksForwards(nextCharIsWhiteSpace ? 2 : 1, selectionPolicy); } } @@ -420,14 +416,14 @@ private void mousePressed(MouseEvent e) { if(e.isShiftDown()) { // On Mac always extend selection, // switching anchor and caret if necessary. - model.moveTo( + view.moveTo( hit.getInsertionIndex(), isMac ? SelectionPolicy.EXTEND : SelectionPolicy.ADJUST); } else { switch (e.getClickCount()) { case 1: firstLeftPress(hit); break; case 2: selectWord(); break; - case 3: model.selectParagraph(); break; + case 3: view.selectParagraph(); break; default: // do nothing } } @@ -438,7 +434,7 @@ private void mousePressed(MouseEvent e) { private void firstLeftPress(CharacterHit hit) { view.clearTargetCaretOffset(); - IndexRange selection = model.getSelection(); + IndexRange selection = view.getSelection(); if(view.isEditable() && selection.getLength() != 0 && hit.getCharacterIndex().isPresent() && @@ -448,7 +444,7 @@ private void firstLeftPress(CharacterHit hit) { dragSelection = DragState.POTENTIAL_DRAG; } else { dragSelection = DragState.NO_DRAG; - model.moveTo(hit.getInsertionIndex(), SelectionPolicy.CLEAR); + view.moveTo(hit.getInsertionIndex(), SelectionPolicy.CLEAR); } } @@ -486,9 +482,9 @@ private void dragTo(Point2D p) { if(dragSelection == DragState.DRAG || dragSelection == DragState.POTENTIAL_DRAG) { // MOUSE_DRAGGED may arrive even before DRAG_DETECTED - model.positionCaret(hit.getInsertionIndex()); + view.positionCaret(hit.getInsertionIndex()); } else { - model.moveTo(hit.getInsertionIndex(), SelectionPolicy.ADJUST); + view.moveTo(hit.getInsertionIndex(), SelectionPolicy.ADJUST); } } @@ -505,7 +501,7 @@ private void mouseReleased(MouseEvent e) { case POTENTIAL_DRAG: // drag didn't happen, position caret CharacterHit hit = view.hit(e.getX(), e.getY()); - model.moveTo(hit.getInsertionIndex(), SelectionPolicy.CLEAR); + view.moveTo(hit.getInsertionIndex(), SelectionPolicy.CLEAR); break; case DRAG: // only handle drags if mouse was released inside of view diff --git a/richtextfx/src/test/java/org/fxmisc/richtext/model/AreaTest.java b/richtextfx/src/test/java/org/fxmisc/richtext/model/AreaTest.java index 84838e90e..d3b5c9054 100644 --- a/richtextfx/src/test/java/org/fxmisc/richtext/model/AreaTest.java +++ b/richtextfx/src/test/java/org/fxmisc/richtext/model/AreaTest.java @@ -1,8 +1,13 @@ package org.fxmisc.richtext.model; import org.fxmisc.richtext.InlineCssTextArea; +import org.fxmisc.richtext.StyledTextArea; import org.junit.Test; +import java.util.Collection; + +import static org.junit.Assert.assertEquals; + public class AreaTest { private InlineCssTextArea area = new InlineCssTextArea(); @@ -13,4 +18,44 @@ public void deletingTextThatWasJustInsertedShouldNotMergeTheTwoChanges() { area.replaceText(0, area.getLength(), ""); area.undo(); } + + @Test + public void testUndoWithWinNewlines() { + final TextOps>, Collection> segOps = StyledText.textOps(); + + String text1 = "abc\r\ndef"; + String text2 = "A\r\nB\r\nC"; + + area.replaceText(text1); + area.getUndoManager().forgetHistory(); + area.insertText(0, text2); + assertEquals("A\nB\nCabc\ndef", area.getText()); + + area.undo(); + assertEquals("abc\ndef", area.getText()); + } + + @Test + public void testForBug216() { + final TextOps, Boolean> segOps = StyledText.textOps(); + + // set up area with some styled text content + boolean initialStyle = false; + StyledTextArea area = new StyledTextArea<>( + "", (t, s) -> {}, initialStyle, (t, s) -> {}, + new SimpleEditableStyledDocument<>("", initialStyle), true); + area.replaceText("testtest"); + area.setStyle(0, 8, true); + + // add a space styled by initialStyle + area.setUseInitialStyleForInsertion(true); + area.insertText(4, " "); + + // add another space + area.insertText(5, " "); + + // testing that undo/redo don't throw an exception + area.undo(); + area.redo(); + } } From ec94238e6b0d22e126dd72495dd4dd71f2fe92f7 Mon Sep 17 00:00:00 2001 From: Jordan Martinez Date: Thu, 23 Mar 2017 13:42:50 -0700 Subject: [PATCH 3/3] Finish removing middleman: ESD now replaces STAModel as the model --- .../model/EditableStyledDocument.java | 3 +- .../richtext/model/StyledTextAreaModel.java | 592 ------------------ .../model/StyledTextAreaModelTest.java | 56 -- 3 files changed, 1 insertion(+), 650 deletions(-) delete mode 100644 richtextfx/src/main/java/org/fxmisc/richtext/model/StyledTextAreaModel.java delete mode 100644 richtextfx/src/test/java/org/fxmisc/richtext/model/StyledTextAreaModelTest.java diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/EditableStyledDocument.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/EditableStyledDocument.java index 9873a5afc..55f1a8edd 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/EditableStyledDocument.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/EditableStyledDocument.java @@ -10,8 +10,7 @@ /** * Content model for {@link org.fxmisc.richtext.GenericStyledArea}. Implements edit operations - * on styled text, but not worrying about additional aspects such as - * caret or selection, which are handled by {@link StyledTextAreaModel}. + * on styled text, but not worrying about view aspects. */ public interface EditableStyledDocument extends StyledDocument { diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledTextAreaModel.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledTextAreaModel.java deleted file mode 100644 index a712506e3..000000000 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledTextAreaModel.java +++ /dev/null @@ -1,592 +0,0 @@ -package org.fxmisc.richtext.model; - -import static org.fxmisc.richtext.model.TwoDimensional.Bias.*; - -import java.util.Optional; -import java.util.function.BiFunction; -import java.util.function.Consumer; - -import javafx.beans.property.BooleanProperty; -import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.value.ObservableBooleanValue; -import javafx.beans.value.ObservableValue; -import javafx.scene.control.IndexRange; - -import org.fxmisc.undo.UndoManager; -import org.fxmisc.undo.UndoManagerFactory; -import org.reactfx.EventStream; -import org.reactfx.Guard; -import org.reactfx.Subscription; -import org.reactfx.Suspendable; -import org.reactfx.SuspendableEventStream; -import org.reactfx.SuspendableNo; -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} - * - * @param type of style that can be applied to text. - * @param type of style that can be applied to Paragraph - */ -public class StyledTextAreaModel - implements - EditActions, - NavigationActions, - StyleActions, - 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 * - * * - * Properties affect behavior and/or appearance of this control. * - * * - * They are readable and writable by the client code and never change by * - * other means, i.e. they contain either the default value or the value * - * set by the client code. * - * * - * ********************************************************************** */ - - // undo manager - private UndoManager undoManager; - @Override public UndoManager getUndoManager() { return undoManager; } - @Override public void setUndoManager(UndoManagerFactory undoManagerFactory) { - undoManager.close(); - undoManager = preserveStyle - ? createRichUndoManager(undoManagerFactory) - : createPlainUndoManager(undoManagerFactory); - } - - final BooleanProperty useInitialStyleForInsertion = new SimpleBooleanProperty(); - @Override - public BooleanProperty useInitialStyleForInsertionProperty() { return useInitialStyleForInsertion; } - @Override - public void setUseInitialStyleForInsertion(boolean value) { useInitialStyleForInsertion.set(value); } - @Override - public boolean getUseInitialStyleForInsertion() { return useInitialStyleForInsertion.get(); } - - /* ********************************************************************** * - * * - * Observables * - * * - * Observables are "dynamic" (i.e. changing) characteristics of this * - * control. They are not directly settable by the client code, but change * - * in response to user input and/or API actions. * - * * - * ********************************************************************** */ - - // text - @Override public final String getText() { return content.getText(); } - @Override public final ObservableValue textProperty() { return content.textProperty(); } - - // rich text - @Override public final StyledDocument getDocument() { return content.snapshot(); } - - // 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; } - - // 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; } - - // 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(); } - - // beingUpdated - private final SuspendableNo beingUpdated = new SuspendableNo(); - public ObservableBooleanValue beingUpdatedProperty() { return beingUpdated; } - public boolean isBeingUpdated() { return beingUpdated.get(); } - - /* ********************************************************************** * - * * - * Event streams * - * * - * ********************************************************************** */ - - // text changes - @Override - public final EventStream plainTextChanges() { return content.plainChanges(); } - - // rich text changes - @Override - public final EventStream> richChanges() { return content.richChanges(); } - - /* ********************************************************************** * - * * - * Private & Package-Private fields * - * * - * ********************************************************************** */ - - private final TextOps textOps; - - private Subscription subscriptions = () -> {}; - - private Position selectionStart2D; - private Position selectionEnd2D; - - /** - * content model - */ - private final EditableStyledDocument content; - - /** - * Usually used to create another area (View) that shares - * the same document (Model). - * @return this area's {@link EditableStyledDocument} - */ - public final EditableStyledDocument getContent() { return content; } - - private final S initialTextStyle; - @Override public final S getInitialTextStyle() { return initialTextStyle; } - - private final PS initialParagraphStyle; - @Override public final PS getInitialParagraphStyle() { return initialParagraphStyle; } - - // TODO: Currently, only undo/redo respect this flag. - private final boolean preserveStyle; - @Override public final boolean isPreserveStyle() { return preserveStyle; } - - - /* ********************************************************************** * - * * - * Constructors * - * * - * ********************************************************************** */ - - /** - * Creates a text area with empty text content. - * - * @param initialTextStyle style to use in places where no other style is - * specified (yet). - * @param initialParagraphStyle style to use in places where no other style is - * specified (yet). - */ - public StyledTextAreaModel(PS initialParagraphStyle, S initialTextStyle, TextOps segmentOps) { - this(initialParagraphStyle, initialTextStyle, segmentOps, true); - } - - public StyledTextAreaModel(PS initialParagraphStyle, S initialTextStyle, TextOps segmentOps, boolean preserveStyle - ) { - this(initialParagraphStyle, initialTextStyle, - new GenericEditableStyledDocumentBase<>(initialParagraphStyle, initialTextStyle, segmentOps), - segmentOps, preserveStyle); - } - - /** - * The same as {@link #StyledTextAreaModel(Object, Object, TextOps)} except that - * this constructor can be used to create another {@code StyledTextArea} object that - * shares the same {@link EditableStyledDocument}. - */ - public StyledTextAreaModel(PS initialParagraphStyle, S initialTextStyle, - EditableStyledDocument document, TextOps textOps - ) { - this(initialParagraphStyle, initialTextStyle, document, textOps, true); - } - - public StyledTextAreaModel( - PS initialParagraphStyle, - S initialTextStyle, - EditableStyledDocument document, - TextOps textOps, - boolean preserveStyle - ) { - this.textOps = textOps; - this.initialTextStyle = initialTextStyle; - this.initialParagraphStyle = initialParagraphStyle; - this.preserveStyle = preserveStyle; - this.content = document; - - // 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 - 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); - } - } - }); - - undoManager = preserveStyle - ? 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(); - - final Suspendable omniSuspendable = Suspendable.combine( - beingUpdated, // must be first, to be the last one to release - - caretPosition, - anchor, - selection, - selectedText, - currentParagraph, - caretColumn); - manageSubscription(omniSuspendable.suspendWhen(content.beingUpdatedProperty())); - } - - - /* ********************************************************************** * - * * - * Queries * - * * - * Queries are parameterized observables. * - * * - * ********************************************************************** */ - - @Override - public final String getText(int start, int end) { - return content.getText(start, end); - } - - @Override - public String getText(int paragraph) { - return content.getText(paragraph); - } - - public Paragraph getParagraph(int index) { - return content.getParagraph(index); - } - - public int getParagraphLenth(int index) { - return content.getParagraphLength(index); - } - - @Override - public StyledDocument subDocument(int start, int end) { - return content.subSequence(start, end); - } - - @Override - public StyledDocument subDocument(int paragraphIndex) { - return content.subDocument(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() : getParagraphLenth(paragraph); - - // force selectionProperty() to be valid - getSelection(); - - return new IndexRange(start, end); - } - - @Override - public S getStyleOfChar(int index) { - return content.getStyleOfChar(index); - } - - @Override - public S getStyleAtPosition(int position) { - return content.getStyleAtPosition(position); - } - - @Override - public IndexRange getStyleRangeAtPosition(int position) { - return content.getStyleRangeAtPosition(position); - } - - @Override - public StyleSpans getStyleSpans(int from, int to) { - return content.getStyleSpans(from, to); - } - - @Override - public S getStyleOfChar(int paragraph, int index) { - return content.getStyleOfChar(paragraph, index); - } - - @Override - public S getStyleAtPosition(int paragraph, int position) { - return content.getStyleOfChar(paragraph, position); - } - - @Override - public IndexRange getStyleRangeAtPosition(int paragraph, int position) { - return content.getStyleRangeAtPosition(paragraph, position); - } - - @Override - public StyleSpans getStyleSpans(int paragraph) { - return content.getStyleSpans(paragraph); - } - - @Override - public StyleSpans getStyleSpans(int paragraph, int from, int to) { - return content.getStyleSpans(paragraph, from, to); - } - - @Override - public int getAbsolutePosition(int paragraphIndex, int columnIndex) { - return content.getAbsolutePosition(paragraphIndex, columnIndex); - } - - @Override - public Position position(int row, int col) { - return content.position(row, col); - } - - @Override - public Position offsetToPosition(int charOffset, Bias bias) { - return content.offsetToPosition(charOffset, bias); - } - - - /* ********************************************************************** * - * * - * Actions * - * * - * Actions change the state of this control. They typically cause a * - * change of one or more observables and/or produce an event. * - * * - * ********************************************************************** */ - - @Override - public void setStyle(int from, int to, S style) { - content.setStyle(from, to, style); - } - - @Override - public void setStyle(int paragraph, S style) { - content.setStyle(paragraph, style); - } - - @Override - public void setStyle(int paragraph, int from, int to, S style) { - content.setStyle(paragraph, from, to, style); - } - - @Override - public void setStyleSpans(int from, StyleSpans styleSpans) { - content.setStyleSpans(from, styleSpans); - } - - @Override - public void setStyleSpans(int paragraph, int from, StyleSpans styleSpans) { - content.setStyleSpans(paragraph, from, styleSpans); - } - - @Override - public void setParagraphStyle(int paragraph, PS paragraphStyle) { - content.setParagraphStyle(paragraph, paragraphStyle); - } - - @Override - public void replaceText(int start, int end, String text) { - StyledDocument doc = ReadOnlyStyledDocument.fromString( - text, getParagraphStyleForInsertionAt(start), getStyleForInsertionAt(start), textOps); - replace(start, end, doc); - } - - @Override - public void replace(int start, int end, StyledDocument replacement) { - try (Guard g = content.beingUpdatedProperty().suspend()) { - start = clamp(0, start, getLength()); - end = clamp(0, end, getLength()); - - content.replace(start, end, replacement); - - int newCaretPos = start + replacement.length(); - 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())); - } - } - - /** - * 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, currentParagraph, caretColumn)) { - internalCaretPosition.setValue(pos); - } - } - - /* ********************************************************************** * - * * - * Public API * - * * - * ********************************************************************** */ - - public void dispose() { - subscriptions.unsubscribe(); - } - - /* ********************************************************************** * - * * - * Private methods * - * * - * ********************************************************************** */ - - private S getStyleForInsertionAt(int pos) { - if(useInitialStyleForInsertion.get()) { - return initialTextStyle; - } else { - return content.getStyleAtPosition(pos); - } - } - - private PS getParagraphStyleForInsertionAt(int pos) { - if(useInitialStyleForInsertion.get()) { - return initialParagraphStyle; - } else { - return content.getParagraphStyleAtPosition(pos); - } - } - - private void subscribeTo(EventStream src, Consumer consumer) { - manageSubscription(src.subscribe(consumer)); - } - - private void manageSubscription(Subscription subscription) { - subscriptions = subscriptions.and(subscription); - } - - private UndoManager createPlainUndoManager(UndoManagerFactory factory) { - Consumer apply = change -> replaceText(change.getPosition(), change.getPosition() + change.getRemoved().length(), change.getInserted()); - BiFunction> merge = PlainTextChange::mergeWith; - return factory.create(plainTextChanges(), PlainTextChange::invert, apply, merge); - } - - private UndoManager createRichUndoManager(UndoManagerFactory factory) { - Consumer> apply = change -> replace(change.getPosition(), change.getPosition() + change.getRemoved().length(), change.getInserted()); - BiFunction, RichTextChange, Optional>> merge = RichTextChange::mergeWith; - 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/test/java/org/fxmisc/richtext/model/StyledTextAreaModelTest.java b/richtextfx/src/test/java/org/fxmisc/richtext/model/StyledTextAreaModelTest.java deleted file mode 100644 index b8df9131d..000000000 --- a/richtextfx/src/test/java/org/fxmisc/richtext/model/StyledTextAreaModelTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.fxmisc.richtext.model; - -import static org.junit.Assert.*; - -import java.util.Collection; -import java.util.Collections; - -import org.junit.Test; - -public class StyledTextAreaModelTest { - - @Test - public void testUndoWithWinNewlines() { - final TextOps>, Collection> segOps = StyledText.textOps(); - - String text1 = "abc\r\ndef"; - String text2 = "A\r\nB\r\nC"; - StyledTextAreaModel, StyledText>, Collection> model = new StyledTextAreaModel<>( - Collections.emptyList(), - Collections.emptyList(), - segOps - ); - - model.replaceText(text1); - model.getUndoManager().forgetHistory(); - model.insertText(0, text2); - assertEquals("A\nB\nCabc\ndef", model.getText()); - - model.undo(); - assertEquals("abc\ndef", model.getText()); - } - - @Test - public void testForBug216() { - final TextOps, Boolean> segOps = StyledText.textOps(); - - // set up area with some styled text content - boolean initialStyle = false; - StyledTextAreaModel, Boolean> model = new StyledTextAreaModel<>( - "", initialStyle, new SimpleEditableStyledDocument<>("", initialStyle), segOps, true); - model.replaceText("testtest"); - model.setStyle(0, 8, true); - - // add a space styled by initialStyle - model.setUseInitialStyleForInsertion(true); - model.insertText(4, " "); - - // add another space - model.insertText(5, " "); - - // testing that undo/redo don't throw an exception - model.undo(); - model.redo(); - } - -}