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

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

There is a newer version: 0.11.3
Show newest version
package org.fxmisc.richtext;

import static org.fxmisc.richtext.ReadOnlyStyledDocument.ParagraphsPolicy.*;
import static org.fxmisc.richtext.TwoDimensional.Bias.*;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;

import javafx.beans.binding.Bindings;
import javafx.beans.binding.StringBinding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;

import org.fxmisc.richtext.ReadOnlyStyledDocument.ParagraphsPolicy;
import org.reactfx.EventSource;
import org.reactfx.EventStream;
import org.reactfx.EventStreams;
import org.reactfx.Guard;
import org.reactfx.value.SuspendableVar;
import org.reactfx.value.Val;
import org.reactfx.value.Var;

/**
 * Content model for {@link StyledTextArea}. Implements edit operations
 * on styled text, but not worrying about additional aspects such as
 * caret or selection.
 */
final class EditableStyledDocument
extends StyledDocumentBase>> {

    /* ********************************************************************** *
     *                                                                        *
     * Observables                                                            *
     *                                                                        *
     * Observables are "dynamic" (i.e. changing) characteristics of an object.*
     * They are not directly settable by the client code, but change in       *
     * response to user input and/or API actions.                             *
     *                                                                        *
     * ********************************************************************** */

    /**
     * Content of this {@code StyledDocument}.
     */
    private final StringBinding text = Bindings.createStringBinding(() -> getText(0, length()));
    @Override
    public String getText() { return text.getValue(); }
    public ObservableValue textProperty() { return text; }

    /**
     * Length of this {@code StyledDocument}.
     */
    private final SuspendableVar length = Var.newSimpleVar(0).suspendable();
    public int getLength() { return length.getValue(); }
    public Val lengthProperty() { return length; }
    @Override
    public int length() { return length.getValue(); }

    /**
     * Unmodifiable observable list of styled paragraphs of this document.
     */
    @Override
    public ObservableList> getParagraphs() {
        return FXCollections.unmodifiableObservableList(paragraphs);
    }

    /**
     * Read-only snapshot of the current state of this document.
     */
    public ReadOnlyStyledDocument snapshot() {
        return new ReadOnlyStyledDocument<>(paragraphs, ParagraphsPolicy.COPY);
    }


    /* ********************************************************************** *
     *                                                                        *
     * Event streams                                                          *
     *                                                                        *
     * ********************************************************************** */

    // To publish a text change:
    //   1. push to textChangePosition,
    //   2. push to textRemovalEnd,
    //   3. push to insertedText.
    //
    // To publish a rich change:
    //   a)
    //     1. push to textChangePosition,
    //     2. push to textRemovalEnd,
    //     3. push to insertedDocument;
    //   b)
    //     1. push to styleChangePosition
    //     2. push to styleChangeEnd
    //     3. push to styleChangeDone.

    private final EventSource textChangePosition = new EventSource<>();
    private final EventSource styleChangePosition = new EventSource<>();

    private final EventSource textRemovalEnd = new EventSource<>();
    private final EventSource styleChangeEnd = new EventSource<>();

    private final EventSource insertedText = new EventSource<>();

    private final EventSource> insertedDocument = new EventSource<>();
    private final EventSource styleChangeDone = new EventSource<>();

    private final EventStream plainTextChanges;
    public EventStream plainTextChanges() { return plainTextChanges; }

    private final EventStream> richChanges;
    public EventStream> richChanges() { return richChanges; }

    {
        EventStream removedText = EventStreams.zip(textChangePosition, textRemovalEnd).map(t2 -> t2.map((a, b) -> getText(a, b)));
        EventStream changePosition = EventStreams.merge(textChangePosition, styleChangePosition);
        EventStream removalEnd = EventStreams.merge(textRemovalEnd, styleChangeEnd);
        EventStream> removedDocument = EventStreams.zip(changePosition, removalEnd).map(t2 -> t2.map((a, b) -> subSequence(a, b)));
        EventStream insertionEnd = styleChangeEnd.emitOn(styleChangeDone);
        EventStream> insertedDocument = EventStreams.merge(
                this.insertedDocument,
                changePosition.emitBothOnEach(insertionEnd).map(t2 -> t2.map((a, b) -> subSequence(a, b))));

        plainTextChanges = EventStreams.zip(textChangePosition, removedText, insertedText)
                .filter(t3 -> t3.map((pos, removed, inserted) -> !removed.equals(inserted)))
                .map(t3 -> t3.map((pos, removed, inserted) -> new PlainTextChange(pos, removed, inserted)));

        richChanges = EventStreams.zip(changePosition, removedDocument, insertedDocument)
                .filter(t3 -> t3.map((pos, removed, inserted) -> !removed.equals(inserted)))
                .map(t3 -> t3.map((pos, removed, inserted) -> new RichTextChange(pos, removed, inserted)));
    }


    /* ********************************************************************** *
     *                                                                        *
     * Properties                                                             *
     *                                                                        *
     * ********************************************************************** */

    final BooleanProperty useInitialStyleForInsertion = new SimpleBooleanProperty();


    /* ********************************************************************** *
     *                                                                        *
     * Fields                                                                 *
     *                                                                        *
     * ********************************************************************** */

    private final S initialStyle;


    /* ********************************************************************** *
     *                                                                        *
     * Constructors                                                           *
     *                                                                        *
     * ********************************************************************** */

    @SuppressWarnings("unchecked")
    EditableStyledDocument(S initialStyle) {
        super(FXCollections.observableArrayList(new Paragraph("", initialStyle)));
        this.initialStyle = initialStyle;
    }


    /* ********************************************************************** *
     *                                                                        *
     * Actions                                                                *
     *                                                                        *
     * Actions change the state of the object. They typically cause a change  *
     * of one or more observables and/or produce an event.                    *
     *                                                                        *
     * ********************************************************************** */

    public void replaceText(int start, int end, String text) {
        StyledDocument doc = ReadOnlyStyledDocument.fromString(
                text, getStyleForInsertionAt(start));
        replace(start, end, doc);
    }

    public void replace(int start, int end, StyledDocument replacement) {
        ensureValidRange(start, end);

        textChangePosition.push(start);
        textRemovalEnd.push(end);

        Position start2D = navigator.offsetToPosition(start, Forward);
        Position end2D = start2D.offsetBy(end - start, Forward);
        int firstParIdx = start2D.getMajor();
        int firstParFrom = start2D.getMinor();
        int lastParIdx = end2D.getMajor();
        int lastParTo = end2D.getMinor();

        // Get the leftovers after cutting out the deletion
        Paragraph firstPar = paragraphs.get(firstParIdx).trim(firstParFrom);
        Paragraph lastPar = paragraphs.get(lastParIdx).subSequence(lastParTo);

        List> replacementPars = replacement.getParagraphs();

        List> newPars = join(firstPar, replacementPars, lastPar);
        setAll(firstParIdx, lastParIdx + 1, newPars);

        // update length, invalidate text
        int replacementLength =
                replacementPars.stream().mapToInt(Paragraph::length).sum() +
                replacementPars.size() - 1;
        int newLength = length.getValue() - (end - start) + replacementLength;
        length.suspendWhile(() -> { // don't publish length change until text is invalidated
            length.setValue(newLength);
            text.invalidate();
        });

        // complete the change events
        insertedText.push(replacement.toString());
        StyledDocument doc =
                replacement instanceof ReadOnlyStyledDocument
                ? replacement
                : new ReadOnlyStyledDocument<>(replacement.getParagraphs(), COPY);
        insertedDocument.push(doc);
    }

    public void setStyle(int from, int to, S style) {
        ensureValidRange(from, to);

        try(Guard commitOnClose = beginStyleChange(from, to)) {
            Position start = navigator.offsetToPosition(from, Forward);
            Position end = to == from
                    ? start
                    : start.offsetBy(to - from, Backward);
            int firstParIdx = start.getMajor();
            int firstParFrom = start.getMinor();
            int lastParIdx = end.getMajor();
            int lastParTo = end.getMinor();

            if(firstParIdx == lastParIdx) {
                Paragraph p = paragraphs.get(firstParIdx);
                p = p.restyle(firstParFrom, lastParTo, style);
                paragraphs.set(firstParIdx, p);
            } else {
                int affectedPars = lastParIdx - firstParIdx + 1;
                List> restyledPars = new ArrayList<>(affectedPars);

                Paragraph firstPar = paragraphs.get(firstParIdx);
                restyledPars.add(firstPar.restyle(firstParFrom, firstPar.length(), style));

                for(int i = firstParIdx + 1; i < lastParIdx; ++i) {
                    Paragraph p = paragraphs.get(i);
                    restyledPars.add(p.restyle(style));
                }

                Paragraph lastPar = paragraphs.get(lastParIdx);
                restyledPars.add(lastPar.restyle(0, lastParTo, style));

                setAll(firstParIdx, lastParIdx + 1, restyledPars);
            }
        }
    }

    public void setStyle(int paragraph, S style) {
        Paragraph p = paragraphs.get(paragraph);
        int start = position(paragraph, 0).toOffset();
        int end = start + p.length();

        try(Guard commitOnClose = beginStyleChange(start, end)) {
            p = p.restyle(style);
            paragraphs.set(paragraph, p);
        }
    }

    public void setStyle(int paragraph, int fromCol, int toCol, S style) {
        ensureValidParagraphRange(paragraph, fromCol, toCol);
        int parOffset = position(paragraph, 0).toOffset();
        int start = parOffset + fromCol;
        int end = parOffset + toCol;

        try(Guard commitOnClose = beginStyleChange(start, end)) {
            Paragraph p = paragraphs.get(paragraph);
            p = p.restyle(fromCol, toCol, style);
            paragraphs.set(paragraph, p);
        }
    }

    public void setStyleSpans(int from, StyleSpans styleSpans) {
        int len = styleSpans.length();
        ensureValidRange(from, from + len);

        Position start = offsetToPosition(from, Forward);
        Position end = start.offsetBy(len, Backward);
        int skip = terminatorLengthToSkip(start);
        int trim = terminatorLengthToTrim(end);
        if(skip + trim >= len) {
            return;
        } else if(skip + trim > 0) {
            styleSpans = styleSpans.subView(skip, len - trim);
            len -= skip + trim;
            from += skip;
            start = start.offsetBy(skip, Forward);
            end = end.offsetBy(-trim, Backward);
        }

        try(Guard commitOnClose = beginStyleChange(from, from + len)) {
            int firstParIdx = start.getMajor();
            int firstParFrom = start.getMinor();
            int lastParIdx = end.getMajor();
            int lastParTo = end.getMinor();

            if(firstParIdx == lastParIdx) {
                Paragraph p = paragraphs.get(firstParIdx);
                Paragraph q = p.restyle(firstParFrom, styleSpans);
                if(q != p) {
                    paragraphs.set(firstParIdx, q);
                }
            } else {
                Paragraph firstPar = paragraphs.get(firstParIdx);
                Position spansFrom = styleSpans.position(0, 0);
                Position spansTo = spansFrom.offsetBy(firstPar.length() - firstParFrom, Backward);
                Paragraph q = firstPar.restyle(firstParFrom, styleSpans.subView(spansFrom, spansTo));
                if(q != firstPar) {
                    paragraphs.set(firstParIdx, q);
                }
                spansFrom = spansTo.offsetBy(1, Forward); // skip the newline

                for(int i = firstParIdx + 1; i < lastParIdx; ++i) {
                    Paragraph par = paragraphs.get(i);
                    spansTo = spansFrom.offsetBy(par.length(), Backward);
                    q = par.restyle(0, styleSpans.subView(spansFrom, spansTo));
                    if(q != par) {
                        paragraphs.set(i, q);
                    }
                    spansFrom = spansTo.offsetBy(1, Forward); // skip the newline
                }

                Paragraph lastPar = paragraphs.get(lastParIdx);
                spansTo = spansFrom.offsetBy(lastParTo, Backward);
                q = lastPar.restyle(0, styleSpans.subView(spansFrom, spansTo));
                if(q != lastPar) {
                    paragraphs.set(lastParIdx, q);
                }
            }
        }
    }

    public void setStyleSpans(int paragraph, int from, StyleSpans styleSpans) {
        int len = styleSpans.length();
        ensureValidParagraphRange(paragraph, from, len);
        int parOffset = position(paragraph, 0).toOffset();
        int start = parOffset + from;
        int end = start + len;

        try(Guard commitOnClose = beginStyleChange(start, end)) {
            Paragraph p = paragraphs.get(paragraph);
            Paragraph q = p.restyle(from, styleSpans);
            if(q != p) {
                paragraphs.set(paragraph, q);
            }
        }
    }


    /* ********************************************************************** *
     *                                                                        *
     * Private and package private methods                                    *
     *                                                                        *
     * ********************************************************************** */

    private void ensureValidRange(int start, int end) {
        ensureValidRange(start, end, length());
    }

    private void ensureValidParagraphRange(int par, int start, int end) {
        if(par < 0 || par >= paragraphs.size()) {
            throw new IllegalArgumentException(par + " is not a valid paragraph index. Must be from [0, " + paragraphs.size() + ")");
        }
        ensureValidRange(start, end, fullLength(par));
    }

    private int fullLength(int par) {
        int n = paragraphs.size();
        return paragraphs.get(par).length() + (par == n-1 ? 0 : 1);
    }

    private void ensureValidRange(int start, int end, int len) {
        if(start < 0) {
            throw new IllegalArgumentException("start cannot be negative: " + start);
        }
        if(end > len) {
            throw new IllegalArgumentException("end is greater than length: " + end + " > " + len);
        }
        if(start > end) {
            throw new IllegalArgumentException("start is greater than end: " + start + " > " + end);
        }
    }

    private int terminatorLengthToSkip(Position pos) {
        Paragraph par = paragraphs.get(pos.getMajor());
        int skipSum = 0;
        while(pos.getMinor() == par.length() && pos.getMajor() < paragraphs.size() - 1) {
            skipSum += 1;
            pos = pos.offsetBy(1, Forward); // will jump to the next paragraph
            par = paragraphs.get(pos.getMajor());
        }
        return skipSum;
    }

    private int terminatorLengthToTrim(Position pos) {
        int parLen = paragraphs.get(pos.getMajor()).length();
        int trimSum = 0;
        while(pos.getMinor() > parLen) {
            assert pos.getMinor() - parLen == 1;
            trimSum += 1;
            pos = pos.offsetBy(-1, Backward); // may jump to the end of previous paragraph, if parLen was 0
            parLen = paragraphs.get(pos.getMajor()).length();
        }
        return trimSum;
    }

    private Guard beginStyleChange(int start, int end) {
        styleChangePosition.push(start);
        styleChangeEnd.push(end);
        return () -> styleChangeDone.push(null);
    }

    private List> join(Paragraph first, List> middle, Paragraph last) {
        int m = middle.size();
        if(m == 0) {
            return Arrays.asList(first.concat(last));
        } else if(m == 1) {
            return Arrays.asList(first.concat(middle.get(0)).concat(last));
        } else {
            List> res = new ArrayList<>(middle.size());
            res.add(first.concat(middle.get(0)));
            res.addAll(middle.subList(1, m - 1));
            res.add(middle.get(m-1).concat(last));
            return res;
        }
    }

    // TODO: Replace with ObservableList.setAll(from, to, col) when implemented.
    // See https://javafx-jira.kenai.com/browse/RT-32655.
    private void setAll(int startIdx, int endIdx, Collection> pars) {
        if(startIdx > 0 || endIdx < paragraphs.size()) {
            paragraphs.subList(startIdx, endIdx).clear(); // note that paragraphs remains non-empty at all times
            paragraphs.addAll(startIdx, pars);
        } else {
            paragraphs.setAll(pars);
        }
    }

    S getStyleForInsertionAt(int pos) {
        return getStyleForInsertionAt(navigator.offsetToPosition(pos, Forward));
    }

    S getStyleForInsertionAt(Position insertionPos) {
        if(useInitialStyleForInsertion.get()) {
            return initialStyle;
        } else {
            Paragraph par = paragraphs.get(insertionPos.getMajor());
            return par.getStyleAtPosition(insertionPos.getMinor());
        }
    }
}