All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.fxmisc.richtext.StyledTextArea Maven / Gradle / Ivy

package org.fxmisc.richtext;

import static org.fxmisc.richtext.PopupAlignment.*;
import static org.fxmisc.richtext.TwoDimensional.Bias.*;

import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.IntFunction;
import java.util.function.UnaryOperator;

import javafx.beans.InvalidationListener;
import javafx.beans.binding.Binding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableIntegerValue;
import javafx.beans.value.ObservableStringValue;
import javafx.beans.value.ObservableValue;
import javafx.css.CssMetaData;
import javafx.css.Styleable;
import javafx.css.StyleableObjectProperty;
import javafx.geometry.Insets;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.IndexRange;
import javafx.scene.control.Skin;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.stage.PopupWindow;

import org.fxmisc.easybind.EasyBind;
import org.fxmisc.richtext.CssProperties.EditableProperty;
import org.fxmisc.richtext.CssProperties.FontProperty;
import org.fxmisc.richtext.skin.StyledTextAreaBehavior;
import org.fxmisc.richtext.skin.StyledTextAreaVisual;
import org.fxmisc.richtext.util.skin.Skins;
import org.fxmisc.undo.UndoManager;
import org.fxmisc.undo.UndoManagerFactory;
import org.reactfx.EventStream;
import org.reactfx.Guard;
import org.reactfx.Guardian;
import org.reactfx.Indicator;
import org.reactfx.SuspendableEventStream;
import org.reactfx.inhibeans.collection.Collections;
import org.reactfx.inhibeans.collection.ObservableList;

import com.sun.javafx.Utils;

/**
 * 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.

* * @param type of style that can be applied to text. */ public class StyledTextArea extends Control implements TextEditingArea, EditActions, ClipboardActions, NavigationActions, UndoActions, TwoDimensional { /** * Index range [0, 0). */ public static final IndexRange EMPTY_RANGE = new IndexRange(0, 0); /* ********************************************************************** * * * * 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. * * * * ********************************************************************** */ // editable property private final BooleanProperty editable = new EditableProperty<>(this); @Override public final boolean isEditable() { return editable.get(); } @Override public final void setEditable(boolean value) { editable.set(value); } @Override public final BooleanProperty editableProperty() { return editable; } // wrapText property private final BooleanProperty wrapText = new SimpleBooleanProperty(this, "wrapText"); @Override public final boolean isWrapText() { return wrapText.get(); } @Override public final void setWrapText(boolean value) { wrapText.set(value); } @Override public final BooleanProperty wrapTextProperty() { return wrapText; } // undo manager private UndoManager undoManager; @Override public UndoManager getUndoManager() { return undoManager; } @Override public void setUndoManager(UndoManagerFactory undoManagerFactory) { undoManager.close(); undoManager = preserveStyle ? createRichUndoManager(undoManagerFactory) : createPlainUndoManager(undoManagerFactory); } // font property /** * The default font to use where font is not specified otherwise. */ private final StyleableObjectProperty font = new FontProperty<>(this); public final StyleableObjectProperty fontProperty() { return font; } public final void setFont(Font value) { font.setValue(value); } public final Font getFont() { return font.getValue(); } 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 public void setPopupAtCaret(PopupWindow popup) { popupWindow.set(popup); } @Deprecated public PopupWindow getPopupAtCaret() { return popupWindow.get(); } @Deprecated public ObjectProperty popupAtCaretProperty() { return popupWindow; } 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; } 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; } 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; } private final ObjectProperty> paragraphGraphicFactory = new SimpleObjectProperty<>(null); public void setParagraphGraphicFactory(IntFunction factory) { paragraphGraphicFactory.set(factory); } public IntFunction getParagraphGraphicFactory() { return paragraphGraphicFactory.get(); } public ObjectProperty> paragraphGraphicFactoryProperty() { return paragraphGraphicFactory; } /* ********************************************************************** * * * * 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 private final org.reactfx.inhibeans.binding.Binding text; @Override public final String getText() { return text.getValue(); } @Override public final ObservableValue textProperty() { return text; } // rich text @Override public final StyledDocument getDocument() { return content.snapshot(); }; // length private final org.reactfx.inhibeans.binding.IntegerBinding length; @Override public final int getLength() { return length.get(); } @Override public final ObservableIntegerValue lengthProperty() { return length; } // caret position private final IntegerProperty internalCaretPosition = new SimpleIntegerProperty(0); private final org.reactfx.inhibeans.binding.IntegerBinding caretPosition = org.reactfx.inhibeans.binding.IntegerBinding.wrap(internalCaretPosition); @Override public final int getCaretPosition() { return caretPosition.get(); } @Override public final ObservableIntegerValue caretPositionProperty() { return caretPosition; } // selection anchor private final org.reactfx.inhibeans.property.SimpleIntegerProperty anchor = new org.reactfx.inhibeans.property.SimpleIntegerProperty(0); @Override public final int getAnchor() { return anchor.get(); } @Override public final ObservableIntegerValue anchorProperty() { return anchor; } // selection private final ObjectProperty internalSelection = new SimpleObjectProperty<>(EMPTY_RANGE); private final org.reactfx.inhibeans.binding.ObjectBinding selection = org.reactfx.inhibeans.binding.ObjectBinding.wrap(internalSelection); @Override public final IndexRange getSelection() { return selection.getValue(); } @Override public final ObservableValue selectionProperty() { return selection; } // selected text private final org.reactfx.inhibeans.binding.StringBinding selectedText; @Override public final String getSelectedText() { return selectedText.get(); } @Override public final ObservableStringValue selectedTextProperty() { return selectedText; } // current paragraph index private final org.reactfx.inhibeans.binding.IntegerBinding currentParagraph; @Override public final int getCurrentParagraph() { return currentParagraph.get(); } @Override public final ObservableIntegerValue currentParagraphProperty() { return currentParagraph; } // caret column private final org.reactfx.inhibeans.binding.IntegerBinding caretColumn; @Override public final int getCaretColumn() { return caretColumn.get(); } @Override public final ObservableIntegerValue caretColumnProperty() { return caretColumn; } // paragraphs private final ObservableList> paragraphs; @Override public ObservableList> getParagraphs() { return paragraphs; } // beingUpdated private final Indicator beingUpdated = new Indicator(); public Indicator beingUpdatedProperty() { return beingUpdated; } public boolean isBeingUpdated() { return beingUpdated.isOn(); } /* ********************************************************************** * * * * Event streams * * * * ********************************************************************** */ // text changes private final SuspendableEventStream plainTextChanges; @Override public final EventStream plainTextChanges() { return plainTextChanges; } // rich text changes private final SuspendableEventStream> richTextChanges; @Override public final EventStream> richChanges() { return richTextChanges; } /* ********************************************************************** * * * * Private fields * * * * ********************************************************************** */ private Position selectionStart2D; private Position selectionEnd2D; /** * content model */ private final EditableStyledDocument content; /** * Style used by default when no other style is provided. */ private final S initialStyle; /** * Style applicator used by the default skin. */ private final BiConsumer applyStyle; /** * Indicates whether style should be preserved on undo/redo, * copy/paste and text move. * TODO: Currently, only undo/redo respect this flag. */ private final boolean preserveStyle; private final Guardian omniGuardian; /* ********************************************************************** * * * * Constructors * * * * ********************************************************************** */ /** * Creates a text area with empty text content. * * @param initialStyle 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. */ public StyledTextArea(S initialStyle, BiConsumer applyStyle) { this(initialStyle, applyStyle, true); } public StyledTextArea(S initialStyle, BiConsumer applyStyle, boolean preserveStyle) { this.initialStyle = initialStyle; this.applyStyle = applyStyle; this.preserveStyle = preserveStyle; content = new EditableStyledDocument<>(initialStyle); paragraphs = Collections.wrap(content.getParagraphs()); text = org.reactfx.inhibeans.binding.Binding.wrap(content.textProperty()); length = org.reactfx.inhibeans.binding.IntegerBinding.wrap(content.lengthProperty()); plainTextChanges = content.plainTextChanges().pausable(); richTextChanges = content.richChanges().pausable(); undoManager = preserveStyle ? createRichUndoManager(UndoManagerFactory.unlimitedHistoryFactory()) : createPlainUndoManager(UndoManagerFactory.unlimitedHistoryFactory()); Binding caretPosition2D = EasyBind.map(internalCaretPosition, p -> content.offsetToPosition(p.intValue(), Forward)); paragraphs.addListener((InvalidationListener) (obs -> caretPosition2D.invalidate())); currentParagraph = org.reactfx.inhibeans.binding.IntegerBinding.wrap( EasyBind.map(caretPosition2D, p -> p.getMajor())); caretColumn = org.reactfx.inhibeans.binding.IntegerBinding.wrap( EasyBind.map(caretPosition2D, p -> p.getMinor())); selectionStart2D = position(0, 0); selectionEnd2D = position(0, 0); internalSelection.addListener(obs -> { IndexRange sel = internalSelection.get(); selectionStart2D = offsetToPosition(sel.getStart(), Forward); selectionEnd2D = sel.getLength() == 0 ? selectionStart2D : selectionStart2D.offsetBy(sel.getLength(), Backward); }); selectedText = new org.reactfx.inhibeans.binding.StringBinding() { { bind(internalSelection, content.textProperty()); } @Override protected String computeValue() { return content.getText(internalSelection.get()); } }; omniGuardian = Guardian.combine( beingUpdated, // must be first, to be the last one to release text, length, caretPosition, anchor, selection, selectedText, currentParagraph, caretColumn, // add streams after properties, to be released before them plainTextChanges::suspend, richTextChanges::suspend, // paragraphs to be released first paragraphs); this.setBackground(new Background(new BackgroundFill(Color.WHITE, CornerRadii.EMPTY, Insets.EMPTY))); getStyleClass().add("styled-text-area"); } /* ********************************************************************** * * * * Queries * * * * Queries are parameterized observables. * * * * ********************************************************************** */ @Override public final String getText(int start, int end) { return content.getText(start, end); } @Override public String getText(int paragraph) { return paragraphs.get(paragraph).toString(); } public Paragraph getParagraph(int index) { return paragraphs.get(index); } @Override public StyledDocument subDocument(int start, int end) { return content.subSequence(start, end); } @Override public StyledDocument subDocument(int paragraphIndex) { return content.subDocument(paragraphIndex); } /** * Returns the selection range in the given paragraph. */ public IndexRange getParagraphSelection(int paragraph) { int startPar = selectionStart2D.getMajor(); int endPar = selectionEnd2D.getMajor(); if(paragraph < startPar || paragraph > endPar) { return EMPTY_RANGE; } int start = paragraph == startPar ? selectionStart2D.getMinor() : 0; int end = paragraph == endPar ? selectionEnd2D.getMinor() : paragraphs.get(paragraph).length(); // force selectionProperty() to be valid getSelection(); return new IndexRange(start, end); } /** * 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 content.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 content.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 content.getStyleRangeAtPosition(position); } /** * Returns the styles in the given character range. */ public StyleSpans getStyleSpans(int from, int to) { return content.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 content.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 content.getStyleOfChar(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 content.getStyleRangeAtPosition(paragraph, position); } /** * Returns styles of the whole paragraph. */ public StyleSpans getStyleSpans(int paragraph) { return content.getStyleSpans(paragraph); } /** * Returns the styles in the given character range of the given paragraph. */ public StyleSpans getStyleSpans(int paragraph, int from, int to) { return content.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 Position position(int row, int col) { return content.position(row, col); } @Override public Position offsetToPosition(int charOffset, Bias bias) { return content.offsetToPosition(charOffset, bias); } /* ********************************************************************** * * * * Actions * * * * Actions change the state of this control. They typically cause a * * change of one or more observables and/or produce an event. * * * * ********************************************************************** */ /** * Sets style for the given character range. */ public void setStyle(int from, int to, S style) { try(Guard g = omniGuardian.guard()) { content.setStyle(from, to, style); } } /** * Sets style for the whole paragraph. */ public void setStyle(int paragraph, S style) { try(Guard g = omniGuardian.guard()) { content.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) { try(Guard g = omniGuardian.guard()) { content.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 styleSpans) { try(Guard g = omniGuardian.guard()) { content.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 styleSpans) { try(Guard g = omniGuardian.guard()) { content.setStyleSpans(paragraph, from, styleSpans); } } /** * Resets the style of the given range to the initial style. */ public void clearStyle(int from, int to) { setStyle(from, to, initialStyle); } /** * Resets the style of the given paragraph to the initial style. */ public void clearStyle(int paragraph) { setStyle(paragraph, initialStyle); } /** * Resets the style of the given range in the given paragraph * to the initial style. */ public void clearStyle(int paragraph, int from, int to) { setStyle(paragraph, from, to, initialStyle); } @Override public void replaceText(int start, int end, String text) { try(Guard g = omniGuardian.guard()) { start = Utils.clamp(0, start, getLength()); end = Utils.clamp(0, end, getLength()); content.replaceText(start, end, text); int newCaretPos = start + text.length(); selectRange(newCaretPos, newCaretPos); } } @Override public void replace(int start, int end, StyledDocument replacement) { try(Guard g = omniGuardian.guard()) { start = Utils.clamp(0, start, getLength()); end = Utils.clamp(0, end, getLength()); content.replace(start, end, replacement); int newCaretPos = start + replacement.length(); selectRange(newCaretPos, newCaretPos); } } @Override public void selectRange(int anchor, int caretPosition) { try(Guard g = guard(this.caretPosition, currentParagraph, caretColumn, this.anchor, selection, selectedText)) { this.internalCaretPosition.set(Utils.clamp(0, caretPosition, getLength())); this.anchor.set(Utils.clamp(0, anchor, getLength())); this.internalSelection.set(IndexRange.normalize(getAnchor(), getCaretPosition())); } } @Override public void positionCaret(int pos) { try(Guard g = guard(caretPosition, currentParagraph, caretColumn)) { internalCaretPosition.set(pos); } } /* ********************************************************************** * * * * Look & feel * * * * ********************************************************************** */ @Override protected Skin createDefaultSkin() { return Skins.createSimpleSkin( this, area -> new StyledTextAreaVisual<>(area, applyStyle), StyledTextAreaBehavior::new); } @Override public List> getControlCssMetaData() { List> superMetaData = super.getControlCssMetaData(); List> myMetaData = Arrays.>asList( font.getCssMetaData()); List> res = new ArrayList<>(superMetaData.size() + myMetaData.size()); res.addAll(superMetaData); res.addAll(myMetaData); return res; } /* ********************************************************************** * * * * Private methods * * * * ********************************************************************** */ private UndoManager createPlainUndoManager(UndoManagerFactory factory) { Consumer apply = change -> replaceText(change.getPosition(), change.getPosition() + change.getRemoved().length(), change.getInserted()); Consumer undo = change -> replaceText(change.getPosition(), change.getPosition() + change.getInserted().length(), change.getRemoved()); BiFunction> merge = (change1, change2) -> change1.mergeWith(change2); return factory.create(plainTextChanges(), apply, undo, merge); } private UndoManager createRichUndoManager(UndoManagerFactory factory) { Consumer> apply = change -> replace(change.getPosition(), change.getPosition() + change.getRemoved().length(), change.getInserted()); Consumer> undo = change -> replace(change.getPosition(), change.getPosition() + change.getInserted().length(), change.getRemoved()); BiFunction, RichTextChange, Optional>> merge = (change1, change2) -> change1.mergeWith(change2); return factory.create(richChanges(), apply, undo, merge); } private Guard guard(Guardian... guardians) { return Guardian.combine(beingUpdated, Guardian.combine(guardians)).guard(); } }