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

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

The newest version!
package org.fxmisc.richtext;

import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
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.Supplier;
import java.util.function.UnaryOperator;

import org.fxmisc.richtext.model.Paragraph;
import org.fxmisc.richtext.model.StyledSegment;
import org.reactfx.util.Tuple2;
import org.reactfx.util.Tuples;
import org.reactfx.value.Val;

import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.MapChangeListener;
import javafx.collections.ObservableMap;
import javafx.collections.ObservableSet;
import javafx.collections.SetChangeListener;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.IndexRange;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.LineTo;
import javafx.scene.shape.MoveTo;
import javafx.scene.shape.Path;
import javafx.scene.shape.PathElement;
import javafx.scene.shape.StrokeLineCap;
import javafx.scene.shape.StrokeType;

/**
 * The class responsible for rendering the segments in an paragraph. It also renders additional RichTextFX-specific
 * CSS found in {@link TextExt} as well as the selection and caret shapes.
 *
 * @param  paragraph style type
 * @param  segment type
 * @param  segment style type
 */
class ParagraphText extends TextFlowExt {

    private final ObservableSet carets = FXCollections.observableSet(new HashSet<>(1));
    public final ObservableSet caretsProperty() { return carets; }

    private final ObservableMap, SelectionPath> selections =
            FXCollections.observableMap(new HashMap<>(1));
    public final ObservableMap, SelectionPath> selectionsProperty() { return selections; }

    private final MapChangeListener, ? super SelectionPath> selectionPathListener;
    private final SetChangeListener caretNodeListener;

    // FIXME: changing it currently has not effect, because
    // Text.selectionFillProperty().set(newFill) doesn't work
    // properly for Text node inside a TextFlow (as of JDK8-b100).
    private final ObjectProperty highlightTextFill = new SimpleObjectProperty<>(Color.WHITE);
    public ObjectProperty highlightTextFillProperty() {
        return highlightTextFill;
    }

    private Paragraph paragraph;

    private final CustomCssShapeHelper backgroundShapeHelper;
    private final CustomCssShapeHelper borderShapeHelper;
    private final CustomCssShapeHelper underlineShapeHelper;

    // Note: order of children matters because later children cover up earlier children:
    // towards children's 0 index:
    //      background shapes
    //      selection shapes - always add to selectionShapeStartIndex
    //      border shapes
    //      text
    //      underline shapes
    //      caret shapes
    // towards getChildren().size() - 1 index
    private int selectionShapeStartIndex = 0;

    ParagraphText(Paragraph par, Function, Node> nodeFactory) {
        this.paragraph = par;

        getStyleClass().add("paragraph-text");

        Val leftInset = Val.map(insetsProperty(), Insets::getLeft);
        Val topInset = Val.map(insetsProperty(), Insets::getTop);

        ChangeListener selectionRangeListener = (obs, ov, nv) -> requestLayout();
        selectionPathListener = change -> {
            if (change.wasRemoved()) {
                SelectionPath p = change.getValueRemoved();
                p.rangeProperty().removeListener(selectionRangeListener);
                p.layoutXProperty().unbind();
                p.layoutYProperty().unbind();

                getChildren().remove(p);
            }
            if (change.wasAdded()) {
                SelectionPath p = change.getValueAdded();
                p.rangeProperty().addListener(selectionRangeListener);
                p.layoutXProperty().bind(leftInset);
                p.layoutYProperty().bind(topInset);

                getChildren().add(selectionShapeStartIndex, p);
                updateSingleSelection(p);
            }
        };
        selections.addListener( selectionPathListener );

        ChangeListener caretPositionListener = (obs, ov, nv) -> requestLayout();
        caretNodeListener = change -> {
            if (change.wasRemoved()) {
                CaretNode caret = change.getElementRemoved();
                caret.columnPositionProperty().removeListener(caretPositionListener);
                caret.layoutXProperty().unbind();
                caret.layoutYProperty().unbind();

                getChildren().remove(caret);
            }
            if (change.wasAdded()) {
                CaretNode caret = change.getElementAdded();
                caret.columnPositionProperty().addListener(caretPositionListener);
                caret.layoutXProperty().bind(leftInset);
                caret.layoutYProperty().bind(topInset);

                getChildren().add(caret);
                updateSingleCaret(caret);
            }
        };
        carets.addListener( caretNodeListener );

        // XXX: see the note at highlightTextFill
//        highlightTextFill.addListener(new ChangeListener() {
//            @Override
//            public void changed(ObservableValue observable,
//                    Paint oldFill, Paint newFill) {
//                for(PumpedUpText text: textNodes())
//                    text.selectionFillProperty().set(newFill);
//            }
//        });

        // populate with text nodes
        par.getStyledSegments().stream().map(nodeFactory).forEach(n -> {
            if (n instanceof TextExt) {
                TextExt t = (TextExt) n;
                // XXX: binding selectionFill to textFill,
                // see the note at highlightTextFill
                t.selectionFillProperty().bind(t.fillProperty());
            }
            getChildren().add(n);
        });

        // set up custom css shape helpers
        UnaryOperator configurePath = shape -> {
            shape.setManaged(false);
            shape.layoutXProperty().bind(leftInset);
            shape.layoutYProperty().bind(topInset);
            return shape;
        };
        Supplier createBackgroundShape = () -> configurePath.apply(new BackgroundPath());
        Supplier createBorderShape = () -> configurePath.apply(new BorderPath());
        Supplier createUnderlineShape = () -> configurePath.apply(new UnderlinePath());

        Consumer> clearUnusedShapes = paths -> getChildren().removeAll(paths);
        Consumer addToBackground = path -> getChildren().add(0, path);
        Consumer addToBackgroundAndIncrementSelectionIndex = addToBackground
                .andThen(ignore -> selectionShapeStartIndex++);
        Consumer addToForeground = path -> getChildren().add(path);
        backgroundShapeHelper = new CustomCssShapeHelper<>(
                createBackgroundShape,
                (backgroundShape, tuple) -> {
                    backgroundShape.setStrokeWidth(0);
                    backgroundShape.setFill(tuple._1);
                    backgroundShape.getElements().setAll(getRangeShape(tuple._2));
                },
                addToBackgroundAndIncrementSelectionIndex,
                clearUnusedShapes
        );
        borderShapeHelper = new CustomCssShapeHelper<>(
                createBorderShape,
                (borderShape, tuple) -> {
                    BorderAttributes attributes = tuple._1;
                    borderShape.setStrokeWidth(attributes.width);
                    borderShape.setStroke(attributes.color);
                    if (attributes.type != null) {
                        borderShape.setStrokeType(attributes.type);
                    }
                    if (attributes.dashArray != null) {
                        borderShape.getStrokeDashArray().setAll(attributes.dashArray);
                    }
                    borderShape.getElements().setAll(getRangeShape(tuple._2));
                },
                addToBackground,
                clearUnusedShapes
        );
        underlineShapeHelper = new CustomCssShapeHelper<>(
                createUnderlineShape,
                (underlineShape, tuple) -> {
                    UnderlineAttributes attributes = tuple._1;
                    underlineShape.setStroke(attributes.color);
                    underlineShape.setStrokeWidth(attributes.width);
                    underlineShape.setStrokeLineCap(attributes.cap);
                    if (attributes.dashArray != null) {
                        underlineShape.getStrokeDashArray().setAll(attributes.dashArray);
                    }
                    PathElement[] shape = getUnderlineShape(tuple._2.getStart(), tuple._2.getEnd(),
                                                            attributes.offset, attributes.waveRadius);
                    underlineShape.getElements().setAll(shape);
                },
                addToForeground,
                clearUnusedShapes
        );
    }

    void dispose() {
        carets.clear();
        selections.clear();
        // The above must be before the below to prevent any memory leaks.
        // Then remove listeners to also avoid memory leaks.
        selections.removeListener( selectionPathListener );
        carets.removeListener( caretNodeListener );

        getChildren().stream().filter( n -> n instanceof TextExt ).map( n -> (TextExt) n )
        .forEach( t -> t.selectionFillProperty().unbind() ); 

        try { getChildren().clear(); }
        catch ( Exception EX ) {}
    }

    public Paragraph getParagraph() {
        return paragraph;
    }

    public  double getCaretOffsetX(T caret) {
        layout(); // ensure layout, is a no-op if not dirty
        if ( isVisible() /* notFolded */ ) checkWithinParagraph(caret);
        Bounds bounds = caret.getLayoutBounds();
        return (bounds.getMinX() + bounds.getMaxX()) / 2;
    }

    public  Bounds getCaretBounds(T caret) {
        layout(); // ensure layout, is a no-op if not dirty
        checkWithinParagraph(caret);
        return caret.getBoundsInParent();
    }

    public  Bounds getCaretBoundsOnScreen(T caret) {
        layout(); // ensure layout, is a no-op if not dirty
        checkWithinParagraph(caret);
        Bounds localBounds = caret.getBoundsInLocal();
        return caret.localToScreen(localBounds);
    }

    public Bounds getRangeBoundsOnScreen(int from, int to) {
        layout(); // ensure layout, is a no-op if not dirty
        PathElement[] rangeShape = getRangeShapeSafely(from, to);

        Path p = new Path();
        p.setManaged(false);
        p.setLayoutX(getInsets().getLeft());
        p.setLayoutY(getInsets().getTop());

        getChildren().add(p);

        p.getElements().setAll(rangeShape);
        Bounds localBounds = p.getBoundsInLocal();
        Bounds rangeBoundsOnScreen = p.localToScreen(localBounds);

        getChildren().remove(p);

        return rangeBoundsOnScreen;
    }

    public Optional getSelectionBoundsOnScreen(Selection selection) {
        if(selection.getLength() == 0) {
            return Optional.empty();
        } else {
            layout(); // ensure layout, is a no-op if not dirty
            SelectionPath selectionShape = selections.get(selection);
            checkWithinParagraph(selectionShape);
            Bounds localBounds = selectionShape.getBoundsInLocal();
            return Optional.ofNullable(selectionShape.localToScreen(localBounds));
        }
    }

    public int getCurrentLineStartPosition(Caret caret) {
        return getLineStartPosition(getClampedCaretPosition(caret));
    }

    public int getCurrentLineEndPosition(Caret caret) {
        return getLineEndPosition(getClampedCaretPosition(caret));
    }

    public int currentLineIndex(Caret caret) {
        return getLineOfCharacter(getClampedCaretPosition(caret));
    }

    public int currentLineIndex(int position) {
        return getLineOfCharacter(position);
    }

    private  void checkWithinParagraph(T shape) {
        if (shape.getParent() != this) {
            throw new IllegalArgumentException(String.format(
                    "This ParagraphText is not the parent of the given shape (%s):\nExpected: %s\nActual:   %s",
                    shape, this, shape.getParent()
            ));
        }
    }
    private int getClampedCaretPosition(Caret caret) {
        return Math.min(caret.getColumnPosition(), paragraph.length());
    }

    private void updateAllCaretShapes() {
        carets.forEach(this::updateSingleCaret);
    }

    private void updateSingleCaret(CaretNode caretNode) {
        PathElement[] shape = getCaretShape(getClampedCaretPosition(caretNode), true);
        caretNode.getElements().setAll(shape);
    }

    private void updateAllSelectionShapes() {
        selections.values().forEach(this::updateSingleSelection);
    }

    private void updateSingleSelection(SelectionPath path) {
        path.getElements().setAll(getRangeShapeSafely(path.rangeProperty().getValue()));
    }

    private PathElement[] getRangeShapeSafely(IndexRange range) {
        return getRangeShapeSafely(range.getStart(), range.getEnd());
    }

    /**
     * Gets the range shape for the given positions within the text, including the newline character, if range
     * defined by the start/end arguments include it.
     *
     * @param start the start position of the range shape
     * @param end the end position of the range shape. If {@code end == paragraph.length() + 1}, the newline character
     *            will be included in the selection by selecting the rest of the line
     */
    private PathElement[] getRangeShapeSafely(int start, int end) {
        PathElement[] shape;
        if (end <= paragraph.length()) {
            // selection w/o newline char
            shape = getRangeShape(start, end);
        } else {
            // Selection includes a newline character.
            double width = getWidth() - getInsets().getRight() - getInsets().getLeft();
            if (paragraph.length() == 0) {
                // empty paragraph
                shape = createRectangle(0, 0, width, getHeight());
            } else if (start == paragraph.length()) {
                // selecting only the newline char

                // calculate the bounds of the last character
                shape = getRangeShape(start - 1, start);
                LineTo lineToTopRight = (LineTo) shape[shape.length - 4];
                shape = createRectangle(lineToTopRight.getX(), lineToTopRight.getY(), width, getHeight());
            } else {
                shape = getRangeShape(start, paragraph.length());
                // Since this might be a wrapped multi-line paragraph,
                // there may be multiple groups of (1 MoveTo, 4 LineTo objects) for each line:
                // MoveTo(topLeft), LineTo(topRight), LineTo(bottomRight), LineTo(bottomLeft)

                // Adjust the top right, and bottom left & right corners to extend to the
                // correct width and height of the line, simulating a full line selection.
                int length = shape.length;
                if ( length > 3 )  // Prevent IndexOutOfBoundsException accessing shape[] issue #689
                {
                    int bottomRightIndex = length - 3;
                    int topRightIndex = bottomRightIndex - 1;
                    int bottomLeftIndex = bottomRightIndex + 1;

                    LineTo lineToTopRight = (LineTo) shape[topRightIndex];
                    LineTo lineToBottomLeft = (LineTo) shape[bottomLeftIndex];

                    shape[topRightIndex] = new LineTo(width, lineToTopRight.getY());
                    shape[bottomLeftIndex] = new LineTo(lineToBottomLeft.getX(), getHeight());
                    shape[bottomRightIndex] = new LineTo(width, getHeight());
                }
            }
        }

        if ( getLineSpacing() > 0 ) {
            double half = getLineSpacing() / 2.0;
            for ( int g = 0; g < shape.length; g += 5 ) {
                MoveTo tl = (MoveTo) shape[g];
                tl.setY( tl.getY()-half );
                LineTo tr = (LineTo) shape[g+1];
                tr.setY( tl.getY() );
                LineTo br = (LineTo) shape[g+2];
                br.setY( br.getY()+half );
                LineTo bl = (LineTo) shape[g+3];
                bl.setY( br.getY() );
                LineTo t2 = (LineTo) shape[g+4];
                t2.setY( tl.getY() );
            }
        }

        if (getLineCount() > 1) {
            // adjust right corners of wrapped lines
            double width = getWidth() - getInsets().getRight() - getInsets().getLeft();
            boolean wrappedAtEndPos = (end > 0 && getLineOfCharacter(end) > getLineOfCharacter(end - 1));
            int adjustLength = shape.length - (wrappedAtEndPos ? 0 : 5);
            for (int i = 0; i < adjustLength; i++) {
                if (shape[i] instanceof MoveTo) {
                    ((LineTo)shape[i + 1]).setX(width);
                    ((LineTo)shape[i + 2]).setX(width);
                }
            }
        }

        return shape;
    }

    private PathElement[] createRectangle(double topLeftX, double topLeftY, double bottomRightX, double bottomRightY) {
        return new PathElement[] {
                new MoveTo(topLeftX, topLeftY),
                new LineTo(bottomRightX, topLeftY),
                new LineTo(bottomRightX, bottomRightY),
                new LineTo(topLeftX, bottomRightY),
                new LineTo(topLeftX, topLeftY)
        };
    }

    private void updateBackgroundShapes() {
        int start = 0;

        // calculate shared values among consecutive nodes
        for (Node node : getManagedChildren()) {
            if (!(node instanceof TextExt)) {
                // node is a custom objects (e.g. image)
                // - custom objects do not support background color, border and underline
                // - text length of custom objects is always 1
                start += 1;
                continue;
            }

            TextExt text = (TextExt) node;
            int end = start + text.getText().length();

            Paint backgroundColor = text.getBackgroundColor();
            if (backgroundColor != null) {
                backgroundShapeHelper.updateSharedShapeRange(backgroundColor, start, end, Paint::equals);
            }

            BorderAttributes border = new BorderAttributes(text);
            if (!border.isNullValue()) {
                borderShapeHelper.updateSharedShapeRange(border, start, end, BorderAttributes::equalsFaster);
            }

            UnderlineAttributes underline = new UnderlineAttributes(text);
            if (!underline.isNullValue()) {
                underlineShapeHelper.updateSharedShapeRange(underline, start, end, UnderlineAttributes::equalsFaster);
            }

            start = end;
        }

        borderShapeHelper.updateSharedShapes();
        backgroundShapeHelper.updateSharedShapes();
        underlineShapeHelper.updateSharedShapes();
    }

    @Override
    public String toString() {
        return String.format("ParagraphText@%s(paragraph=%s)", hashCode(), paragraph);
    }

    @Override
    protected void layoutChildren() {
        super.layoutChildren();
        updateAllCaretShapes();
        updateAllSelectionShapes();
        updateBackgroundShapes();
    }

    private static class CustomCssShapeHelper {

        private final List> ranges = new LinkedList<>();
        private final List shapes = new LinkedList<>();

        private final Supplier createShape;
        private final BiConsumer> configureShape;
        private final Consumer addToChildren;
        private final Consumer> clearUnusedShapes;

        CustomCssShapeHelper(Supplier createShape, BiConsumer> configureShape,
                             Consumer addToChildren, Consumer> clearUnusedShapes) {
            this.createShape = createShape;
            this.configureShape = configureShape;
            this.addToChildren = addToChildren;
            this.clearUnusedShapes = clearUnusedShapes;
        }

        /**
         * Calculates the range of a value (background color, underline, etc.) that is shared between multiple
         * consecutive {@link TextExt} nodes
         */
        private void updateSharedShapeRange(T value, int start, int end, BiFunction equals) {
            Runnable addNewValueRange = () -> ranges.add(Tuples.t(value, new IndexRange(start, end)));

            if (ranges.isEmpty()) {
                addNewValueRange.run();;
            } else {
                int lastIndex = ranges.size() - 1;
                Tuple2 lastShapeValueRange = ranges.get(lastIndex);
                T lastShapeValue = lastShapeValueRange._1;

                // calculate smallest possible position which is consecutive to the given start position
                final int prevEndNext = lastShapeValueRange.get2().getEnd();
                if (start == prevEndNext &&                // Consecutive?
                    equals.apply(lastShapeValue, value)) { // Same style?

                    IndexRange lastRange = lastShapeValueRange._2;
                    IndexRange extendedRange = new IndexRange(lastRange.getStart(), end);
                    ranges.set(lastIndex, Tuples.t(lastShapeValue, extendedRange));
                } else {
                    addNewValueRange.run();
                }
            }
        }

        /**
         * Updates the shapes calculated in {@link #updateSharedShapeRange(Object, int, int, BiFunction)} and
         * configures them via {@code configureShape}.
         */
        private void updateSharedShapes() {
            // remove or add shapes, depending on what's needed
            int neededNumber = ranges.size();
            int availableNumber = shapes.size();

            if (neededNumber < availableNumber) {
                List unusedShapes = shapes.subList(neededNumber, availableNumber);
                clearUnusedShapes.accept(unusedShapes);
                unusedShapes.clear();
            } else if (availableNumber < neededNumber) {
                for (int i = 0; i < neededNumber - availableNumber; i++) {
                    Path shape = createShape.get();

                    shapes.add(shape);
                    addToChildren.accept(shape);
                }
            }

            // update the shape's color and elements
            for (int i = 0; i < ranges.size(); i++) {
                configureShape.accept(shapes.get(i), ranges.get(i));
            }

            // clear, since it's no longer needed
            ranges.clear();
        }
    }

    private static class BorderAttributes extends LineAttributesBase {

        final StrokeType type;

        BorderAttributes(TextExt text) {
            super(text.getBorderStrokeColor(), text.getBorderStrokeWidth(), text.borderStrokeDashArrayProperty());
            type = text.getBorderStrokeType();
        }

        /**
         * Same as {@link #equals(Object)} but no need to check the object for its class
         */
        public boolean equalsFaster(BorderAttributes attr) {
            return super.equalsFaster(attr) && Objects.equals(type, attr.type);
        }

        @Override
        public boolean equals(Object obj) {
            if (obj instanceof BorderAttributes) {
                BorderAttributes attributes = (BorderAttributes) obj;
                return equalsFaster(attributes);
            } else {
                return false;
            }
        }

        @Override
        public String toString() {
            return String.format("BorderAttributes[type=%s %s]", type, getSubString());
        }
    }

    private static class UnderlineAttributes extends LineAttributesBase {

        final StrokeLineCap cap;
        final double offset;
        final double waveRadius;

        UnderlineAttributes(TextExt text) {
            super(text.getUnderlineColor(), text.getUnderlineWidth(), text.underlineDashArrayProperty());
            cap = text.getUnderlineCap();
            Number waveNumber = text.getUnderlineWaveRadius();
            waveRadius = waveNumber == null ? 0 : waveNumber.doubleValue();
            Number offsetNumber = text.getUnderlineOffset();
            offset = offsetNumber == null ? waveRadius * 0.5 : offsetNumber.doubleValue();
            // The larger the radius the bigger the offset needs to be, so
            // a reasonable default is provided if no offset is specified.
        }

        /**
         * Same as {@link #equals(Object)} but no need to check the object for its class
         */
        public boolean equalsFaster(UnderlineAttributes attr) {
            return super.equalsFaster(attr) && Objects.equals(cap, attr.cap)
                   && offset == attr.offset && waveRadius == attr.waveRadius;
        }

        @Override
        public boolean equals(Object obj) {
            if (obj instanceof UnderlineAttributes) {
                UnderlineAttributes attr = (UnderlineAttributes) obj;
                return equalsFaster(attr);
            } else {
                return false;
            }
        }

        @Override
        public String toString() {
            return String.format("UnderlineAttributes[cap=%s %s]", cap, getSubString());
        }
    }

    private static class LineAttributesBase {

        final double width;
        final Paint color;
        final Double[] dashArray;

        public final boolean isNullValue() { return color == null || width == -1; }

        /**
         * Java Quirk! Using {@code t.get[border/underline]DashArray()} throws a ClassCastException
         * "Double cannot be cast to Number". However, using {@code t.getDashArrayProperty().get()}
         * works without issue
         */
        LineAttributesBase(Paint color, Number width, ObjectProperty dashArrayProp) {
            this.color = color;
            if (color == null || width == null || width.doubleValue() <= 0) {
                // null value
                this.width = -1;
                dashArray = null;
            } else {
                // real value
                this.width = width.doubleValue();

                // get the dash array - JavaFX CSS parser seems to return either a Number[] array
                // or a single value, depending on whether only one or more than one value has been
                // specified in the CSS
                Object dashArrayProperty = dashArrayProp.get();
                if (dashArrayProperty != null) {
                    if (dashArrayProperty.getClass().isArray()) {
                        Number[] numberArray = (Number[]) dashArrayProperty;
                        dashArray = new Double[numberArray.length];
                        int idx = 0;
                        for (Number d : numberArray) {
                            dashArray[idx++] = (Double) d;
                        }
                    } else {
                        dashArray = new Double[1];
                        dashArray[0] = ((Double) dashArrayProperty).doubleValue();
                    }
                } else {
                    dashArray = null;
                }
            }
        }

        /**
         * Same as {@link #equals(Object)} but no need to check the object for its class
         */
        public boolean equalsFaster(LineAttributesBase attr) {
            return Objects.equals(width, attr.width)
                    && Objects.equals(color, attr.color)
                    && Arrays.equals(dashArray, attr.dashArray);
        }

        @Override
        public boolean equals(Object obj) {
            if (obj instanceof LineAttributesBase) {
                LineAttributesBase attr = (LineAttributesBase) obj;
                return equalsFaster(attr);
            } else {
                return false;
            }
        }

        protected final String getSubString() {
            return String.format("width=%s color=%s dashArray=%s", width, color, Arrays.toString(dashArray));
        }
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy