
org.fxmisc.richtext.StyledTextArea Maven / Gradle / Ivy
package org.fxmisc.richtext;
import static javafx.util.Duration.*;
import static org.fxmisc.richtext.PopupAlignment.*;
import static org.reactfx.EventStreams.*;
import static org.reactfx.util.Tuples.*;
import java.time.Duration;
import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.IntConsumer;
import java.util.function.IntFunction;
import java.util.function.IntSupplier;
import java.util.function.IntUnaryOperator;
import java.util.function.UnaryOperator;
import java.util.stream.Stream;
import javafx.beans.binding.Binding;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableBooleanValue;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableSet;
import javafx.css.PseudoClass;
import javafx.css.StyleableObjectProperty;
import javafx.event.Event;
import javafx.geometry.BoundingBox;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.control.IndexRange;
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.Text;
import javafx.scene.text.TextFlow;
import javafx.stage.PopupWindow;
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.CssProperties.EditableProperty;
import org.fxmisc.richtext.model.Codec;
import org.fxmisc.richtext.model.EditActions;
import org.fxmisc.richtext.model.EditableStyledDocument;
import org.fxmisc.richtext.model.NavigationActions;
import org.fxmisc.richtext.model.Paragraph;
import org.fxmisc.richtext.model.PlainTextChange;
import org.fxmisc.richtext.model.RichTextChange;
import org.fxmisc.richtext.model.SimpleEditableStyledDocument;
import org.fxmisc.richtext.model.StyleSpans;
import org.fxmisc.richtext.model.StyledDocument;
import org.fxmisc.richtext.model.StyledTextAreaModel;
import org.fxmisc.richtext.model.TextEditingArea;
import org.fxmisc.richtext.model.TwoDimensional;
import org.fxmisc.richtext.model.TwoLevelNavigator;
import org.fxmisc.richtext.model.UndoActions;
import org.fxmisc.undo.UndoManager;
import org.fxmisc.undo.UndoManagerFactory;
import org.reactfx.EventStream;
import org.reactfx.EventStreams;
import org.reactfx.StateMachine;
import org.reactfx.Subscription;
import org.reactfx.collection.LiveList;
import org.reactfx.util.Tuple2;
import org.reactfx.value.Val;
import org.reactfx.value.Var;
/**
* Text editing control. Accepts user input (keyboard, mouse) and
* provides API to assign style to text ranges. It is suitable for
* syntax highlighting and rich-text editors.
*
* Subclassing is allowed to define the type of style, e.g. inline
* style or style classes.
*
* Note: Scroll bars no longer 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,
*
* {@code
* // shows area without scroll bars
* InlineCssTextArea area = new InlineCssTextArea();
*
* // add scroll bars that will display as needed
* VirtualizedScrollPane vsPane = new VirtualizedScrollPane(area);
*
* Parent parent = //;
* parent.getChildren().add(vsPane)
* }
*
*
* Overriding keyboard shortcuts
*
* {@code StyledTextArea} uses {@code KEY_TYPED} handler to handle ordinary
* character input and {@code KEY_PRESSED} handler 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. This is how to bind {@code Ctrl+S} to the {@code save()}
* operation:
*
* {@code
* 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;
*
* Nodes.addInputMap(area, consume(keyPressed(S, CONTROL_DOWN), event -> save()));
* }
*
*
* @param type of style that can be applied to text.
*/
public class StyledTextArea extends Region
implements
TextEditingArea,
EditActions,
ClipboardActions,
NavigationActions,
UndoActions,
TwoDimensional,
Virtualized {
/**
* Index range [0, 0).
*/
public static final IndexRange EMPTY_RANGE = new IndexRange(0, 0);
private static final PseudoClass HAS_CARET = PseudoClass.getPseudoClass("has-caret");
private static final PseudoClass FIRST_PAR = PseudoClass.getPseudoClass("first-paragraph");
private static final PseudoClass LAST_PAR = PseudoClass.getPseudoClass("last-paragraph");
/* ********************************************************************** *
* *
* 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. *
* *
* ********************************************************************** */
/**
* Background fill for highlighted text.
*/
private final StyleableObjectProperty highlightFill
= new CssProperties.HighlightFillProperty(this, Color.DODGERBLUE);
/**
* Text color for highlighted text.
*/
private final StyleableObjectProperty highlightTextFill
= new CssProperties.HighlightTextFillProperty(this, Color.WHITE);
/**
* Controls the blink rate of the caret, when one is displayed. Setting
* the duration to zero disables blinking.
*/
private final StyleableObjectProperty caretBlinkRate
= new CssProperties.CaretBlinkRateProperty(this, javafx.util.Duration.millis(500));
// editable property
/**
* Indicates whether this text area can be edited by the user.
* Note that this property doesn't affect editing through the API.
*/
private final BooleanProperty editable = new EditableProperty<>(this);
public final boolean isEditable() { return editable.get(); }
public final void setEditable(boolean value) { editable.set(value); }
public final BooleanProperty editableProperty() { return editable; }
// wrapText property
/**
* When a run of text exceeds the width of the text region,
* then this property indicates whether the text should wrap
* onto another line.
*/
private final BooleanProperty wrapText = new SimpleBooleanProperty(this, "wrapText");
public final boolean isWrapText() { return wrapText.get(); }
public final void setWrapText(boolean value) { wrapText.set(value); }
public final BooleanProperty wrapTextProperty() { return wrapText; }
// showCaret property
/**
* Indicates when this text area should display a caret.
*/
private final Var showCaret = Var.newSimpleVar(CaretVisibility.AUTO);
public final CaretVisibility getShowCaret() { return showCaret.getValue(); }
public final void setShowCaret(CaretVisibility value) { showCaret.setValue(value); }
public final Var showCaretProperty() { return showCaret; }
public static enum CaretVisibility {
/** Caret is displayed. */
ON,
/** Caret is displayed when area is focused, enabled, and editable. */
AUTO,
/** Caret is not displayed. */
OFF
}
// undo manager
@Override public UndoManager getUndoManager() { return model.getUndoManager(); }
@Override public void setUndoManager(UndoManagerFactory undoManagerFactory) {
model.setUndoManager(undoManagerFactory);
}
/**
* Popup window that will be positioned by this text area relative to the
* caret or selection. Use {@link #popupAlignmentProperty()} to specify
* how the popup should be positioned relative to the caret or selection.
* Use {@link #popupAnchorOffsetProperty()} or
* {@link #popupAnchorAdjustmentProperty()} to further adjust the position.
*/
private final ObjectProperty popupWindow = new SimpleObjectProperty<>();
public void setPopupWindow(PopupWindow popup) { popupWindow.set(popup); }
public PopupWindow getPopupWindow() { return popupWindow.get(); }
public ObjectProperty popupWindowProperty() { return popupWindow; }
/** @deprecated Use {@link #setPopupWindow(PopupWindow)}. */
@Deprecated
public void setPopupAtCaret(PopupWindow popup) { popupWindow.set(popup); }
/** @deprecated Use {@link #getPopupWindow()}. */
@Deprecated
public PopupWindow getPopupAtCaret() { return popupWindow.get(); }
/** @deprecated Use {@link #popupWindowProperty()}. */
@Deprecated
public ObjectProperty popupAtCaretProperty() { return popupWindow; }
/**
* Specifies further offset (in pixels) of the popup window from the
* position specified by {@link #popupAlignmentProperty()}.
*
* If {@link #popupAnchorAdjustmentProperty()} is also specified, then
* it overrides the offset set by this property.
*/
private final ObjectProperty popupAnchorOffset = new SimpleObjectProperty<>();
public void setPopupAnchorOffset(Point2D offset) { popupAnchorOffset.set(offset); }
public Point2D getPopupAnchorOffset() { return popupAnchorOffset.get(); }
public ObjectProperty popupAnchorOffsetProperty() { return popupAnchorOffset; }
/**
* Specifies how to adjust the popup window's anchor point. The given
* operator is invoked with the screen position calculated according to
* {@link #popupAlignmentProperty()} and should return a new screen
* position. This position will be used as the popup window's anchor point.
*
* Setting this property overrides {@link #popupAnchorOffsetProperty()}.
*/
private final ObjectProperty> popupAnchorAdjustment = new SimpleObjectProperty<>();
public void setPopupAnchorAdjustment(UnaryOperator f) { popupAnchorAdjustment.set(f); }
public UnaryOperator getPopupAnchorAdjustment() { return popupAnchorAdjustment.get(); }
public ObjectProperty> popupAnchorAdjustmentProperty() { return popupAnchorAdjustment; }
/**
* Defines where the popup window given in {@link #popupWindowProperty()}
* is anchored, i.e. where its anchor point is positioned. This position
* can further be adjusted by {@link #popupAnchorOffsetProperty()} or
* {@link #popupAnchorAdjustmentProperty()}.
*/
private final ObjectProperty popupAlignment = new SimpleObjectProperty<>(CARET_TOP);
public void setPopupAlignment(PopupAlignment pos) { popupAlignment.set(pos); }
public PopupAlignment getPopupAlignment() { return popupAlignment.get(); }
public ObjectProperty popupAlignmentProperty() { return popupAlignment; }
/**
* Defines how long the mouse has to stay still over the text before a
* {@link MouseOverTextEvent} of type {@code MOUSE_OVER_TEXT_BEGIN} is
* fired on this text area. When set to {@code null}, no
* {@code MouseOverTextEvent}s are fired on this text area.
*
* Default value is {@code null}.
*/
private final ObjectProperty mouseOverTextDelay = new SimpleObjectProperty<>(null);
public void setMouseOverTextDelay(Duration delay) { mouseOverTextDelay.set(delay); }
public Duration getMouseOverTextDelay() { return mouseOverTextDelay.get(); }
public ObjectProperty mouseOverTextDelayProperty() { return mouseOverTextDelay; }
/**
* Defines how to handle an event in which the user has selected some text, dragged it to a
* new location within the area, and released the mouse at some character {@code index}
* within the area.
*
* By default, this will relocate the selected text to the character index where the mouse
* was released. To override it, use {@link #setOnSelectionDrop(IntConsumer)}.
*/
private Property onSelectionDrop = new SimpleObjectProperty<>(this::moveSelectedText);
public final void setOnSelectionDrop(IntConsumer consumer) { onSelectionDrop.setValue(consumer); }
public final IntConsumer getOnSelectionDrop() { return onSelectionDrop.getValue(); }
private final ObjectProperty> paragraphGraphicFactory = new SimpleObjectProperty<>(null);
public void setParagraphGraphicFactory(IntFunction extends Node> factory) { paragraphGraphicFactory.set(factory); }
public IntFunction extends Node> getParagraphGraphicFactory() { return paragraphGraphicFactory.get(); }
public ObjectProperty> paragraphGraphicFactoryProperty() { return paragraphGraphicFactory; }
/**
* Indicates whether the initial style should also be used for plain text
* inserted into this text area. When {@code false}, the style immediately
* preceding the insertion position is used. Default value is {@code false}.
*/
public BooleanProperty useInitialStyleForInsertionProperty() { return model.useInitialStyleForInsertionProperty(); }
public void setUseInitialStyleForInsertion(boolean value) { model.setUseInitialStyleForInsertion(value); }
public boolean getUseInitialStyleForInsertion() { return model.getUseInitialStyleForInsertion(); }
private Optional, Codec>> styleCodecs = Optional.empty();
/**
* Sets codecs to encode/decode style information to/from binary format.
* Providing codecs enables clipboard actions to retain the style information.
*/
public void setStyleCodecs(Codec paragraphStyleCodec, Codec textStyleCodec) {
styleCodecs = Optional.of(t(paragraphStyleCodec, textStyleCodec));
}
@Override
public Optional, Codec>> getStyleCodecs() {
return styleCodecs;
}
/**
* The estimated scrollX value. This can be set in order to scroll the content.
* Value is only accurate when area does not wrap lines and uses the same font size
* throughout the entire area.
*/
@Override
public Var estimatedScrollXProperty() { return virtualFlow.estimatedScrollXProperty(); }
public double getEstimatedScrollX() { return virtualFlow.estimatedScrollXProperty().getValue(); }
public void setEstimatedScrollX(double value) { virtualFlow.estimatedScrollXProperty().setValue(value); }
/**
* The estimated scrollY value. This can be set in order to scroll the content.
* Value is only accurate when area does not wrap lines and uses the same font size
* throughout the entire area.
*/
@Override
public Var estimatedScrollYProperty() { return virtualFlow.estimatedScrollYProperty(); }
public double getEstimatedScrollY() { return virtualFlow.estimatedScrollYProperty().getValue(); }
public void setEstimatedScrollY(double value) { virtualFlow.estimatedScrollYProperty().setValue(value); }
/* ********************************************************************** *
* *
* 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 model.getText(); }
@Override public final ObservableValue textProperty() { return model.textProperty(); }
// rich text
@Override public final StyledDocument getDocument() { return model.getDocument(); }
// length
@Override public final int getLength() { return model.getLength(); }
@Override public final ObservableValue lengthProperty() { return model.lengthProperty(); }
// caret position
@Override public final int getCaretPosition() { return model.getCaretPosition(); }
@Override public final ObservableValue caretPositionProperty() { return model.caretPositionProperty(); }
// selection anchor
@Override public final int getAnchor() { return model.getAnchor(); }
@Override public final ObservableValue anchorProperty() { return model.anchorProperty(); }
// selection
@Override public final IndexRange getSelection() { return model.getSelection(); }
@Override public final ObservableValue selectionProperty() { return model.selectionProperty(); }
// selected text
@Override public final String getSelectedText() { return model.getSelectedText(); }
@Override public final ObservableValue selectedTextProperty() { return model.selectedTextProperty(); }
// current paragraph index
@Override public final int getCurrentParagraph() { return model.getCurrentParagraph(); }
@Override public final ObservableValue currentParagraphProperty() { return model.currentParagraphProperty(); }
// caret column
@Override public final int getCaretColumn() { return model.getCaretColumn(); }
@Override public final ObservableValue caretColumnProperty() { return model.caretColumnProperty(); }
// paragraphs
@Override public LiveList> getParagraphs() { return model.getParagraphs(); }
// beingUpdated
public ObservableBooleanValue beingUpdatedProperty() { return model.beingUpdatedProperty(); }
public boolean isBeingUpdated() { return model.isBeingUpdated(); }
// total width estimate
/**
* The estimated width of the entire document. Accurate when area does not wrap lines and
* uses the same font size throughout the entire area. Value is only supposed to be set by
* the skin, not the user.
*/
@Override
public Val totalWidthEstimateProperty() { return virtualFlow.totalWidthEstimateProperty(); }
public double getTotalWidthEstimate() { return virtualFlow.totalWidthEstimateProperty().getValue(); }
// total height estimate
/**
* The estimated height of the entire document. Accurate when area does not wrap lines and
* uses the same font size throughout the entire area. Value is only supposed to be set by
* the skin, not the user.
*/
@Override
public Val totalHeightEstimateProperty() { return virtualFlow.totalHeightEstimateProperty(); }
public double getTotalHeightEstimate() { return virtualFlow.totalHeightEstimateProperty().getValue(); }
/* ********************************************************************** *
* *
* Event streams *
* *
* ********************************************************************** */
// text changes
@Override public final EventStream plainTextChanges() { return model.plainTextChanges(); }
// rich text changes
@Override public final EventStream> richChanges() { return model.richChanges(); }
/* ********************************************************************** *
* *
* Private fields *
* *
* ********************************************************************** */
private final StyledTextAreaBehavior behavior;
private Subscription subscriptions = () -> {};
private final Binding caretVisible;
private final Val> _popupAnchorAdjustment;
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 navigator;
private boolean followCaretRequested = false;
/**
* model
*/
private final StyledTextAreaModel model;
/**
* @return this area's {@link StyledTextAreaModel}
*/
final StyledTextAreaModel getModel() {
return model;
}
/* ********************************************************************** *
* *
* Fields necessary for Cloning *
* *
* ********************************************************************** */
/**
* The underlying document that can be displayed by multiple {@code StyledTextArea}s.
*/
public final EditableStyledDocument getContent() { return model.getContent(); }
/**
* Style used by default when no other style is provided.
*/
public final S getInitialTextStyle() { return model.getInitialTextStyle(); }
/**
* Style used by default when no other style is provided.
*/
public final PS getInitialParagraphStyle() { return model.getInitialParagraphStyle(); }
/**
* Style applicator used by the default skin.
*/
private final BiConsumer super TextExt, S> applyStyle;
public final BiConsumer super TextExt, S> getApplyStyle() { return applyStyle; }
/**
* Style applicator used by the default skin.
*/
private final BiConsumer applyParagraphStyle;
public final BiConsumer getApplyParagraphStyle() { return applyParagraphStyle; }
/**
* Indicates whether style should be preserved on undo/redo,
* copy/paste and text move.
* TODO: Currently, only undo/redo respect this flag.
*/
public final boolean isPreserveStyle() { return model.isPreserveStyle(); }
/* ********************************************************************** *
* *
* Constructors *
* *
* ********************************************************************** */
/**
* Creates a text area with empty text content.
*
* @param initialTextStyle style to use in places where no other style is
* specified (yet).
* @param applyStyle function that, given a {@link Text} node and
* a style, applies the style to the text node. This function is
* used by the default skin to apply style to text nodes.
* @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.
*/
public StyledTextArea(PS initialParagraphStyle, BiConsumer applyParagraphStyle,
S initialTextStyle, BiConsumer super TextExt, S> applyStyle
) {
this(initialParagraphStyle, applyParagraphStyle, initialTextStyle, applyStyle, true);
}
public StyledTextArea(PS initialParagraphStyle, BiConsumer applyParagraphStyle,
S initialTextStyle, BiConsumer super TextExt, S> applyStyle,
boolean preserveStyle
) {
this(initialParagraphStyle, applyParagraphStyle, initialTextStyle, applyStyle,
new SimpleEditableStyledDocument<>(initialParagraphStyle, initialTextStyle), preserveStyle);
}
/**
* The same as {@link #StyledTextArea(Object, BiConsumer, Object, BiConsumer)} except that
* this constructor can be used to create another {@code StyledTextArea} object that
* shares the same {@link EditableStyledDocument}.
*/
public StyledTextArea(PS initialParagraphStyle, BiConsumer applyParagraphStyle,
S initialTextStyle, BiConsumer super TextExt, S> applyStyle,
EditableStyledDocument document
) {
this(initialParagraphStyle, applyParagraphStyle, initialTextStyle, applyStyle, document, true);
}
public StyledTextArea(PS initialParagraphStyle, BiConsumer applyParagraphStyle,
S initialTextStyle, BiConsumer super TextExt, S> applyStyle,
EditableStyledDocument document, boolean preserveStyle
) {
this.model = new StyledTextAreaModel<>(initialParagraphStyle, initialTextStyle, document, preserveStyle);
this.applyStyle = applyStyle;
this.applyParagraphStyle = applyParagraphStyle;
// 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();
// Initialize content
virtualFlow = VirtualFlow.createVertical(
getParagraphs(),
par -> {
Cell, ParagraphBox> cell = createCell(
par,
applyStyle,
applyParagraphStyle);
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();
navigator = new TwoLevelNavigator(cellCount, cellLength);
// follow the caret every time the caret position or paragraphs change
EventStream> caretPosDirty = invalidationsOf(caretPositionProperty());
EventStream> paragraphsDirty = invalidationsOf(getParagraphs());
EventStream> selectionDirty = invalidationsOf(selectionProperty());
// need to reposition popup even when caret hasn't moved, but selection has changed (been deselected)
EventStream> caretDirty = merge(caretPosDirty, paragraphsDirty, selectionDirty);
subscribeTo(caretDirty, x -> requestFollowCaret());
// whether or not to display the caret
EventStream blinkCaret = EventStreams.valuesOf(showCaretProperty())
.flatMap(mode -> {
switch (mode) {
case ON:
return EventStreams.valuesOf(Val.constant(true));
case OFF:
return EventStreams.valuesOf(Val.constant(false));
default:
case AUTO:
return EventStreams.valuesOf(focusedProperty()
.and(editableProperty())
.and(disabledProperty().not()));
}
});
// the rate at which to display the caret
EventStream blinkRate = EventStreams.valuesOf(caretBlinkRate);
// The caret is visible in periodic intervals,
// but only when blinkCaret is true.
caretVisible = EventStreams.combine(blinkCaret, blinkRate)
.flatMap(tuple -> {
Boolean blink = tuple.get1();
javafx.util.Duration rate = tuple.get2();
if(blink) {
return rate.lessThanOrEqualTo(ZERO)
? EventStreams.valuesOf(Val.constant(true))
: booleanPulse(rate, caretDirty);
} else {
return EventStreams.valuesOf(Val.constant(false));
}
})
.toBinding(false);
manageBinding(caretVisible);
// Adjust popup anchor by either a user-provided function,
// or user-provided offset, or don't adjust at all.
Val> userOffset = Val.map(
popupAnchorOffsetProperty(),
offset -> anchor -> anchor.add(offset));
_popupAnchorAdjustment =
Val.orElse(
popupAnchorAdjustmentProperty(),
userOffset)
.orElseConst(UnaryOperator.identity());
// 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));
behavior = new StyledTextAreaBehavior(this);
}
/* ********************************************************************** *
* *
* Queries *
* *
* Queries are parameterized observables. *
* *
* ********************************************************************** */
/**
* Returns caret bounds relative to the viewport, i.e. the visual bounds
* of the embedded VirtualFlow.
*/
Optional getCaretBounds() {
return virtualFlow.getCellIfVisible(getCurrentParagraph())
.map(c -> {
Bounds cellBounds = c.getNode().getCaretBounds();
return virtualFlow.cellToViewport(c, cellBounds);
});
}
/**
* Returns x coordinate of the caret in the current paragraph.
*/
ParagraphBox.CaretOffsetX getCaretOffsetX() {
int idx = getCurrentParagraph();
return getCell(idx).getCaretOffsetX();
}
double getViewportHeight() {
return virtualFlow.getHeight();
}
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) {
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);
}
}
/**
* Helpful for determining which letter is at point x, y:
*
* {@code
* StyledTextArea area = // creation code
* area.addEventHandler(MouseEvent.MOUSE_PRESSED, (MouseEvent e) -> {
* CharacterHit hit = area.hit(e.getX(), e.getY());
* int characterPosition = hit.getInsertionIndex();
*
* // move the caret to that character's position
* area.moveTo(characterPosition, SelectionPolicy.CLEAR);
* }}
*
*/
public CharacterHit hit(double x, double y) {
VirtualFlowHit, ParagraphBox>> hit = virtualFlow.hit(x, 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.hit(cellOffset);
return parHit.offset(parOffset);
}
}
/**
* 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();
return _position(parIdx, lineIdx);
}
TwoDimensional.Position _position(int par, int line) {
return navigator.position(par, line);
}
@Override
public final String getText(int start, int end) {
return model.getText(start, end);
}
@Override
public String getText(int paragraph) {
return model.getText(paragraph);
}
public Paragraph getParagraph(int index) {
return model.getParagraph(index);
}
@Override
public StyledDocument subDocument(int start, int end) {
return model.subDocument(start, end);
}
@Override
public StyledDocument subDocument(int paragraphIndex) {
return model.subDocument(paragraphIndex);
}
/**
* Returns the selection range in the given paragraph.
*/
public IndexRange getParagraphSelection(int paragraph) {
return model.getParagraphSelection(paragraph);
}
/**
* Returns the style of the character with the given index.
* If {@code index} points to a line terminator character,
* the last style used in the paragraph terminated by that
* line terminator is returned.
*/
public S getStyleOfChar(int index) {
return model.getStyleOfChar(index);
}
/**
* Returns the style at the given position. That is the style of the
* character immediately preceding {@code position}, except when
* {@code position} points to a paragraph boundary, in which case it
* is the style at the beginning of the latter paragraph.
*
* In other words, most of the time {@code getStyleAtPosition(p)}
* is equivalent to {@code getStyleOfChar(p-1)}, except when {@code p}
* points to a paragraph boundary, in which case it is equivalent to
* {@code getStyleOfChar(p)}.
*/
public S getStyleAtPosition(int position) {
return model.getStyleAtPosition(position);
}
/**
* Returns the range of homogeneous style that includes the given position.
* If {@code position} points to a boundary between two styled ranges, then
* the range preceding {@code position} is returned. If {@code position}
* points to a boundary between two paragraphs, then the first styled range
* of the latter paragraph is returned.
*/
public IndexRange getStyleRangeAtPosition(int position) {
return model.getStyleRangeAtPosition(position);
}
/**
* Returns the styles in the given character range.
*/
public StyleSpans getStyleSpans(int from, int to) {
return model.getStyleSpans(from, to);
}
/**
* Returns the styles in the given character range.
*/
public StyleSpans getStyleSpans(IndexRange range) {
return getStyleSpans(range.getStart(), range.getEnd());
}
/**
* Returns the style of the character with the given index in the given
* paragraph. If {@code index} is beyond the end of the paragraph, the
* style at the end of line is returned. If {@code index} is negative, it
* is the same as if it was 0.
*/
public S getStyleOfChar(int paragraph, int index) {
return model.getStyleOfChar(paragraph, index);
}
/**
* Returns the style at the given position in the given paragraph.
* This is equivalent to {@code getStyleOfChar(paragraph, position-1)}.
*/
public S getStyleAtPosition(int paragraph, int position) {
return model.getStyleAtPosition(paragraph, position);
}
/**
* Returns the range of homogeneous style that includes the given position
* in the given paragraph. If {@code position} points to a boundary between
* two styled ranges, then the range preceding {@code position} is returned.
*/
public IndexRange getStyleRangeAtPosition(int paragraph, int position) {
return model.getStyleRangeAtPosition(paragraph, position);
}
/**
* Returns styles of the whole paragraph.
*/
public StyleSpans getStyleSpans(int paragraph) {
return model.getStyleSpans(paragraph);
}
/**
* Returns the styles in the given character range of the given paragraph.
*/
public StyleSpans getStyleSpans(int paragraph, int from, int to) {
return model.getStyleSpans(paragraph, from, to);
}
/**
* Returns the styles in the given character range of the given paragraph.
*/
public StyleSpans getStyleSpans(int paragraph, IndexRange range) {
return getStyleSpans(paragraph, range.getStart(), range.getEnd());
}
@Override
public int getAbsolutePosition(int paragraphIndex, int columnIndex) {
return model.getAbsolutePosition(paragraphIndex, columnIndex);
}
@Override
public Position position(int row, int col) {
return model.position(row, col);
}
@Override
public Position offsetToPosition(int charOffset, Bias bias) {
return model.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. *
* *
* ********************************************************************** */
void scrollBy(Point2D deltas) {
virtualFlow.scrollXBy(deltas.getX());
virtualFlow.scrollYBy(deltas.getY());
}
void show(double y) {
virtualFlow.show(y);
}
void showCaretAtBottom() {
int parIdx = getCurrentParagraph();
Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx);
Bounds caretBounds = cell.getNode().getCaretBounds();
double y = caretBounds.getMaxY();
virtualFlow.showAtOffset(parIdx, getViewportHeight() - y);
}
void showCaretAtTop() {
int parIdx = getCurrentParagraph();
Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx);
Bounds caretBounds = cell.getNode().getCaretBounds();
double y = caretBounds.getMinY();
virtualFlow.showAtOffset(parIdx, -y);
}
void requestFollowCaret() {
followCaretRequested = true;
requestLayout();
}
private void followCaret() {
int parIdx = getCurrentParagraph();
Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx);
Bounds caretBounds = cell.getNode().getCaretBounds();
double graphicWidth = cell.getNode().getGraphicPrefWidth();
Bounds region = extendLeft(caretBounds, graphicWidth);
virtualFlow.show(parIdx, region);
}
/**
* Sets style for the given character range.
*/
public void setStyle(int from, int to, S style) {
model.setStyle(from, to, style);
}
/**
* Sets style for the whole paragraph.
*/
public void setStyle(int paragraph, S style) {
model.setStyle(paragraph, style);
}
/**
* Sets style for the given range relative in the given paragraph.
*/
public void setStyle(int paragraph, int from, int to, S style) {
model.setStyle(paragraph, from, to, style);
}
/**
* Set multiple style ranges at once. This is equivalent to
*
* for(StyleSpan{@code } span: styleSpans) {
* setStyle(from, from + span.getLength(), span.getStyle());
* from += span.getLength();
* }
*
* but the actual implementation is more efficient.
*/
public void setStyleSpans(int from, StyleSpans extends S> styleSpans) {
model.setStyleSpans(from, styleSpans);
}
/**
* Set multiple style ranges of a paragraph at once. This is equivalent to
*
* for(StyleSpan{@code } span: styleSpans) {
* setStyle(paragraph, from, from + span.getLength(), span.getStyle());
* from += span.getLength();
* }
*
* but the actual implementation is more efficient.
*/
public void setStyleSpans(int paragraph, int from, StyleSpans extends S> styleSpans) {
model.setStyleSpans(paragraph, from, styleSpans);
}
/**
* Sets style for the whole paragraph.
*/
public void setParagraphStyle(int paragraph, PS paragraphStyle) {
model.setParagraphStyle(paragraph, paragraphStyle);
}
/**
* Resets the style of the given range to the initial style.
*/
public void clearStyle(int from, int to) {
model.clearStyle(from, to);
}
/**
* Resets the style of the given paragraph to the initial style.
*/
public void clearStyle(int paragraph) {
model.clearStyle(paragraph);
}
/**
* Resets the style of the given range in the given paragraph
* to the initial style.
*/
public void clearStyle(int paragraph, int from, int to) {
model.clearStyle(paragraph, from, to);
}
/**
* Resets the style of the given paragraph to the initial style.
*/
public void clearParagraphStyle(int paragraph) {
model.clearParagraphStyle(paragraph);
}
@Override
public void replaceText(int start, int end, String text) {
model.replaceText(start, end, text);
}
@Override
public void replace(int start, int end, StyledDocument replacement) {
model.replace(start, end, replacement);
}
@Override
public void selectRange(int anchor, int caretPosition) {
model.selectRange(anchor, caretPosition);
}
/**
* {@inheritDoc}
* @deprecated You probably meant to use {@link #moveTo(int)}. This method will be made
* package-private in the future
*/
@Deprecated
@Override
public void positionCaret(int pos) {
model.positionCaret(pos);
}
/* ********************************************************************** *
* *
* Public API *
* *
* ********************************************************************** */
public void dispose() {
subscriptions.unsubscribe();
model.dispose();
virtualFlow.dispose();
}
/* ********************************************************************** *
* *
* Layout *
* *
* ********************************************************************** */
@Override
protected void layoutChildren() {
virtualFlow.resize(getWidth(), getHeight());
if(followCaretRequested) {
followCaretRequested = false;
followCaret();
}
// position popup
PopupWindow popup = getPopupWindow();
PopupAlignment alignment = getPopupAlignment();
UnaryOperator adjustment = _popupAnchorAdjustment.getValue();
if(popup != null) {
positionPopup(popup, alignment, adjustment);
}
}
/* ********************************************************************** *
* *
* Private methods *
* *
* ********************************************************************** */
private Cell, ParagraphBox> createCell(
Paragraph paragraph,
BiConsumer super TextExt, S> applyStyle,
BiConsumer applyParagraphStyle) {
ParagraphBox box = new ParagraphBox<>(paragraph, applyParagraphStyle, applyStyle);
box.highlightFillProperty().bind(highlightFill);
box.highlightTextFillProperty().bind(highlightTextFill);
box.wrapTextProperty().bind(wrapTextProperty());
box.graphicFactoryProperty().bind(paragraphGraphicFactoryProperty());
box.graphicOffset.bind(virtualFlow.breadthOffsetProperty());
Val hasCaret = Val.combine(
box.indexProperty(),
currentParagraphProperty(),
(bi, cp) -> bi.intValue() == cp.intValue());
Subscription hasCaretPseudoClass = hasCaret.values().subscribe(value -> box.pseudoClassStateChanged(HAS_CARET, value));
Subscription firstParPseudoClass = box.indexProperty().values().subscribe(idx -> box.pseudoClassStateChanged(FIRST_PAR, idx == 0));
Subscription lastParPseudoClass = EventStreams.combine(
box.indexProperty().values(),
getParagraphs().sizeProperty().values()
).subscribe(in -> in.exec((i, n) -> box.pseudoClassStateChanged(LAST_PAR, i == n-1)));
// caret is visible only in the paragraph with the caret
Val cellCaretVisible = hasCaret.flatMap(x -> x ? caretVisible : Val.constant(false));
box.caretVisibleProperty().bind(cellCaretVisible);
// bind cell's caret position to area's caret column,
// when the cell is the one with the caret
box.caretPositionProperty().bind(hasCaret.flatMap(has -> has
? caretColumnProperty()
: Val.constant(0)));
// keep paragraph selection updated
ObjectBinding cellSelection = Bindings.createObjectBinding(() -> {
int idx = box.getIndex();
return idx != -1
? getParagraphSelection(idx)
: StyledTextArea.EMPTY_RANGE;
}, selectionProperty(), box.indexProperty());
box.selectionProperty().bind(cellSelection);
return new Cell, ParagraphBox>() {
@Override
public ParagraphBox getNode() {
return box;
}
@Override
public void updateIndex(int index) {
box.setIndex(index);
}
@Override
public void dispose() {
box.highlightFillProperty().unbind();
box.highlightTextFillProperty().unbind();
box.wrapTextProperty().unbind();
box.graphicFactoryProperty().unbind();
box.graphicOffset.unbind();
hasCaretPseudoClass.unsubscribe();
firstParPseudoClass.unsubscribe();
lastParPseudoClass.unsubscribe();
box.caretVisibleProperty().unbind();
box.caretPositionProperty().unbind();
box.selectionProperty().unbind();
cellSelection.dispose();
}
};
}
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 void positionPopup(
PopupWindow popup,
PopupAlignment alignment,
UnaryOperator adjustment) {
Optional bounds = null;
switch(alignment.getAnchorObject()) {
case CARET: bounds = getCaretBoundsOnScreen(); break;
case SELECTION: bounds = getSelectionBoundsOnScreen(); break;
}
bounds.ifPresent(b -> {
double x = 0, y = 0;
switch(alignment.getHorizontalAlignment()) {
case LEFT: x = b.getMinX(); break;
case H_CENTER: x = (b.getMinX() + b.getMaxX()) / 2; break;
case RIGHT: x = b.getMaxX(); break;
}
switch(alignment.getVerticalAlignment()) {
case TOP: y = b.getMinY();
case V_CENTER: y = (b.getMinY() + b.getMaxY()) / 2; break;
case BOTTOM: y = b.getMaxY(); break;
}
Point2D anchor = adjustment.apply(new Point2D(x, y));
popup.setAnchorX(anchor.getX());
popup.setAnchorY(anchor.getY());
});
}
private Optional getCaretBoundsOnScreen() {
return virtualFlow.getCellIfVisible(getCurrentParagraph())
.map(c -> c.getNode().getCaretBoundsOnScreen());
}
private Optional getSelectionBoundsOnScreen() {
IndexRange selection = getSelection();
if(selection.getLength() == 0) {
return getCaretBoundsOnScreen();
}
Bounds[] bounds = virtualFlow.visibleCells().stream()
.map(c -> c.getNode().getSelectionBoundsOnScreen())
.filter(Optional::isPresent)
.map(Optional::get)
.toArray(Bounds[]::new);
if(bounds.length == 0) {
return Optional.empty();
}
double minX = Stream.of(bounds).mapToDouble(Bounds::getMinX).min().getAsDouble();
double maxX = Stream.of(bounds).mapToDouble(Bounds::getMaxX).max().getAsDouble();
double minY = Stream.of(bounds).mapToDouble(Bounds::getMinY).min().getAsDouble();
double maxY = Stream.of(bounds).mapToDouble(Bounds::getMaxY).max().getAsDouble();
return Optional.of(new BoundingBox(minX, minY, maxX-minX, maxY-minY));
}
private void subscribeTo(EventStream src, Consumer cOnsumer) {
manageSubscription(src.subscribe(cOnsumer));
}
private void manageSubscription(Subscription subscription) {
subscriptions = subscriptions.and(subscription);
}
private void manageBinding(Binding> binding) {
subscriptions = subscriptions.and(binding::dispose);
}
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 static EventStream booleanPulse(javafx.util.Duration javafxDuration, EventStream> restartImpulse) {
Duration duration = Duration.ofMillis(Math.round(javafxDuration.toMillis()));
EventStream> ticks = EventStreams.restartableTicks(duration, restartImpulse);
return StateMachine.init(false)
.on(restartImpulse.withDefaultEvent(null)).transition((state, impulse) -> true)
.on(ticks).transition((state, tick) -> !state)
.toStateStream();
}
}
| |