org.fxmisc.richtext.skin.StyledTextAreaVisual Maven / Gradle / Ivy
Show all versions of richtextfx Show documentation
package org.fxmisc.richtext.skin;
import static org.reactfx.EventStreams.*;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
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.BooleanBinding;
import javafx.beans.binding.ObjectBinding;
import javafx.collections.FXCollections;
import javafx.collections.ObservableSet;
import javafx.css.CssMetaData;
import javafx.css.Styleable;
import javafx.css.StyleableObjectProperty;
import javafx.event.Event;
import javafx.geometry.BoundingBox;
import javafx.geometry.Bounds;
import javafx.geometry.Point2D;
import javafx.scene.control.IndexRange;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.stage.PopupWindow;
import org.fxmisc.flowless.Cell;
import org.fxmisc.flowless.VirtualFlow;
import org.fxmisc.flowless.VirtualFlowHit;
import org.fxmisc.richtext.MouseOverTextEvent;
import org.fxmisc.richtext.Paragraph;
import org.fxmisc.richtext.PopupAlignment;
import org.fxmisc.richtext.StyledTextArea;
import org.fxmisc.richtext.TwoDimensional.Position;
import org.fxmisc.richtext.TwoLevelNavigator;
import org.fxmisc.richtext.skin.CssProperties.HighlightFillProperty;
import org.fxmisc.richtext.skin.CssProperties.HighlightTextFillProperty;
import org.fxmisc.richtext.skin.ParagraphBox.CaretOffsetX;
import org.fxmisc.wellbehaved.skin.SimpleVisualBase;
import org.reactfx.EventStream;
import org.reactfx.EventStreams;
import org.reactfx.Subscription;
import org.reactfx.value.Val;
public class StyledTextAreaVisual extends SimpleVisualBase> {
private final StyledTextAreaView node;
public StyledTextAreaVisual(StyledTextArea control, BiConsumer super TextExt, S> applyStyle) {
super(control);
this.node = new StyledTextAreaView<>(control, applyStyle);
}
@Override
public void dispose() {
node.dispose();
}
@Override
public StyledTextAreaView getNode() {
return node;
}
@Override
public List> getCssMetaData() {
return node.getCssMetaData();
}
}
/**
* StyledTextArea skin.
*/
class StyledTextAreaView extends Region {
/* ********************************************************************** *
* *
* Properties *
* *
* ********************************************************************** */
/**
* Background fill for highlighted text.
*/
private final StyleableObjectProperty highlightFill
= new HighlightFillProperty(this, Color.DODGERBLUE);
/**
* Text color for highlighted text.
*/
private final StyleableObjectProperty highlightTextFill
= new HighlightTextFillProperty(this, Color.WHITE);
/* ********************************************************************** *
* *
* Private fields *
* *
* ********************************************************************** */
private final StyledTextArea area;
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;
/* ********************************************************************** *
* *
* Constructors *
* *
* ********************************************************************** */
public StyledTextAreaView(
StyledTextArea styledTextArea,
BiConsumer super TextExt, S> applyStyle) {
this.area = styledTextArea;
// load the default style
area.getStylesheets().add(StyledTextAreaView.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(
area.getParagraphs(),
par -> {
Cell, ParagraphBox> cell = createCell(par, applyStyle);
nonEmptyCells.add(cell.getNode());
return cell.beforeReset(() -> nonEmptyCells.remove(cell.getNode()))
.afterUpdateItem(p -> nonEmptyCells.add(cell.getNode()));
});
getChildren().add(virtualFlow);
// initialize navigator
IntSupplier cellCount = () -> area.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(area.caretPositionProperty());
EventStream> paragraphsDirty = invalidationsOf(area.getParagraphs());
EventStream> selectionDirty = invalidationsOf(area.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 animate the caret
BooleanBinding blinkCaret = area.focusedProperty()
.and(area.editableProperty())
.and(area.disabledProperty().not());
manageBinding(blinkCaret);
// The caret is visible in periodic intervals,
// but only when blinkCaret is true.
caretVisible = EventStreams.valuesOf(blinkCaret)
.flatMap(blink -> blink
? booleanPulse(Duration.ofMillis(500))
: 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(
area.popupAnchorOffsetProperty(),
offset -> anchor -> anchor.add(offset));
this.popupAnchorAdjustment =
Val.orElse(
area.popupAnchorAdjustmentProperty(),
userOffset)
.orElseConst(UnaryOperator.identity());
// dispatch MouseOverTextEvents when mouseOverTextDelay is not null
EventStreams.valuesOf(area.mouseOverTextDelayProperty())
.flatMap(delay -> delay != null
? mouseOverTextEvents(nonEmptyCells, delay)
: EventStreams.never())
.subscribe(evt -> Event.fireEvent(area, evt));
}
/* ********************************************************************** *
* *
* Public API *
* *
* ********************************************************************** */
public void dispose() {
subscriptions.unsubscribe();
virtualFlow.dispose();
}
/* ********************************************************************** *
* *
* Layout *
* *
* ********************************************************************** */
@Override
protected void layoutChildren() {
virtualFlow.resize(getWidth(), getHeight());
if(followCaretRequested) {
followCaretRequested = false;
followCaret();
}
// position popup
PopupWindow popup = area.getPopupWindow();
PopupAlignment alignment = area.getPopupAlignment();
UnaryOperator adjustment = popupAnchorAdjustment.getValue();
if(popup != null) {
positionPopup(popup, alignment, adjustment);
}
}
/* ********************************************************************** *
* *
* Look & feel *
* *
* ********************************************************************** */
@Override
public List> getCssMetaData() {
return Arrays.>asList(
highlightFill.getCssMetaData(),
highlightTextFill.getCssMetaData());
}
/* ********************************************************************** *
* *
* Actions *
* *
* ********************************************************************** */
void scrollBy(Point2D deltas) {
virtualFlow.scrollX(deltas.getX());
virtualFlow.scrollY(deltas.getY());
}
void show(double y) {
virtualFlow.show(y);
}
void showCaretAtBottom() {
int parIdx = area.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 = area.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 = area.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);
}
/* ********************************************************************** *
* *
* Queries *
* *
* ********************************************************************** */
/**
* Returns caret bounds relative to the viewport, i.e. the visual bounds
* of the embedded VirtualFlow.
*/
Optional getCaretBounds() {
return virtualFlow.getCellIfVisible(area.getCurrentParagraph())
.map(c -> {
Bounds cellBounds = c.getNode().getCaretBounds();
return virtualFlow.cellToViewport(c, cellBounds);
});
}
/**
* Returns x coordinate of the caret in the current paragraph.
*/
CaretOffsetX getCaretOffsetX() {
int idx = area.getCurrentParagraph();
return getCell(idx).getCaretOffsetX();
}
double getViewportHeight() {
return virtualFlow.getViewportHeight();
}
CharacterHit hit(CaretOffsetX x, 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(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(area.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);
}
}
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(area.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.
*/
Position currentLine() {
int parIdx = area.getCurrentParagraph();
Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx);
int lineIdx = cell.getNode().getCurrentLineIndex();
return position(parIdx, lineIdx);
}
Position position(int par, int line) {
return navigator.position(par, line);
}
/* ********************************************************************** *
* *
* Private methods *
* *
* ********************************************************************** */
private Cell, ParagraphBox> createCell(
Paragraph paragraph,
BiConsumer super TextExt, S> applyStyle) {
ParagraphBox box = new ParagraphBox<>(paragraph, applyStyle);
box.highlightFillProperty().bind(highlightFill);
box.highlightTextFillProperty().bind(highlightTextFill);
box.wrapTextProperty().bind(area.wrapTextProperty());
box.graphicFactoryProperty().bind(area.paragraphGraphicFactoryProperty());
box.graphicOffset.bind(virtualFlow.breadthOffsetProperty());
Val hasCaret = Val.combine(
box.indexProperty(),
area.currentParagraphProperty(),
(bi, cp) -> bi.intValue() == cp.intValue());
// caret is visible only in the paragraph with the caret
Val cellCaretVisible = Val.combine(hasCaret, caretVisible, (a, b) -> a && b);
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
? area.caretColumnProperty()
: Val.constant(0)));
// keep paragraph selection updated
ObjectBinding cellSelection = Bindings.createObjectBinding(() -> {
int idx = box.getIndex();
return idx != -1
? area.getParagraphSelection(idx)
: StyledTextArea.EMPTY_RANGE;
}, area.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();
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 area.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(area.getCurrentParagraph())
.map(c -> c.getNode().getCaretBoundsOnScreen());
}
private Optional getSelectionBoundsOnScreen() {
IndexRange selection = area.getSelection();
if(selection.getLength() == 0) {
return getCaretBoundsOnScreen();
}
Bounds[] bounds = virtualFlow.visibleCells().stream()
.map(c -> c.getNode().getSelectionBoundsOnScreen())
.filter(opt -> opt.isPresent())
.map(opt -> opt.get())
.toArray(n -> new Bounds[n]);
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(Duration duration) {
return EventStreams.ticks(duration).accumulate(true, (b, x) -> !b);
}
} | |