org.fxmisc.richtext.EditableStyledDocument Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of richtextfx Show documentation
Show all versions of richtextfx Show documentation
Rich-text area for JavaFX
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 extends S> 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 extends S> 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());
}
}
}