org.fxmisc.richtext.GenericStyledArea Maven / Gradle / Ivy
package org.fxmisc.richtext;
import static org.reactfx.EventStreams.*;
import static org.reactfx.util.Tuples.*;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
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.IntFunction;
import java.util.function.IntSupplier;
import java.util.function.IntUnaryOperator;
import java.util.function.Predicate;
import java.util.function.UnaryOperator;
import javafx.application.ConditionalFeature;
import javafx.application.Platform;
import javafx.beans.NamedArg;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableSet;
import javafx.css.CssMetaData;
import javafx.css.PseudoClass;
import javafx.css.StyleConverter;
import javafx.css.Styleable;
import javafx.css.StyleableObjectProperty;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.geometry.BoundingBox;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Point2D;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.IndexRange;
import javafx.scene.input.InputMethodEvent;
import javafx.scene.input.InputMethodRequests;
import javafx.scene.input.InputMethodTextRun;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.text.TextFlow;
import org.fxmisc.flowless.Cell;
import org.fxmisc.flowless.VirtualFlow;
import org.fxmisc.flowless.VirtualFlowHit;
import org.fxmisc.flowless.Virtualized;
import org.fxmisc.flowless.VirtualizedScrollPane;
import org.fxmisc.richtext.model.Codec;
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.PlainTextChange;
import org.fxmisc.richtext.model.Replacement;
import org.fxmisc.richtext.model.RichTextChange;
import org.fxmisc.richtext.model.StyleSpans;
import org.fxmisc.richtext.model.StyledDocument;
import org.fxmisc.richtext.model.StyledSegment;
import org.fxmisc.richtext.model.TextOps;
import org.fxmisc.richtext.model.TwoDimensional;
import org.fxmisc.richtext.model.TwoLevelNavigator;
import org.fxmisc.richtext.event.MouseOverTextEvent;
import org.fxmisc.richtext.util.SubscribeableContentsObsSet;
import org.fxmisc.richtext.util.UndoUtils;
import org.fxmisc.undo.UndoManager;
import org.reactfx.EventStream;
import org.reactfx.EventStreams;
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.util.Tuple2;
import org.reactfx.value.Val;
import org.reactfx.value.Var;
/**
* Text editing control that renders and edits a {@link EditableStyledDocument}.
*
* Accepts user input (keyboard, mouse) and provides API to assign style to text ranges. It is suitable for
* syntax highlighting and rich-text editors.
*
* Adding Scrollbars to the Area
*
* By default, scroll bars do not appear when the content spans outside of the viewport.
* To add scroll bars, the area needs to be wrapped in a {@link VirtualizedScrollPane}. For example,
*
* // shows area without scroll bars
* InlineCssTextArea area = new InlineCssTextArea();
*
* // add scroll bars that will display as needed
* VirtualizedScrollPane<InlineCssTextArea> vsPane = new VirtualizedScrollPane<>(area);
*
* Parent parent = // creation code
* parent.getChildren().add(vsPane)
*
*
* Auto-Scrolling to the Caret
*
* Every time the underlying {@link EditableStyledDocument} changes via user interaction (e.g. typing) through
* the {@code GenericStyledArea}, the area will scroll to insure the caret is kept in view. However, this does not
* occur if changes are done programmatically. For example, let's say the area is displaying the bottom part
* of the area's {@link EditableStyledDocument} and some code changes something in the top part of the document
* that is not currently visible. If there is no call to {@link #requestFollowCaret()} at the end of that code,
* the area will not auto-scroll to that section of the document. The change will occur, and the user will continue
* to see the bottom part of the document as before. If such a call is there, then the area will scroll
* to the top of the document and no longer display the bottom part of it.
* For example...
*
* // assuming the user is currently seeing the top of the area
*
* // then changing the bottom, currently not visible part of the area...
* int startParIdx = 40;
* int startColPosition = 2;
* int endParIdx = 42;
* int endColPosition = 10;
*
* // ...by itself will not scroll the viewport to where the change occurs
* area.replaceText(startParIdx, startColPosition, endParIdx, endColPosition, "replacement text");
*
* // adding this line after the last modification to the area will cause the viewport to scroll to that change
* // leaving the following line out will leave the viewport unaffected and the user will not notice any difference
* area.requestFollowCaret();
*
*
* Additionally, when overriding the default user-interaction behavior, remember to include a call
* to {@link #requestFollowCaret()}.
*
* Setting the area's {@link UndoManager}
*
*
* The default UndoManager can undo/redo either {@link PlainTextChange}s or {@link RichTextChange}s. To create
* your own specialized version that may use changes different than these (or a combination of these changes
* with others), create them using the convenient factory methods in {@link UndoUtils}.
*
*
* Overriding default keyboard behavior
*
* {@code GenericStyledArea} uses {@link javafx.scene.input.KeyEvent#KEY_TYPED KEY_TYPED} to handle ordinary
* character input and {@link javafx.scene.input.KeyEvent#KEY_PRESSED KEY_PRESSED} to handle control key
* combinations (including Enter and Tab). To add or override some keyboard
* shortcuts, while keeping the rest in place, you would combine the default
* event handler with a new one that adds or overrides some of the default
* key combinations.
*
* For example, this is how to bind {@code Ctrl+S} to the {@code save()} operation:
*
*
* import static javafx.scene.input.KeyCode.*;
* import static javafx.scene.input.KeyCombination.*;
* import static org.fxmisc.wellbehaved.event.EventPattern.*;
* import static org.fxmisc.wellbehaved.event.InputMap.*;
*
* import org.fxmisc.wellbehaved.event.Nodes;
*
* // installs the following consume InputMap,
* // so that a CTRL+S event saves the document and consumes the event
* Nodes.addInputMap(area, consume(keyPressed(S, CONTROL_DOWN), event -> save()));
*
*
* Overriding default mouse behavior
*
* The area's default mouse behavior properly handles auto-scrolling and dragging the selected text to a new location.
* As such, some parts cannot be partially overridden without it affecting other behavior.
*
* The following lists either {@link org.fxmisc.wellbehaved.event.EventPattern}s that cannot be
* overridden without negatively affecting the default mouse behavior or describe how to safely override things
* in a special way without disrupting the auto scroll behavior.
*
* -
* First (1 click count) Primary Button Mouse Pressed Events:
* (
EventPattern.mousePressed(MouseButton.PRIMARY).onlyIf(e -> e.getClickCount() == 1)
).
* Do not override. Instead, use {@link #onOutsideSelectionMousePressed},
* {@link #onInsideSelectionMousePressReleased}, or see next item.
*
* - (
* All Other Mouse Pressed Events (e.g., Primary with 2+ click count):
* Aside from hiding the context menu if it is showing (use {@link #hideContextMenu()} some((where in your
* overriding InputMap to maintain this behavior), these can be safely overridden via any of the
* {@link org.fxmisc.wellbehaved.event.template.InputMapTemplate InputMapTemplate's factory methods} or
* {@link org.fxmisc.wellbehaved.event.InputMap InputMap's factory methods}.
*
* -
* Primary-Button-only Mouse Drag Detection Events:
* (
EventPattern.eventType(MouseEvent.DRAG_DETECTED).onlyIf(e -> e.getButton() == MouseButton.PRIMARY && !e.isMiddleButtonDown() && !e.isSecondaryButtonDown())
).
* Do not override. Instead, use {@link #onNewSelectionDrag} or {@link #onSelectionDrag}.
*
* -
* Primary-Button-only Mouse Drag Events:
* (
EventPattern.mouseDragged().onlyIf(e -> e.getButton() == MouseButton.PRIMARY && !e.isMiddleButtonDown() && !e.isSecondaryButtonDown())
)
* Do not override, but see next item.
*
* -
* All Other Mouse Drag Events:
* You may safely override other Mouse Drag Events using different
* {@link org.fxmisc.wellbehaved.event.EventPattern}s without affecting default behavior only if
* process InputMaps (
* {@link org.fxmisc.wellbehaved.event.template.InputMapTemplate#process(javafx.event.EventType, BiFunction)},
* {@link org.fxmisc.wellbehaved.event.template.InputMapTemplate#process(org.fxmisc.wellbehaved.event.EventPattern, BiFunction)},
* {@link org.fxmisc.wellbehaved.event.InputMap#process(javafx.event.EventType, Function)}, or
* {@link org.fxmisc.wellbehaved.event.InputMap#process(org.fxmisc.wellbehaved.event.EventPattern, Function)}
* ) are used and {@link org.fxmisc.wellbehaved.event.InputHandler.Result#PROCEED} is returned.
* The area has a "catch all" Mouse Drag InputMap that will auto scroll towards the mouse drag event when it
* occurs outside the bounds of the area and will stop auto scrolling when the mouse event occurs within the
* area. However, this only works if the event is not consumed before the event reaches that InputMap.
* To insure the auto scroll feature is enabled, set {@link #isAutoScrollOnDragDesired()} to true in your
* process InputMap. If the feature is not desired for that specific drag event, set it to false in the
* process InputMap.
* Note: Due to this "catch-all" nature, all Mouse Drag Events are consumed.
*
* -
* Primary-Button-only Mouse Released Events:
* (
EventPattern.mouseReleased().onlyIf(e -> e.getButton() == MouseButton.PRIMARY && !e.isMiddleButtonDown() && !e.isSecondaryButtonDown())
).
* Do not override. Instead, use {@link #onNewSelectionDragFinished}, {@link #onSelectionDropped}, or see next item.
*
* -
* All other Mouse Released Events:
* You may override other Mouse Released Events using different
* {@link org.fxmisc.wellbehaved.event.EventPattern}s without affecting default behavior only if
* process InputMaps (
* {@link org.fxmisc.wellbehaved.event.template.InputMapTemplate#process(javafx.event.EventType, BiFunction)},
* {@link org.fxmisc.wellbehaved.event.template.InputMapTemplate#process(org.fxmisc.wellbehaved.event.EventPattern, BiFunction)},
* {@link org.fxmisc.wellbehaved.event.InputMap#process(javafx.event.EventType, Function)}, or
* {@link org.fxmisc.wellbehaved.event.InputMap#process(org.fxmisc.wellbehaved.event.EventPattern, Function)}
* ) are used and {@link org.fxmisc.wellbehaved.event.InputHandler.Result#PROCEED} is returned.
* The area has a "catch-all" InputMap that will consume all mouse released events and stop auto scroll if it
* was scrolling. However, this only works if the event is not consumed before the event reaches that InputMap.
* Note: Due to this "catch-all" nature, all Mouse Released Events are consumed.
*
*
*
* CSS, Style Classes, and Pseudo Classes
*
* Refer to the
* RichTextFX CSS Reference Guide
* .
*
*
* Area Actions and Other Operations
*
* To distinguish the actual operations one can do on this area from the boilerplate methods
* within this area (e.g. properties and their getters/setters, etc.), look at the interfaces
* this area implements. Each lists and documents methods that fall under that category.
*
*
* To update multiple portions of the area's underlying document in one call, see {@link #createMultiChange()}.
*
*
* Calculating a Position Within the Area
*
* To calculate a position or index within the area, read through the javadoc of
* {@link org.fxmisc.richtext.model.TwoDimensional} and {@link org.fxmisc.richtext.model.TwoDimensional.Bias}.
* Also, read the difference between "position" and "index" in
* {@link org.fxmisc.richtext.model.StyledDocument#getAbsolutePosition(int, int)}.
*
*
* @see EditableStyledDocument
* @see TwoDimensional
* @see org.fxmisc.richtext.model.TwoDimensional.Bias
* @see VirtualFlow
* @see VirtualizedScrollPane
* @see Caret
* @see Selection
* @see CaretSelectionBind
*
* @param type of style that can be applied to paragraphs (e.g. {@link TextFlow}.
* @param type of segment used in {@link Paragraph}. Can be only text (plain or styled) or
* a type that combines text and other {@link Node}s.
* @param type of style that can be applied to a segment.
*/
public class GenericStyledArea extends Region
implements
TextEditingArea,
EditActions,
ClipboardActions,
NavigationActions,
StyleActions,
UndoActions,
ViewActions,
TwoDimensional,
Virtualized {
/**
* Index range [0, 0).
*/
public static final IndexRange EMPTY_RANGE = new IndexRange(0, 0);
private static final PseudoClass READ_ONLY = PseudoClass.getPseudoClass("readonly");
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");
/* ********************************************************************** *
* *
* 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. *
* *
* ********************************************************************** */
/**
* Text color for highlighted text.
*/
private final StyleableObjectProperty highlightTextFill
= new CustomStyleableProperty<>(Color.WHITE, "highlightTextFill", this, HIGHLIGHT_TEXT_FILL);
// editable property
private final BooleanProperty editable = new SimpleBooleanProperty(this, "editable", true) {
@Override
protected void invalidated() {
((Region) getBean()).pseudoClassStateChanged(READ_ONLY, !get());
}
};
@Override public final BooleanProperty editableProperty() { return editable; }
// Don't remove as FXMLLoader doesn't recognise default methods !
@Override public void setEditable(boolean value) { editable.set(value); }
@Override public boolean isEditable() { return editable.get(); }
// wrapText property
private final BooleanProperty wrapText = new SimpleBooleanProperty(this, "wrapText");
@Override public final BooleanProperty wrapTextProperty() { return wrapText; }
// Don't remove as FXMLLoader doesn't recognise default methods !
@Override public void setWrapText(boolean value) { wrapText.set(value); }
@Override public boolean isWrapText() { return wrapText.get(); }
// undo manager
private UndoManager undoManager;
@Override public UndoManager getUndoManager() { return undoManager; }
/**
* @param undoManager may be null in which case a no op undo manager will be set.
*/
@Override public void setUndoManager(UndoManager undoManager) {
this.undoManager.close();
this.undoManager = undoManager != null ? undoManager : UndoUtils.noOpUndoManager();
}
private Locale textLocale = Locale.getDefault();
/**
* This is used to determine word and sentence breaks while navigating or selecting.
* Override this method if your paragraph or text style accommodates Locales as well.
* @return Locale.getDefault() by default
*/
@Override
public Locale getLocale() { return textLocale; }
public void setLocale( Locale editorLocale ) {
textLocale = editorLocale;
}
private final ObjectProperty mouseOverTextDelay = new SimpleObjectProperty<>(null);
@Override public ObjectProperty mouseOverTextDelayProperty() { return mouseOverTextDelay; }
private final ObjectProperty> paragraphGraphicFactory = new SimpleObjectProperty<>(null);
@Override public ObjectProperty> paragraphGraphicFactoryProperty() { return paragraphGraphicFactory; }
public void recreateParagraphGraphic( int parNdx ) {
ObjectProperty> gProp;
gProp = getCell(parNdx).graphicFactoryProperty();
gProp.unbind();
gProp.bind(paragraphGraphicFactoryProperty());
}
public Node getParagraphGraphic( int parNdx ) {
return getCell(parNdx).getGraphic();
}
/**
* This Node is shown to the user, centered over the area, when the area has no text content.
*
To customize the placeholder's layout override {@link #configurePlaceholder( Node )}
*/
public final void setPlaceholder(Node value) { setPlaceholder(value,Pos.CENTER); }
public void setPlaceholder(Node value, Pos where) { placeHolderProp.set(value); placeHolderPos = Objects.requireNonNull(where); }
private ObjectProperty placeHolderProp = new SimpleObjectProperty<>(this, "placeHolder", null);
public final ObjectProperty placeholderProperty() { return placeHolderProp; }
public final Node getPlaceholder() { return placeHolderProp.get(); }
private Pos placeHolderPos = Pos.CENTER;
private ObjectProperty contextMenu = new SimpleObjectProperty<>(null);
@Override public final ObjectProperty contextMenuObjectProperty() { return contextMenu; }
// Don't remove as FXMLLoader doesn't recognise default methods !
@Override public void setContextMenu(ContextMenu menu) { contextMenu.set(menu); }
@Override public ContextMenu getContextMenu() { return contextMenu.get(); }
protected final boolean isContextMenuPresent() { return contextMenu.get() != null; }
private DoubleProperty contextMenuXOffset = new SimpleDoubleProperty(2);
@Override public final DoubleProperty contextMenuXOffsetProperty() { return contextMenuXOffset; }
// Don't remove as FXMLLoader doesn't recognise default methods !
@Override public void setContextMenuXOffset(double offset) { contextMenuXOffset.set(offset); }
@Override public double getContextMenuXOffset() { return contextMenuXOffset.get(); }
private DoubleProperty contextMenuYOffset = new SimpleDoubleProperty(2);
@Override public final DoubleProperty contextMenuYOffsetProperty() { return contextMenuYOffset; }
// Don't remove as FXMLLoader doesn't recognise default methods !
@Override public void setContextMenuYOffset(double offset) { contextMenuYOffset.set(offset); }
@Override public double getContextMenuYOffset() { return contextMenuYOffset.get(); }
private final BooleanProperty useInitialStyleForInsertion = new SimpleBooleanProperty();
@Override public BooleanProperty useInitialStyleForInsertionProperty() { return useInitialStyleForInsertion; }
private Optional, Codec>>> styleCodecs = Optional.empty();
@Override public void setStyleCodecs(Codec paragraphStyleCodec, Codec> styledSegCodec) {
styleCodecs = Optional.of(t(paragraphStyleCodec, styledSegCodec));
}
@Override public Optional, Codec>>> getStyleCodecs() {
return styleCodecs;
}
@Override public Var estimatedScrollXProperty() { return virtualFlow.estimatedScrollXProperty(); }
@Override public Var estimatedScrollYProperty() { return virtualFlow.estimatedScrollYProperty(); }
private final SubscribeableContentsObsSet caretSet;
private final SubscribeableContentsObsSet> selectionSet;
public final boolean addCaret(CaretNode caret) {
if (caret.getArea() != this) {
throw new IllegalArgumentException(String.format(
"The caret (%s) is associated with a different area (%s), " +
"not this area (%s)", caret, caret.getArea(), this));
}
return caretSet.add(caret);
}
public final boolean removeCaret(CaretNode caret) {
if (caret != caretSelectionBind.getUnderlyingCaret()) {
return caretSet.remove(caret);
} else {
return false;
}
}
public final boolean addSelection(Selection selection) {
if (selection.getArea() != this) {
throw new IllegalArgumentException(String.format(
"The selection (%s) is associated with a different area (%s), " +
"not this area (%s)", selection, selection.getArea(), this));
}
return selectionSet.add(selection);
}
public final boolean removeSelection(Selection selection) {
if (selection != caretSelectionBind.getUnderlyingSelection()) {
return selectionSet.remove(selection);
} else {
return false;
}
}
/* ********************************************************************** *
* *
* Mouse Behavior Hooks *
* *
* Hooks for overriding some of the default mouse behavior *
* *
* ********************************************************************** */
@Override public final EventHandler getOnOutsideSelectionMousePressed() { return onOutsideSelectionMousePressed.get(); }
@Override public final void setOnOutsideSelectionMousePressed(EventHandler handler) { onOutsideSelectionMousePressed.set( handler ); }
@Override public final ObjectProperty> onOutsideSelectionMousePressedProperty() { return onOutsideSelectionMousePressed; }
private final ObjectProperty> onOutsideSelectionMousePressed = new SimpleObjectProperty<>( e -> {
moveTo( hit( e.getX(), e.getY() ).getInsertionIndex(), SelectionPolicy.CLEAR );
});
@Override public final EventHandler getOnInsideSelectionMousePressReleased() { return onInsideSelectionMousePressReleased.get(); }
@Override public final void setOnInsideSelectionMousePressReleased(EventHandler handler) { onInsideSelectionMousePressReleased.set( handler ); }
@Override public final ObjectProperty> onInsideSelectionMousePressReleasedProperty() { return onInsideSelectionMousePressReleased; }
private final ObjectProperty> onInsideSelectionMousePressReleased = new SimpleObjectProperty<>( e -> {
moveTo( hit( e.getX(), e.getY() ).getInsertionIndex(), SelectionPolicy.CLEAR );
});
private final ObjectProperty> onNewSelectionDrag = new SimpleObjectProperty<>(p -> {
CharacterHit hit = hit(p.getX(), p.getY());
moveTo(hit.getInsertionIndex(), SelectionPolicy.ADJUST);
});
@Override public final ObjectProperty> onNewSelectionDragProperty() { return onNewSelectionDrag; }
@Override public final EventHandler getOnNewSelectionDragFinished() { return onNewSelectionDragFinished.get(); }
@Override public final void setOnNewSelectionDragFinished(EventHandler handler) { onNewSelectionDragFinished.set( handler ); }
@Override public final ObjectProperty> onNewSelectionDragFinishedProperty() { return onNewSelectionDragFinished; }
private final ObjectProperty> onNewSelectionDragFinished = new SimpleObjectProperty<>( e -> {
CharacterHit hit = hit(e.getX(), e.getY());
moveTo(hit.getInsertionIndex(), SelectionPolicy.ADJUST);
});
private final ObjectProperty> onSelectionDrag = new SimpleObjectProperty<>(p -> {
CharacterHit hit = hit(p.getX(), p.getY());
displaceCaret(hit.getInsertionIndex());
});
@Override public final ObjectProperty> onSelectionDragProperty() { return onSelectionDrag; }
@Override public final EventHandler getOnSelectionDropped() { return onSelectionDropped.get(); }
@Override public final void setOnSelectionDropped(EventHandler handler) { onSelectionDropped.set( handler ); }
@Override public final ObjectProperty> onSelectionDroppedProperty() { return onSelectionDropped; }
private final ObjectProperty> onSelectionDropped = new SimpleObjectProperty<>( e -> {
moveSelectedText( hit( e.getX(), e.getY() ).getInsertionIndex() );
});
// not a hook, but still plays a part in the default mouse behavior
private final BooleanProperty autoScrollOnDragDesired = new SimpleBooleanProperty(true);
@Override public final BooleanProperty autoScrollOnDragDesiredProperty() { return autoScrollOnDragDesired; }
// Don't remove as FXMLLoader doesn't recognise default methods !
@Override public void setAutoScrollOnDragDesired(boolean val) { autoScrollOnDragDesired.set(val); }
@Override public boolean isAutoScrollOnDragDesired() { return autoScrollOnDragDesired.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 ObservableValue textProperty() { return content.textProperty(); }
// rich text
@Override public final StyledDocument getDocument() { return content; }
private CaretSelectionBind caretSelectionBind;
@Override public final CaretSelectionBind getCaretSelectionBind() { return caretSelectionBind; }
// length
@Override public final ObservableValue lengthProperty() { return content.lengthProperty(); }
// paragraphs
@Override public LiveList> getParagraphs() { return content.getParagraphs(); }
private final SuspendableList> visibleParagraphs;
@Override public final LiveList> getVisibleParagraphs() { return visibleParagraphs; }
// beingUpdated
private final SuspendableNo beingUpdated = new SuspendableNo();
@Override public final SuspendableNo beingUpdatedProperty() { return beingUpdated; }
// total width estimate
@Override public Val totalWidthEstimateProperty() { return virtualFlow.totalWidthEstimateProperty(); }
// total height estimate
@Override public Val totalHeightEstimateProperty() { return virtualFlow.totalHeightEstimateProperty(); }
/* ********************************************************************** *
* *
* Event streams *
* *
* ********************************************************************** */
@Override public EventStream>> multiRichChanges() { return content.multiRichChanges(); }
@Override public EventStream> multiPlainChanges() { return content.multiPlainChanges(); }
// text changes
@Override public final EventStream plainTextChanges() { return content.plainChanges(); }
// rich text changes
@Override public final EventStream> richChanges() { return content.richChanges(); }
private final SuspendableEventStream> viewportDirty;
@Override public final EventStream> viewportDirtyEvents() { return viewportDirty; }
/* ********************************************************************** *
* *
* Private fields *
* *
* ********************************************************************** */
private Subscription subscriptions = () -> {};
private final VirtualFlow, Cell, ParagraphBox>> virtualFlow;
// used for two-level navigation, where on the higher level are
// paragraphs and on the lower level are lines within a paragraph
private final TwoLevelNavigator paragraphLineNavigator;
private boolean paging, followCaretRequested = false;
/* ********************************************************************** *
* *
* Fields necessary for Cloning *
* *
* ********************************************************************** */
private final EditableStyledDocument content;
@Override 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; }
private final BiConsumer applyParagraphStyle;
@Override public final BiConsumer getApplyParagraphStyle() { return applyParagraphStyle; }
// TODO: Currently, only undo/redo respect this flag.
private final boolean preserveStyle;
@Override public final boolean isPreserveStyle() { return preserveStyle; }
/* ********************************************************************** *
* *
* Miscellaneous *
* *
* ********************************************************************** */
private final TextOps segmentOps;
@Override public final TextOps getSegOps() { return segmentOps; }
private final EventStream autoCaretBlinksSteam;
final EventStream autoCaretBlink() { return autoCaretBlinksSteam; }
/* ********************************************************************** *
* *
* Constructors *
* *
* ********************************************************************** */
/**
* Creates a text area with empty text content.
*
* @param initialParagraphStyle style to use in places where no other style is
* specified (yet).
* @param applyParagraphStyle function that, given a {@link TextFlow} node and
* a style, applies the style to the paragraph node. This function is
* used by the default skin to apply style to paragraph nodes.
* @param initialTextStyle style to use in places where no other style is
* specified (yet).
* @param segmentOps The operations which are defined on the text segment objects.
* @param nodeFactory A function which is used to create the JavaFX scene nodes for a
* particular segment.
*/
public GenericStyledArea(@NamedArg("initialParagraphStyle") PS initialParagraphStyle,
@NamedArg("applyParagraphStyle") BiConsumer applyParagraphStyle,
@NamedArg("initialTextStyle") S initialTextStyle,
@NamedArg("segmentOps") TextOps segmentOps,
@NamedArg("nodeFactory") Function, Node> nodeFactory) {
this(initialParagraphStyle, applyParagraphStyle, initialTextStyle, segmentOps, true, nodeFactory);
}
/**
* Same as {@link #GenericStyledArea(Object, BiConsumer, Object, TextOps, Function)} but also allows one
* to specify whether the undo manager should be a plain or rich undo manager via {@code preserveStyle}.
*
* @param initialParagraphStyle style to use in places where no other style is specified (yet).
* @param applyParagraphStyle function that, given a {@link TextFlow} node and
* a style, applies the style to the paragraph node. This function is
* used by the default skin to apply style to paragraph nodes.
* @param initialTextStyle style to use in places where no other style is specified (yet).
* @param segmentOps The operations which are defined on the text segment objects.
* @param preserveStyle whether to use an undo manager that can undo/redo {@link RichTextChange}s or
* {@link PlainTextChange}s
* @param nodeFactory A function which is used to create the JavaFX scene node for a particular segment.
*/
public GenericStyledArea(@NamedArg("initialParagraphStyle") PS initialParagraphStyle,
@NamedArg("applyParagraphStyle") BiConsumer applyParagraphStyle,
@NamedArg("initialTextStyle") S initialTextStyle,
@NamedArg("segmentOps") TextOps segmentOps,
@NamedArg("preserveStyle") boolean preserveStyle,
@NamedArg("nodeFactory") Function, Node> nodeFactory) {
this(initialParagraphStyle, applyParagraphStyle, initialTextStyle,
new GenericEditableStyledDocument<>(initialParagraphStyle, initialTextStyle, segmentOps), segmentOps, preserveStyle, nodeFactory);
}
/**
* The same as {@link #GenericStyledArea(Object, BiConsumer, Object, TextOps, Function)} except that
* this constructor can be used to create another {@code GenericStyledArea} that renders and edits the same
* {@link EditableStyledDocument} or when one wants to use a custom {@link EditableStyledDocument} implementation.
*/
public GenericStyledArea(
@NamedArg("initialParagraphStyle") PS initialParagraphStyle,
@NamedArg("applyParagraphStyle") BiConsumer applyParagraphStyle,
@NamedArg("initialTextStyle") S initialTextStyle,
@NamedArg("document") EditableStyledDocument document,
@NamedArg("segmentOps") TextOps segmentOps,
@NamedArg("nodeFactory") Function, Node> nodeFactory) {
this(initialParagraphStyle, applyParagraphStyle, initialTextStyle, document, segmentOps, true, nodeFactory);
}
/**
* Creates an area with flexibility in all of its options.
*
* @param initialParagraphStyle style to use in places where no other style is specified (yet).
* @param applyParagraphStyle function that, given a {@link TextFlow} node and
* a style, applies the style to the paragraph node. This function is
* used by the default skin to apply style to paragraph nodes.
* @param initialTextStyle style to use in places where no other style is specified (yet).
* @param document the document to render and edit
* @param segmentOps The operations which are defined on the text segment objects.
* @param preserveStyle whether to use an undo manager that can undo/redo {@link RichTextChange}s or
* {@link PlainTextChange}s
* @param nodeFactory A function which is used to create the JavaFX scene node for a particular segment.
*/
public GenericStyledArea(
@NamedArg("initialParagraphStyle") PS initialParagraphStyle,
@NamedArg("applyParagraphStyle") BiConsumer applyParagraphStyle,
@NamedArg("initialTextStyle") S initialTextStyle,
@NamedArg("document") EditableStyledDocument document,
@NamedArg("segmentOps") TextOps segmentOps,
@NamedArg("preserveStyle") boolean preserveStyle,
@NamedArg("nodeFactory") Function, Node> nodeFactory) {
this.initialTextStyle = initialTextStyle;
this.initialParagraphStyle = initialParagraphStyle;
this.preserveStyle = preserveStyle;
this.content = document;
this.applyParagraphStyle = applyParagraphStyle;
this.segmentOps = segmentOps;
undoManager = UndoUtils.defaultUndoManager(this);
// allow tab traversal into area
setFocusTraversable(true);
this.setBackground(new Background(new BackgroundFill(Color.WHITE, CornerRadii.EMPTY, Insets.EMPTY)));
getStyleClass().add("styled-text-area");
getStylesheets().add(StyledTextArea.class.getResource("styled-text-area.css").toExternalForm());
// keeps track of currently used non-empty cells
@SuppressWarnings("unchecked")
ObservableSet> nonEmptyCells = FXCollections.observableSet();
caretSet = new SubscribeableContentsObsSet<>();
manageSubscription(() -> {
List l = new ArrayList<>(caretSet);
caretSet.clear();
l.forEach(CaretNode::dispose);
});
selectionSet = new SubscribeableContentsObsSet<>();
manageSubscription(() -> {
List> l = new ArrayList<>(selectionSet);
selectionSet.clear();
l.forEach(Selection::dispose);
});
// Initialize content
virtualFlow = VirtualFlow.createVertical(
getParagraphs(),
par -> {
Cell, ParagraphBox> cell = createCell(
par,
applyParagraphStyle,
nodeFactory);
nonEmptyCells.add(cell.getNode());
return cell.beforeReset(() -> nonEmptyCells.remove(cell.getNode()))
.afterUpdateItem(p -> nonEmptyCells.add(cell.getNode()));
});
getChildren().add(virtualFlow);
// initialize navigator
IntSupplier cellCount = () -> getParagraphs().size();
IntUnaryOperator cellLength = i -> virtualFlow.getCell(i).getNode().getLineCount();
paragraphLineNavigator = new TwoLevelNavigator(cellCount, cellLength);
viewportDirty = merge(
// no need to check for width & height invalidations as scroll values update when these do
// scale
invalidationsOf(scaleXProperty()),
invalidationsOf(scaleYProperty()),
// scroll
invalidationsOf(estimatedScrollXProperty()),
invalidationsOf(estimatedScrollYProperty())
).suppressible();
autoCaretBlinksSteam = EventStreams.valuesOf(focusedProperty()
.and(editableProperty())
.and(disabledProperty().not())
);
caretSelectionBind = new CaretSelectionBindImpl<>("main-caret", "main-selection",this);
caretSelectionBind.paragraphIndexProperty().addListener( this::skipOverFoldedParagraphs );
caretSet.add(caretSelectionBind.getUnderlyingCaret());
selectionSet.add(caretSelectionBind.getUnderlyingSelection());
visibleParagraphs = LiveList.map(virtualFlow.visibleCells(), c -> c.getNode().getParagraph()).suspendable();
final Suspendable omniSuspendable = Suspendable.combine(
beingUpdated, // must be first, to be the last one to release
visibleParagraphs
);
manageSubscription(omniSuspendable.suspendWhen(content.beingUpdatedProperty()));
// dispatch MouseOverTextEvents when mouseOverTextDelay is not null
EventStreams.valuesOf(mouseOverTextDelayProperty())
.flatMap(delay -> delay != null
? mouseOverTextEvents(nonEmptyCells, delay)
: EventStreams.never())
.subscribe(evt -> Event.fireEvent(this, evt));
new GenericStyledAreaBehavior(this);
// Setup place holder visibility & placement
final Val showPlaceholder = Val.create
(
() -> getLength() == 0 && ! isFocused(),
lengthProperty(), focusedProperty()
);
placeHolderProp.addListener( (ob,ov,newNode) -> displayPlaceHolder( showPlaceholder.getValue(), newNode ) );
showPlaceholder.addListener( (ob,ov,show) -> displayPlaceHolder( show, getPlaceholder() ) );
if ( Platform.isFxApplicationThread() ) initInputMethodHandling();
else Platform.runLater( () -> initInputMethodHandling() );
}
private void initInputMethodHandling()
{
if( Platform.isSupported( ConditionalFeature.INPUT_METHOD ) )
{
setOnInputMethodTextChanged( event -> handleInputMethodEvent(event) );
// Both of these have to be set for input composition to work !
setInputMethodRequests( new InputMethodRequests()
{
@Override public Point2D getTextLocation( int offset ) {
return getCaretBounds()
.or( () -> getCharacterBoundsOnScreen( offset, offset ) )
.map( cb -> new Point2D( cb.getMaxX() - 5, cb.getMaxY() ) )
.orElseGet( () -> new Point2D( 10,10 ) );
}
@Override public int getLocationOffset( int x, int y ) {
return 0;
}
@Override public void cancelLatestCommittedText() {}
@Override public String getSelectedText() {
return getSelectedText();
}
});
}
}
// Start/Length of the text under input method composition
private int imstart;
private int imlength;
protected void handleInputMethodEvent( InputMethodEvent event )
{
if ( isEditable() && !isDisabled() )
{
// remove previous input method text (if any) or selected text
if ( imlength != 0 ) {
selectRange( imstart, imstart + imlength );
}
// Insert committed text
if ( event.getCommitted().length() != 0 ) {
replaceText( getSelection(), event.getCommitted() );
}
// Replace composed text
imstart = getSelection().getStart();
StringBuilder composed = new StringBuilder();
for ( InputMethodTextRun run : event.getComposed() ) {
composed.append( run.getText() );
}
replaceText( getSelection(), composed.toString() );
imlength = composed.length();
if ( imlength != 0 )
{
int pos = imstart;
for ( InputMethodTextRun run : event.getComposed() ) {
int endPos = pos + run.getText().length();
pos = endPos;
}
// Set caret position in composed text
int caretPos = event.getCaretPosition();
if ( caretPos >= 0 && caretPos < imlength ) {
selectRange( imstart + caretPos, imstart + caretPos );
}
}
}
}
private Node placeholder;
private boolean positionPlaceholder = false;
private void displayPlaceHolder( boolean show, Node newNode )
{
if ( placeholder != null && (! show || newNode != placeholder) )
{
placeholder.layoutXProperty().unbind();
placeholder.layoutYProperty().unbind();
getChildren().remove( placeholder );
placeholder = null;
setClip( null );
}
if ( newNode != null && show && newNode != placeholder )
{
configurePlaceholder( newNode );
getChildren().add( newNode );
placeholder = newNode;
}
}
/**
* Override this to customize the placeholder's layout.
*
The default position is centered over the area.
*/
protected void configurePlaceholder( Node placeholder )
{
positionPlaceholder = true;
}
/* ********************************************************************** *
* *
* Queries *
* *
* Queries are parameterized observables. *
* *
* ********************************************************************** */
@Override
public final double getViewportHeight() {
return virtualFlow.getHeight();
}
@Override
public final Optional allParToVisibleParIndex(int allParIndex) {
if (allParIndex < 0) {
throw new IllegalArgumentException("The given paragraph index (allParIndex) cannot be negative but was " + allParIndex);
}
if (allParIndex >= getParagraphs().size()) {
throw new IllegalArgumentException(String.format(
"Paragraphs' last index is [%s] but allParIndex was [%s]",
getParagraphs().size() - 1, allParIndex)
);
}
List, ParagraphBox>> visibleList = virtualFlow.visibleCells();
int firstVisibleParIndex = visibleList.get( 0 ).getNode().getIndex();
int targetIndex = allParIndex - firstVisibleParIndex;
if ( allParIndex >= firstVisibleParIndex && targetIndex < visibleList.size() )
{
if ( visibleList.get( targetIndex ).getNode().getIndex() == allParIndex )
{
return Optional.of( targetIndex );
}
}
return Optional.empty();
}
@Override
public final int visibleParToAllParIndex(int visibleParIndex) {
if (visibleParIndex < 0) {
throw new IllegalArgumentException("Visible paragraph index cannot be negative but was " + visibleParIndex);
}
if (visibleParIndex > 0 && visibleParIndex >= getVisibleParagraphs().size()) {
throw new IllegalArgumentException(String.format(
"Visible paragraphs' last index is [%s] but visibleParIndex was [%s]",
getVisibleParagraphs().size() - 1, visibleParIndex)
);
}
Cell, ParagraphBox> visibleCell = null;
if ( visibleParIndex > 0 ) visibleCell = virtualFlow.visibleCells().get( visibleParIndex );
else visibleCell = virtualFlow.getCellIfVisible( virtualFlow.getFirstVisibleIndex() )
.orElseGet( () -> virtualFlow.visibleCells().get( visibleParIndex ) );
return visibleCell.getNode().getIndex();
}
@Override
public CharacterHit hit(double x, double y) {
// mouse position used, so account for padding
double adjustedX = x - getInsets().getLeft();
double adjustedY = y - getInsets().getTop();
VirtualFlowHit, ParagraphBox>> hit = virtualFlow.hit(adjustedX, adjustedY);
if(hit.isBeforeCells()) {
return CharacterHit.insertionAt(0);
} else if(hit.isAfterCells()) {
return CharacterHit.insertionAt(getLength());
} else {
int parIdx = hit.getCellIndex();
int parOffset = getParagraphOffset(parIdx);
ParagraphBox cell = hit.getCell().getNode();
Point2D cellOffset = hit.getCellOffset();
CharacterHit parHit = cell.hit(cellOffset);
return parHit.offset(parOffset);
}
}
@Override
public final int lineIndex(int paragraphIndex, int columnPosition) {
Cell, ParagraphBox> cell = virtualFlow.getCell(paragraphIndex);
return cell.getNode().getCurrentLineIndex(columnPosition);
}
@Override
public int getParagraphLinesCount(int paragraphIndex) {
return virtualFlow.getCell(paragraphIndex).getNode().getLineCount();
}
@Override
public Optional getCharacterBoundsOnScreen(int from, int to) {
if (from < 0) {
throw new IllegalArgumentException("From is negative: " + from);
}
if (from > to) {
throw new IllegalArgumentException(String.format("From is greater than to. from=%s to=%s", from, to));
}
if (to > getLength()) {
throw new IllegalArgumentException(String.format("To is greater than area's length. length=%s, to=%s", getLength(), to));
}
if (from == to) {
CaretNode cursor = new CaretNode( "", this, from );
int parIdx = offsetToPosition( from, Bias.Forward ).getMajor();
ParagraphBox,?,?> paragrafBox = virtualFlow.getCell( parIdx ).getNode();
paragrafBox.caretsProperty().add( cursor );
Bounds cursorBounds = paragrafBox.getCaretBoundsOnScreen( cursor );
paragrafBox.caretsProperty().remove( cursor );
if ( cursorBounds != null && ! cursorBounds.isEmpty() )
{
Bounds emptyCharBounds = new BoundingBox(
cursorBounds.getMinX()+1, cursorBounds.getMinY()+1,
cursorBounds.getWidth()-1, cursorBounds.getHeight()-2
);
return Optional.of( emptyCharBounds );
}
return Optional.empty();
}
// no bounds exist if range is just a newline character
if (getText(from, to).equals("\n")) {
return Optional.empty();
}
// if 'from' is the newline character at the end of a multi-line paragraph, it returns a Bounds that whose
// minX & minY are the minX and minY of the paragraph itself, not the newline character. So, ignore it.
int realFrom = getText(from, from + 1).equals("\n") ? from + 1 : from;
Position startPosition = offsetToPosition(realFrom, Bias.Forward);
int startRow = startPosition.getMajor();
Position endPosition = startPosition.offsetBy(to - realFrom, Bias.Forward);
int endRow = endPosition.getMajor();
if (startRow == endRow) {
return getRangeBoundsOnScreen(startRow, startPosition.getMinor(), endPosition.getMinor());
} else {
Optional rangeBounds = getRangeBoundsOnScreen(startRow, startPosition.getMinor(),
getParagraph(startRow).length());
for (int i = startRow + 1; i <= endRow; i++) {
Optional nextLineBounds = getRangeBoundsOnScreen(i, 0,
i == endRow
? endPosition.getMinor()
: getParagraph(i).length()
);
if (nextLineBounds.isPresent()) {
if (rangeBounds.isPresent()) {
Bounds lineBounds = nextLineBounds.get();
rangeBounds = rangeBounds.map(b -> {
double minX = Math.min(b.getMinX(), lineBounds.getMinX());
double minY = Math.min(b.getMinY(), lineBounds.getMinY());
double maxX = Math.max(b.getMaxX(), lineBounds.getMaxX());
double maxY = Math.max(b.getMaxY(), lineBounds.getMaxY());
return new BoundingBox(minX, minY, maxX - minX, maxY - minY);
});
} else {
rangeBounds = nextLineBounds;
}
}
}
return rangeBounds;
}
}
@Override
public final String getText(int start, int end) {
return content.getText(start, end);
}
@Override
public String getText(int paragraph) {
return content.getText(paragraph);
}
@Override
public String getText(IndexRange range) {
return content.getText(range);
}
@Override
public StyledDocument subDocument(int start, int end) {
return content.subSequence(start, end);
}
@Override
public StyledDocument subDocument(int paragraphIndex) {
return content.subDocument(paragraphIndex);
}
@Override
public IndexRange getParagraphSelection(Selection selection, int paragraph) {
int startPar = selection.getStartParagraphIndex();
int endPar = selection.getEndParagraphIndex();
if(selection.getLength() == 0 || paragraph < startPar || paragraph > endPar) {
return EMPTY_RANGE;
}
int start = paragraph == startPar ? selection.getStartColumnPosition() : 0;
int end = paragraph == endPar ? selection.getEndColumnPosition() : getParagraphLength(paragraph) + 1;
// force rangeProperty() to be valid
selection.getRange();
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.getStyleAtPosition(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);
}
@Override
public Bounds getVisibleParagraphBoundsOnScreen(int visibleParagraphIndex) {
return getParagraphBoundsOnScreen(virtualFlow.visibleCells().get(visibleParagraphIndex));
}
@Override
public Optional getParagraphBoundsOnScreen(int paragraphIndex) {
return virtualFlow.getCellIfVisible(paragraphIndex).map(this::getParagraphBoundsOnScreen);
}
@Override
public final Optional getCaretBoundsOnScreen(T caret) {
Optional caretBounds;
try { // This is the default mechanism, but sometimes throws just like in followCaret()
caretBounds = virtualFlow.getCellIfVisible(caret.getParagraphIndex())
.map(c -> c.getNode().getCaretBoundsOnScreen(caret));
}
catch ( IllegalArgumentException EX ) {
// This is an alternative mechanism, to address https://github.com/FXMisc/RichTextFX/issues/1048
caretBounds = Optional.ofNullable( caretSelectionBind.getUnderlyingCaret().getLayoutBounds() );
}
return caretBounds;
}
/* ********************************************************************** *
* *
* 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 scrollXToPixel(double pixel) {
suspendVisibleParsWhile(() -> virtualFlow.scrollXToPixel(pixel));
}
@Override
public void scrollYToPixel(double pixel) {
suspendVisibleParsWhile(() -> virtualFlow.scrollYToPixel(pixel));
}
@Override
public void scrollXBy(double deltaX) {
suspendVisibleParsWhile(() -> virtualFlow.scrollXBy(deltaX));
}
@Override
public void scrollYBy(double deltaY) {
suspendVisibleParsWhile(() -> virtualFlow.scrollYBy(deltaY));
}
@Override
public void scrollBy(Point2D deltas) {
suspendVisibleParsWhile(() -> virtualFlow.scrollBy(deltas));
}
@Override
public void showParagraphInViewport(int paragraphIndex) {
suspendVisibleParsWhile(() -> virtualFlow.show(paragraphIndex));
}
@Override
public void showParagraphAtTop(int paragraphIndex) {
suspendVisibleParsWhile(() -> virtualFlow.showAsFirst(paragraphIndex));
}
@Override
public void showParagraphAtBottom(int paragraphIndex) {
suspendVisibleParsWhile(() -> virtualFlow.showAsLast(paragraphIndex));
}
@Override
public void showParagraphRegion(int paragraphIndex, Bounds region) {
suspendVisibleParsWhile(() -> virtualFlow.show(paragraphIndex, region));
}
public void showParagraphAtCenter(int paragraphIndex) {
double offset = Math.floor( getHeight() / 2.0 );
suspendVisibleParsWhile(() -> virtualFlow.showAtOffset(paragraphIndex,offset));
}
@Override
public void requestFollowCaret() {
followCaretRequested = true;
requestLayout();
}
@Override
public void lineStart(SelectionPolicy policy) {
moveTo(getCurrentParagraph(), getCurrentLineStartInParargraph(), policy);
}
@Override
public void lineEnd(SelectionPolicy policy) {
moveTo(getCurrentParagraph(), getCurrentLineEndInParargraph(), policy);
}
public int getCurrentLineStartInParargraph() {
return virtualFlow.getCell(getCurrentParagraph()).getNode().getCurrentLineStartPosition(caretSelectionBind.getUnderlyingCaret());
}
public int getCurrentLineEndInParargraph() {
return virtualFlow.getCell(getCurrentParagraph()).getNode().getCurrentLineEndPosition(caretSelectionBind.getUnderlyingCaret());
}
private double caretPrevY = -1;
private LineSelection lineHighlighter;
private ObjectProperty lineHighlighterFill;
/**
* The default fill is "highlighter" yellow. It can also be styled using CSS with:
* .styled-text-area .line-highlighter { -fx-fill: lime; }
* CSS selectors from Path, Shape, and Node can also be used.
*/
public void setLineHighlighterFill( Paint highlight )
{
if ( lineHighlighterFill != null && highlight != null ) {
lineHighlighterFill.set( highlight );
}
else {
boolean lineHighlightOn = isLineHighlighterOn();
if ( lineHighlightOn ) setLineHighlighterOn( false );
if ( highlight == null ) lineHighlighterFill = null;
else lineHighlighterFill = new SimpleObjectProperty( highlight );
if ( lineHighlightOn ) setLineHighlighterOn( true );
}
}
public boolean isLineHighlighterOn() {
return lineHighlighter != null && selectionSet.contains( lineHighlighter ) ;
}
/**
* Highlights the line that the main caret is on.
* Line highlighting automatically follows the caret.
*/
public void setLineHighlighterOn( boolean show )
{
if ( show )
{
if ( lineHighlighter != null ) return;
lineHighlighter = new LineSelection<>( this, lineHighlighterFill );
Consumer caretListener = b ->
{
if ( lineHighlighter != null && (b.getMinY() != caretPrevY || getCaretColumn() == 1) ) {
if ( getSelection().getLength() != 0 ) lineHighlighter.deselect();
else lineHighlighter.selectCurrentLine();
caretPrevY = b.getMinY();
}
};
caretBoundsProperty().addListener( (ob,ov,nv) -> nv.ifPresent( caretListener ) );
getCaretBounds().ifPresent( caretListener );
selectionProperty().addListener( (ob,ov,nv) ->
{
if ( lineHighlighter != null ) {
if ( nv.getLength() == 0 ) lineHighlighter.selectCurrentLine();
else if ( ov.getLength() == 0 ) lineHighlighter.deselect();
}
});
selectionSet.add( lineHighlighter );
}
else if ( lineHighlighter != null ) {
selectionSet.remove( lineHighlighter );
lineHighlighter.deselect();
lineHighlighter = null;
caretPrevY = -1;
}
}
/**
* Scrolls the text one line UP while maintaining the caret's
* position on screen, so that it is now on the NEXT line.
* Note: If the caret isn't visible then this is a noop.
*/
public void nextLine(SelectionPolicy selectionPolicy) {
scrollLine( +1, selectionPolicy );
}
/**
* Scrolls the text one line DOWN while maintaining the caret's
* position on screen, so that it is now on the PREVIOUS line.
* Note: If the caret isn't visible then this is a noop.
*/
public void prevLine(SelectionPolicy selectionPolicy) {
scrollLine( -1, selectionPolicy );
}
/**
* Scrolls the text (direction*caretHeight) while maintaining the caret's current
* position on screen. Note: If the caret isn't visible then this is a noop.
*/
private void scrollLine(int direction, SelectionPolicy selectionPolicy) {
getCaretBoundsOnScreen( getCaretSelectionBind().getUnderlyingCaret() )
.map( caretBounds -> (caretBounds.getHeight()-2) * direction )
.ifPresent( deltaY -> scrollText(deltaY, selectionPolicy) );
}
@Override
public void prevPage(SelectionPolicy selectionPolicy) {
// Paging up and we're in the first frame then move/select to start.
if ( firstVisibleParToAllParIndex() == 0 ) {
caretSelectionBind.moveTo( 0, selectionPolicy );
}
else page( -1, selectionPolicy );
}
@Override
public void nextPage(SelectionPolicy selectionPolicy) {
// Paging down and we're in the last frame then move/select to end.
if ( lastVisibleParToAllParIndex() == getParagraphs().size()-1 ) {
caretSelectionBind.moveTo( getLength(), selectionPolicy );
}
else page( +1, selectionPolicy );
}
/**
* @param pgCount the number of pages to page up/down.
* Negative numbers for paging up and positive for down.
*/
private void page(int pgCount, SelectionPolicy selectionPolicy)
{
scrollText( pgCount * getViewportHeight(), selectionPolicy );
}
/**
* Scrolls the text by deltaY while maintaining the caret's current position on screen.
*/
private void scrollText(double deltaY, SelectionPolicy selectionPolicy)
{
// Use underlying caret to get the same behaviour as navigating up/down a line where the x position is sticky
Optional cb = caretSelectionBind.getUnderlyingCaret().getCaretBounds();
paging = true; // Prevent scroll from reverting back to the current caret position
suspendVisibleParsWhile( () -> virtualFlow.scrollYBy( deltaY ) );
cb.map( this::screenToLocal ) // Place caret near the same on screen position as before
.map( b -> hit( b.getMinX(), b.getMinY()+b.getHeight()/2.0 ).getInsertionIndex() )
.ifPresent( i -> caretSelectionBind.moveTo( i, selectionPolicy ) );
// Adjust scroll by a few pixels to get the caret at the exact on screen location as before
cb.ifPresent( prev -> getCaretBounds().map( newB -> newB.getMinY() - prev.getMinY() )
.filter( delta -> delta != 0.0 ).ifPresent( delta -> scrollYBy( delta ) ) );
}
@Override
public void displaceCaret(int pos) {
caretSelectionBind.displaceCaret(pos);
}
@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 extends S> styleSpans) {
content.setStyleSpans(from, styleSpans);
}
@Override
public void setStyleSpans(int paragraph, int from, StyleSpans extends S> styleSpans) {
content.setStyleSpans(paragraph, from, styleSpans);
}
@Override
public void setParagraphStyle(int paragraph, PS paragraphStyle) {
content.setParagraphStyle(paragraph, paragraphStyle);
}
/**
* If you want to preset the style to be used for inserted text. Note that useInitialStyleForInsertion overrides this if true.
*/
public final void setTextInsertionStyle( S txtStyle ) { insertionTextStyle = txtStyle; }
public final S getTextInsertionStyle() { return insertionTextStyle; }
private S insertionTextStyle;
@Override
public final S getTextStyleForInsertionAt(int pos) {
if ( insertionTextStyle != null ) {
return insertionTextStyle;
} else if ( useInitialStyleForInsertion.get() ) {
return initialTextStyle;
} else {
return content.getStyleAtPosition(pos);
}
}
private PS insertionParagraphStyle;
/**
* If you want to preset the style to be used. Note that useInitialStyleForInsertion overrides this if true.
*/
public final void setParagraphInsertionStyle( PS paraStyle ) { insertionParagraphStyle = paraStyle; }
public final PS getParagraphInsertionStyle() { return insertionParagraphStyle; }
@Override
public final PS getParagraphStyleForInsertionAt(int pos) {
if ( insertionParagraphStyle != null ) {
return insertionParagraphStyle;
} else if ( useInitialStyleForInsertion.get() ) {
return initialParagraphStyle;
} else {
return content.getParagraphStyleAtPosition(pos);
}
}
@Override
public void replaceText(int start, int end, String text) {
StyledDocument doc = ReadOnlyStyledDocument.fromString(
text, getParagraphStyleForInsertionAt(start), getTextStyleForInsertionAt(start), segmentOps
);
replace(start, end, doc);
}
@Override
public void replace(int start, int end, SEG seg, S style) {
if (style == null) style = getTextStyleForInsertionAt(start);
StyledDocument doc = ReadOnlyStyledDocument.fromSegment(
seg, getParagraphStyleForInsertionAt(start), style, segmentOps
);
replace(start, end, doc);
}
@Override
public void replace(int start, int end, StyledDocument replacement) {
content.replace(start, end, replacement);
int newCaretPos = start + replacement.length();
selectRange(newCaretPos, newCaretPos);
}
void replaceMulti(List> replacements) {
content.replaceMulti(replacements);
// don't update selection as this is not the main method through which the area is updated
// leave that up to the developer using it to determine what to do
}
@Override
public MultiChangeBuilder createMultiChange() {
return new MultiChangeBuilder<>(this);
}
@Override
public MultiChangeBuilder createMultiChange(int initialNumOfChanges) {
return new MultiChangeBuilder<>(this, initialNumOfChanges);
}
/**
* Convenience method to fold (hide/collapse) the currently selected paragraphs,
* into (i.e. excluding) the first paragraph of the range.
*
* @param styleMixin Given a paragraph style PS, return a new PS that will activate folding.
*
* See {@link #fold(int, int, UnaryOperator)} for more info.
*/
protected void foldSelectedParagraphs( UnaryOperator styleMixin )
{
IndexRange range = getSelection();
fold( range.getStart(), range.getEnd(), styleMixin );
}
/**
* Folds (hides/collapses) paragraphs from start to
* end , into (i.e. excluding) the first paragraph of the range.
*
* @param styleMixin Given a paragraph style PS, return a new PS that will activate folding.
*
* See {@link #fold(int, int, UnaryOperator)} for more info.
*/
protected void foldParagraphs( int start, int end, UnaryOperator styleMixin )
{
start = getAbsolutePosition( start, 0 );
end = getAbsolutePosition( end, getParagraphLength( end ) );
fold( start, end, styleMixin );
}
/**
* Folds (hides/collapses) paragraphs from character position startPos
* to endPos , into (i.e. excluding) the first paragraph of the range.
*
* Folding is achieved with the help of paragraph styling, which is applied to the paragraph's
* TextFlow object through the applyParagraphStyle BiConsumer (supplied in the constructor to
* GenericStyledArea). When applyParagraphStyle is to apply fold styling it just needs to set
* the TextFlow's visibility to collapsed for it to be folded. See {@code InlineCssTextArea},
* {@code StyleClassedTextArea}, and {@code RichTextDemo} for different ways of doing this.
* Also read the GitHub Wiki.
*
* The UnaryOperator styleMixin must return a
* different paragraph style Object to what was submitted.
*
* @param styleMixin Given a paragraph style PS, return a new PS that will activate folding.
*/
protected void fold( int startPos, int endPos, UnaryOperator styleMixin )
{
ReadOnlyStyledDocument subDoc;
UnaryOperator> mapper;
subDoc = (ReadOnlyStyledDocument) subDocument( startPos, endPos );
mapper = p -> p.setParagraphStyle( styleMixin.apply( p.getParagraphStyle() ) );
for ( int p = 1; p < subDoc.getParagraphCount(); p++ ) {
subDoc = subDoc.replaceParagraph( p, mapper ).get1();
}
replace( startPos, endPos, subDoc );
recreateParagraphGraphic( offsetToPosition( startPos, Bias.Backward ).getMajor() );
moveTo( startPos );
foldCheck = true;
}
protected boolean foldCheck = false;
private void skipOverFoldedParagraphs( ObservableValue extends Integer> ob, Integer prevParagraph, Integer newParagraph )
{
if ( foldCheck && getCell( newParagraph ).isFolded() )
{
// Prevent Ctrl+A and Ctrl+End breaking when the last paragraph is folded
// github.com/FXMisc/RichTextFX/pull/965#issuecomment-706268116
if ( newParagraph == getParagraphs().size() - 1 ) return;
int skip = (newParagraph - prevParagraph > 0) ? +1 : -1;
int p = newParagraph + skip;
while ( p > 0 && p < getParagraphs().size() ) {
if ( getCell( p ).isFolded() ) p += skip;
else break;
}
if ( p < 0 || p == getParagraphs().size() ) p = prevParagraph;
int col = Math.min( getCaretColumn(), getParagraphLength( p ) );
if ( getSelection().getLength() == 0 ) moveTo( p, col );
else moveTo( p, col, SelectionPolicy.EXTEND );
}
}
/**
* Unfolds paragraphs startingFrom onwards for the currently folded block.
*
* The UnaryOperator styleMixin must return a
* different paragraph style Object to what was submitted.
*
* @param isFolded Given a paragraph style PS check if it's folded.
* @param styleMixin Given a paragraph style PS, return a new PS that excludes fold styling.
*/
protected void unfoldParagraphs( int startingFrom, Predicate isFolded, UnaryOperator styleMixin )
{
LiveList> pList = getParagraphs();
int to = startingFrom;
while ( ++to < pList.size() )
{
if ( ! isFolded.test( pList.get( to ).getParagraphStyle() ) ) break;
}
if ( --to > startingFrom )
{
ReadOnlyStyledDocument subDoc;
UnaryOperator> mapper;
int startPos = getAbsolutePosition( startingFrom, 0 );
int endPos = getAbsolutePosition( to, getParagraphLength( to ) );
subDoc = (ReadOnlyStyledDocument) subDocument( startPos, endPos );
mapper = p -> p.setParagraphStyle( styleMixin.apply( p.getParagraphStyle() ) );
for ( int p = 1; p < subDoc.getParagraphCount(); p++ ) {
subDoc = subDoc.replaceParagraph( p, mapper ).get1();
}
replace( startPos, endPos, subDoc );
moveTo( startingFrom, getParagraphLength( startingFrom ) );
recreateParagraphGraphic( startingFrom );
}
}
/* ********************************************************************** *
* *
* Public API *
* *
* ********************************************************************** */
@Override
public void dispose() {
if (undoManager != null) {
undoManager.close();
}
subscriptions.unsubscribe();
virtualFlow.dispose();
}
/* ********************************************************************** *
* *
* Layout *
* *
* ********************************************************************** */
private BooleanProperty autoHeightProp = new SimpleBooleanProperty();
public BooleanProperty autoHeightProperty() {
return autoHeightProp;
}
public void setAutoHeight( boolean value ) {
autoHeightProp.set( value );
}
public boolean isAutoHeight() {
return autoHeightProp.get();
}
@Override
protected double computePrefHeight( double width )
{
if ( autoHeightProp.get() )
{
if ( getWidth() == 0.0 ) Platform.runLater( () -> requestLayout() );
else
{
double height = 0.0;
Insets in = getInsets();
for ( int p = 0; p < getParagraphs().size(); p++ ) {
height += getCell( p ).getHeight();
}
if ( height > 0.0 ) {
return height + in.getTop() + in.getBottom();
}
}
}
return super.computePrefHeight( width );
}
@Override
protected void layoutChildren() {
Insets ins = getInsets();
visibleParagraphs.suspendWhile(() -> {
virtualFlow.resizeRelocate(
ins.getLeft(), ins.getTop(),
getWidth() - ins.getLeft() - ins.getRight(),
getHeight() - ins.getTop() - ins.getBottom());
if(followCaretRequested && ! paging) {
try (Guard g = viewportDirty.suspend()) {
followCaret();
}
}
followCaretRequested = false;
paging = false;
});
Node holder = placeholder;
if (holder != null && holder.isManaged()) {
if (holder.isResizable()) holder.autosize();
if (positionPlaceholder) Region.positionInArea
(
holder, getLayoutX(), getLayoutY(), getWidth(), getHeight(), getBaselineOffset(),
ins, placeHolderPos.getHpos(), placeHolderPos.getVpos(), isSnapToPixel()
);
}
}
/* ********************************************************************** *
* *
* Package-Private methods *
* *
* ********************************************************************** */
/**
* Returns the current line as a two-level index.
* The major number is the paragraph index, the minor
* number is the line number within the paragraph.
*
* This method has a side-effect of bringing the current
* paragraph to the viewport if it is not already visible.
*/
TwoDimensional.Position currentLine() {
int parIdx = getCurrentParagraph();
Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx);
int lineIdx = cell.getNode().getCurrentLineIndex(caretSelectionBind.getUnderlyingCaret());
return paragraphLineNavigator.position(parIdx, lineIdx);
}
void showCaretAtBottom() {
int parIdx = getCurrentParagraph();
Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx);
Bounds caretBounds = cell.getNode().getCaretBounds(caretSelectionBind.getUnderlyingCaret());
double y = caretBounds.getMaxY();
suspendVisibleParsWhile(() -> virtualFlow.showAtOffset(parIdx, getViewportHeight() - y));
}
void showCaretAtTop() {
int parIdx = getCurrentParagraph();
Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx);
Bounds caretBounds = cell.getNode().getCaretBounds(caretSelectionBind.getUnderlyingCaret());
double y = caretBounds.getMinY();
suspendVisibleParsWhile(() -> virtualFlow.showAtOffset(parIdx, -y));
}
/**
* Returns x coordinate of the caret in the current paragraph.
*/
final ParagraphBox.CaretOffsetX getCaretOffsetX(CaretNode caret) {
return getCell(caret.getParagraphIndex()).getCaretOffsetX(caret);
}
CharacterHit hit(ParagraphBox.CaretOffsetX x, TwoDimensional.Position targetLine) {
int parIdx = targetLine.getMajor();
ParagraphBox cell = virtualFlow.getCell(parIdx).getNode();
CharacterHit parHit = cell.hitTextLine(x, targetLine.getMinor());
return parHit.offset(getParagraphOffset(parIdx));
}
CharacterHit hit(ParagraphBox.CaretOffsetX x, double y) {
// don't account for padding here since height of virtualFlow is used, not area + potential padding
VirtualFlowHit, ParagraphBox>> hit = virtualFlow.hit(0.0, y);
if(hit.isBeforeCells()) {
return CharacterHit.insertionAt(0);
} else if(hit.isAfterCells()) {
return CharacterHit.insertionAt(getLength());
} else {
int parIdx = hit.getCellIndex();
int parOffset = getParagraphOffset(parIdx);
ParagraphBox cell = hit.getCell().getNode();
Point2D cellOffset = hit.getCellOffset();
CharacterHit parHit = cell.hitText(x, cellOffset.getY());
return parHit.offset(parOffset);
}
}
final Optional getSelectionBoundsOnScreen(Selection selection) {
if (selection.getLength() == 0) {
return Optional.empty();
}
List bounds = new ArrayList<>(selection.getParagraphSpan());
for (int i = selection.getStartParagraphIndex(); i <= selection.getEndParagraphIndex(); i++) {
virtualFlow.getCellIfVisible(i)
.ifPresent(c -> c.getNode()
.getSelectionBoundsOnScreen(selection)
.ifPresent(bounds::add)
);
}
if(bounds.size() == 0) {
return Optional.empty();
}
double minX = bounds.stream().mapToDouble(Bounds::getMinX).min().getAsDouble();
double maxX = bounds.stream().mapToDouble(Bounds::getMaxX).max().getAsDouble();
double minY = bounds.stream().mapToDouble(Bounds::getMinY).min().getAsDouble();
double maxY = bounds.stream().mapToDouble(Bounds::getMaxY).max().getAsDouble();
return Optional.of(new BoundingBox(minX, minY, maxX-minX, maxY-minY));
}
void clearTargetCaretOffset() {
caretSelectionBind.clearTargetOffset();
}
ParagraphBox.CaretOffsetX getTargetCaretOffset() {
return caretSelectionBind.getTargetOffset();
}
/* ********************************************************************** *
* *
* Private methods *
* *
* ********************************************************************** */
private Cell, ParagraphBox> createCell(
Paragraph paragraph,
BiConsumer applyParagraphStyle,
Function, Node> nodeFactory) {
ParagraphBox box = new ParagraphBox<>(paragraph, applyParagraphStyle, nodeFactory);
box.highlightTextFillProperty().bind(highlightTextFill);
box.wrapTextProperty().bind(wrapTextProperty());
box.graphicFactoryProperty().bind(paragraphGraphicFactoryProperty());
box.graphicOffset.bind(virtualFlow.breadthOffsetProperty());
EventStream boxIndexValues = box.indexProperty().values().filter(i -> i != -1);
Subscription firstParPseudoClass = boxIndexValues.subscribe(idx -> box.pseudoClassStateChanged(FIRST_PAR, idx == 0));
Subscription lastParPseudoClass = EventStreams.combine(
boxIndexValues,
getParagraphs().sizeProperty().values()
).subscribe(in -> in.exec((i, n) -> box.pseudoClassStateChanged(LAST_PAR, i == n-1)));
// set up caret
Function subscribeToCaret = caret -> {
EventStream caretIndexStream = EventStreams.nonNullValuesOf(caret.paragraphIndexProperty());
// a new event stream needs to be created for each caret added, so that it will immediately
// fire the box's current index value as an event, thereby running the code in the subscribe block
// Reusing boxIndexValues will not fire its most recent event, leading to a caret not being added
// Thus, we'll call the new event stream "fresh" box index values
EventStream freshBoxIndexValues = box.indexProperty().values().filter(i -> i != -1);
return EventStreams.combine(caretIndexStream, freshBoxIndexValues)
.subscribe(t -> {
int caretParagraphIndex = t.get1();
int boxIndex = t.get2();
if (caretParagraphIndex == boxIndex) {
box.caretsProperty().add(caret);
} else {
box.caretsProperty().remove(caret);
}
});
};
Subscription caretSubscription = caretSet.addSubscriber(subscribeToCaret);
// TODO: how should 'hasCaret' be handled now?
Subscription hasCaretPseudoClass = EventStreams
.combine(boxIndexValues, Val.wrap(currentParagraphProperty()).values())
// box index (t1) == caret paragraph index (t2)
.map(t -> t.get1().equals(t.get2()))
.subscribe(value -> box.pseudoClassStateChanged(HAS_CARET, value));
Function, Subscription> subscribeToSelection = selection -> {
EventStream startParagraphValues = EventStreams.nonNullValuesOf(selection.startParagraphIndexProperty());
EventStream endParagraphValues = EventStreams.nonNullValuesOf(selection.endParagraphIndexProperty());
// see comment in caret section about why a new box index EventStream is needed
EventStream freshBoxIndexValues = box.indexProperty().values().filter(i -> i != -1);
return EventStreams.combine(startParagraphValues, endParagraphValues, freshBoxIndexValues)
.subscribe(t -> {
int startPar = t.get1();
int endPar = t.get2();
int boxIndex = t.get3();
if (startPar <= boxIndex && boxIndex <= endPar) {
// So that we don't add multiple paths for the same selection,
// which leads to not removing the additional paths when selection is removed,
// this is a `Map#putIfAbsent(Key, Value)` implementation that creates the path lazily
SelectionPath p = box.selectionsProperty().get(selection);
if (p == null) {
// create & configure path
Val range = Val.create(
() -> box.getIndex() != -1
? getParagraphSelection(selection, box.getIndex())
: EMPTY_RANGE,
selection.rangeProperty()
);
SelectionPath path = new SelectionPath(range);
path.getStyleClass().add( selection.getSelectionName() );
selection.configureSelectionPath(path);
box.selectionsProperty().put(selection, path);
}
} else {
box.selectionsProperty().remove(selection);
}
});
};
Subscription selectionSubscription = selectionSet.addSubscriber(subscribeToSelection);
return new Cell, ParagraphBox>() {
@Override
public ParagraphBox getNode() {
return box;
}
@Override
public void updateIndex(int index) {
box.setIndex(index);
}
@Override
public void dispose() {
box.highlightTextFillProperty().unbind();
box.wrapTextProperty().unbind();
box.graphicFactoryProperty().unbind();
box.graphicOffset.unbind();
box.dispose();
firstParPseudoClass.unsubscribe();
lastParPseudoClass.unsubscribe();
caretSubscription.unsubscribe();
hasCaretPseudoClass.unsubscribe();
selectionSubscription.unsubscribe();
}
};
}
/** Assumes this method is called within a {@link #suspendVisibleParsWhile(Runnable)} block */
private void followCaret() {
int parIdx = getCurrentParagraph();
ParagraphBox paragrafBox = virtualFlow.getCell( parIdx ).getNode();
Bounds caretBounds;
try {
// This is the default mechanism, but is also needed for https://github.com/FXMisc/RichTextFX/issues/1017
caretBounds = paragrafBox.getCaretBounds( caretSelectionBind.getUnderlyingCaret() );
}
catch ( IllegalArgumentException EX ) {
// This is an alternative mechanism, to address https://github.com/FXMisc/RichTextFX/issues/939
caretBounds = caretSelectionBind.getUnderlyingCaret().getLayoutBounds();
}
double graphicWidth = paragrafBox.getGraphicPrefWidth();
Bounds region = extendLeft(caretBounds, graphicWidth);
double scrollX = virtualFlow.getEstimatedScrollX();
// Ordinarily when a caret ends a selection in the target paragraph and scrolling left is required to follow
// the caret then the selection won't be visible. So here we check for this scenario and adjust if needed.
if ( ! isWrapText() && scrollX > 0.0 && getParagraphSelection( parIdx ).getLength() > 0 )
{
CaretNode selectionStart = new CaretNode( "", this, getSelection().getStart() );
paragrafBox.caretsProperty().add( selectionStart );
Bounds startBounds = paragrafBox.getCaretBounds( selectionStart );
paragrafBox.caretsProperty().remove( selectionStart );
if ( startBounds.getMinX() - graphicWidth < scrollX ) {
region = extendLeft( startBounds, graphicWidth );
}
}
// Addresses https://github.com/FXMisc/RichTextFX/issues/937#issuecomment-674319602
if ( parIdx == getParagraphs().size()-1 && paragrafBox.getLineCount() == 1 )
{
region = new BoundingBox // Correcting the region's height
(
region.getMinX(), region.getMinY(), region.getWidth(),
paragrafBox.getLayoutBounds().getHeight()
);
}
virtualFlow.show(parIdx, region);
}
private ParagraphBox getCell(int index) {
return virtualFlow.getCell(index).getNode();
}
private EventStream mouseOverTextEvents(ObservableSet> cells, Duration delay) {
return merge(cells, c -> c.stationaryIndices(delay).map(e -> e.unify(
l -> l.map((pos, charIdx) -> MouseOverTextEvent.beginAt(c.localToScreen(pos), getParagraphOffset(c.getIndex()) + charIdx)),
r -> MouseOverTextEvent.end())));
}
private int getParagraphOffset(int parIdx) {
return position(parIdx, 0).toOffset();
}
private Bounds getParagraphBoundsOnScreen(Cell, ParagraphBox> cell) {
Bounds nodeLocal = cell.getNode().getBoundsInLocal();
Bounds nodeScreen = cell.getNode().localToScreen(nodeLocal);
Bounds areaLocal = getBoundsInLocal();
Bounds areaScreen = localToScreen(areaLocal);
// use area's minX if scrolled right and paragraph's left is not visible
double minX = nodeScreen.getMinX() < areaScreen.getMinX()
? areaScreen.getMinX()
: nodeScreen.getMinX();
// use area's minY if scrolled down vertically and paragraph's top is not visible
double minY = nodeScreen.getMinY() < areaScreen.getMinY()
? areaScreen.getMinY()
: nodeScreen.getMinY();
// use area's width whether paragraph spans outside of it or not
// so that short or long paragraph takes up the entire space
double width = areaScreen.getWidth();
// use area's maxY if scrolled up vertically and paragraph's bottom is not visible
double maxY = nodeScreen.getMaxY() < areaScreen.getMaxY()
? nodeScreen.getMaxY()
: areaScreen.getMaxY();
return new BoundingBox(minX, minY, width, maxY - minY);
}
private Optional getRangeBoundsOnScreen(int paragraphIndex, int from, int to) {
return virtualFlow.getCellIfVisible(paragraphIndex)
.map(c -> c.getNode().getRangeBoundsOnScreen(from, to));
}
private void manageSubscription(Subscription subscription) {
subscriptions = subscriptions.and(subscription);
}
private static Bounds extendLeft(Bounds b, double w) {
if(w == 0) {
return b;
} else {
return new BoundingBox(
b.getMinX() - w, b.getMinY(),
b.getWidth() + w, b.getHeight());
}
}
private void suspendVisibleParsWhile(Runnable runnable) {
Suspendable.combine(beingUpdated, visibleParagraphs).suspendWhile(runnable);
}
/* ********************************************************************** *
* *
* CSS *
* *
* ********************************************************************** */
private static final CssMetaData, Paint> HIGHLIGHT_TEXT_FILL = new CustomCssMetaData<>(
"-fx-highlight-text-fill", StyleConverter.getPaintConverter(), Color.WHITE, s -> s.highlightTextFill
);
private static final List> CSS_META_DATA_LIST;
static {
List> styleables = new ArrayList<>(Region.getClassCssMetaData());
styleables.add(HIGHLIGHT_TEXT_FILL);
CSS_META_DATA_LIST = Collections.unmodifiableList(styleables);
}
@Override
public List> getCssMetaData() {
return CSS_META_DATA_LIST;
}
public static List> getClassCssMetaData() {
return CSS_META_DATA_LIST;
}
}
| | |