com.gluonhq.richtextarea.model.PieceTable Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of rich-text-area Show documentation
Show all versions of rich-text-area Show documentation
Rich text control for JavaFX
The newest version!
/*
* Copyright (c) 2022, 2024, Gluon
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL GLUON BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package com.gluonhq.richtextarea.model;
import com.gluonhq.richtextarea.Selection;
import com.gluonhq.richtextarea.Tools;
import com.gluonhq.richtextarea.undo.AbstractCommand;
import com.gluonhq.richtextarea.undo.CommandManager;
import java.text.CharacterIterator;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import static com.gluonhq.richtextarea.model.TextBuffer.ZERO_WIDTH_TABLE_SEPARATOR;
import static com.gluonhq.richtextarea.model.TextBuffer.ZERO_WIDTH_TEXT;
/**
* Piece table implementation.
* More info at https://en.wikipedia.org/wiki/Piece_table
*/
public final class PieceTable extends AbstractTextBuffer {
final UnitBuffer originalText;
UnitBuffer additionBuffer = new UnitBuffer();
final List pieces = new ArrayList<>();
private final CommandManager commander = new CommandManager<>(this);
private final PieceCharacterIterator pieceCharacterIterator;
TextDecoration decorationAtCaret;
private DecorationModel dm = null;
/**
* Creates a piece table using the original text of a document, and
* sets the original unit buffer, that will contain one or more units.
* A document contains 0, 1 or more decorations.
* If there is no decoration present, for each unit a piece is defined, that
* spans over the length of the unit.
* If there are decorations, for each one, units are defined, pieces are defined, that spans over its length
* @param document model with decorated text to start with
*/
public PieceTable(Document document) {
String text = Objects.requireNonNull(Objects.requireNonNull(document).getText());
List decorations = document.getDecorations();
if (decorations == null || decorations.isEmpty()) {
decorations = List.of(new DecorationModel(0, text.length(), null, null));
}
this.originalText = new UnitBuffer(List.of());
// For each decoration in the document:
AtomicInteger accum = new AtomicInteger(0);
decorations.forEach(d -> {
// parse external text that spans the decoration into units
UnitBuffer units = UnitBuffer.convertTextToUnits(text.substring(d.getStart(), d.getStart() + d.getLength()));
if (units.isEmpty()) {
units.append(new TextUnit(""));
}
// For each unit present:
units.getUnitList().forEach(unit -> {
originalText.append(unit);
// create a new piece that spans the unit
pieces.add(new Piece(PieceTable.this, Piece.BufferType.ORIGINAL, accum.getAndAdd(unit.length()), unit.length(), d.getDecoration(), d.getParagraphDecoration()));
});
});
textLengthProperty.set(originalText.length());
pieceCharacterIterator = new PieceCharacterIterator(this);
}
/**
* Returns full text.
* This is a costly operation as it walks through all the pieces
* @return full text
*/
@Override
public String getText() {
return getText(0, getTextLength());
}
/**
* Returns partial text
* @param start start position within text, inclusive
* @param end end position within text, exclusive
* @return partial text
* @throws IllegalArgumentException if start or end are not in index range of the text
*/
@Override
public String getText(final int start, final int end) {
if (getTextLength() > 0 && !inRange(start, 0, getTextLength())) {
throw new IllegalArgumentException("Start index " + start + " is not in range [0, " + getTextLength() + ")");
}
if (end < 0) {
throw new IllegalArgumentException("End index is not in range");
}
StringBuilder textSB = new StringBuilder();
StringBuilder sb = new StringBuilder();
walkPieces((p, i, tp) -> {
Unit unit = p.getUnit();
sb.append(p.getInternalText());
if (start <= tp + p.length && end > tp && !unit.isEmpty()) {
String text = sb.substring(Math.max(start, tp), Math.min(end, tp + p.length));
if (!text.isEmpty()) {
textSB.append(unit instanceof TextUnit ? text : unit.getText());
}
}
return (end <= tp);
});
return textSB.toString();
}
private int s0, s1;
/**
* Converts a position or index referred to the exportable text into the
* position of internal text
*
* @param position the index from exportable text
* @return an index from internal text
*/
@Override
public int getInternalPosition(int position) {
if (position < 0) {
return position;
}
s0 = -1;
StringBuilder sb = new StringBuilder();
walkPieces((p, i, tp) -> {
Unit unit = p.getUnit();
int sbMin = sb.length();
int sbMax = sbMin + unit.getText().length();
if (sbMin <= position && position <= sbMax) {
s0 = tp + p.length;
}
sb.append(unit.getText());
return (s0 > -1);
});
return s0;
}
/**
* Converts the indices of a selection of exportable text into the
* indices of a selection of internal text
*
* @param selection a selection with indices from exportable text
* @return a selection with indices from internal text
*/
@Override
public Selection getInternalSelection(Selection selection) {
if (selection == null) {
throw new IllegalArgumentException("Selection can't be null");
}
if (!selection.isDefined()) {
return Selection.UNDEFINED;
}
int start = selection.getStart();
int end = selection.getEnd();
if (end < 0) {
throw new IllegalArgumentException("End index is not in range");
}
s0 = -1;
s1 = -1;
StringBuilder sb = new StringBuilder();
walkPieces((p, i, tp) -> {
Unit unit = p.getUnit();
int sbMin = sb.length();
int deltaMin = unit instanceof TextUnit ?
Math.max(0, start - sbMin) : 0;
int sbMax = Math.min(end, sbMin + unit.getText().length());
int deltaMax = unit instanceof TextUnit ?
Math.max(0, (sbMin + unit.getText().length()) - end) : 0;
if (sbMin <= start && start <= sbMax) {
s0 = tp + deltaMin;
}
if (sbMin <= end && end <= sbMax) {
s1 = tp + p.length - deltaMax;
}
sb.append(unit.getText());
return (s0 > -1 && s1 > -1);
});
return new Selection(s0, s1);
}
/**
* Gets the list of decoration models that decorate the text between a starting point
* and an ending position.
*
* @param start start position within text, inclusive
* @param end end position within text, exclusive
* @throws IllegalArgumentException if start or end are not in index range of the text
* @return a list of {@link DecorationModel}
*/
@Override
public List getDecorationModelList(int start, int end) {
if (getTextLength() > 0 && !inRange(start, 0, getTextLength())) {
throw new IllegalArgumentException("Start index " + start + " is not in range [0, " + getTextLength() + ")");
}
if (end < 0) {
throw new IllegalArgumentException("End index is not in range");
}
List mergedList = new ArrayList<>();
if (!pieces.isEmpty()) {
AtomicInteger accum = new AtomicInteger();
StringBuilder sb = new StringBuilder();
walkPieces((p, i, tp) -> {
Unit unit = p.getUnit();
sb.append(p.getInternalText());
if (start <= tp + p.length && end > tp && !unit.isEmpty()) {
String text = sb.substring(Math.max(start, tp), Math.min(end, tp + p.length));
int length = 0;
if (!text.isEmpty()) {
length = (unit instanceof TextUnit ? text : unit.getText()).length();
}
if (mergedList.isEmpty()) {
dm = new DecorationModel(0, length, p.getDecoration(), p.getParagraphDecoration());
} else if (p.getDecoration().equals(dm.getDecoration()) && p.getParagraphDecoration().equals(dm.getParagraphDecoration())) {
mergedList.remove(mergedList.size() - 1);
dm = new DecorationModel(dm.getStart(), dm.getLength() + length, dm.getDecoration(), dm.getParagraphDecoration());
} else {
dm = new DecorationModel(accum.addAndGet(dm.getLength()), length, p.getDecoration(), p.getParagraphDecoration());
}
mergedList.add(dm);
}
return (end <= tp);
});
}
if (mergedList.isEmpty()) {
// provide a default decoration
mergedList.add(DecorationModel.createDefaultDecorationModel(0));
}
return mergedList;
}
@Override
public CharacterIterator getCharacterIterator() {
return pieceCharacterIterator;
}
@Override
public char charAt(int pos) {
return pieceCharacterIterator.charAt(pos);
}
@Override
public List getLineFeeds() {
return pieceCharacterIterator.getLineFeedList();
}
@Override
public void resetCharacterIterator() {
pieceCharacterIterator.reset();
}
// internal append
List appendInternal(UnitBuffer unitBuffer, Decoration decoration, ParagraphDecoration paragraphDecoration) {
int pos = additionBuffer.length();
textLengthProperty.set(getTextLength() + unitBuffer.length());
AtomicInteger accum = new AtomicInteger(pos);
return unitBuffer.getUnitList().stream()
.peek(unit -> additionBuffer.append(unit))
.map(unit -> new Piece(this, Piece.BufferType.ADDITION, accum.getAndAdd(unit.length()), unit.length(), decoration, paragraphDecoration))
.collect(Collectors.toList());
}
/**
* Appends text
* @param text new text
*/
@Override
public void append(String text) {
commander.execute(new AppendCmd(text));
}
@Override
public void decorate(int start, int end, Decoration decoration) {
if (decoration instanceof TextDecoration) {
commander.execute(new TextDecorateCmd(start, end, decoration));
} else if (decoration instanceof ImageDecoration) {
commander.execute(new ImageDecorateCmd((ImageDecoration) decoration, start));
} else if (decoration instanceof ParagraphDecoration) {
commander.execute(new ParagraphDecorateCmd(start, end, (ParagraphDecoration) decoration));
} else {
throw new IllegalArgumentException("Decoration type not supported: " + decoration);
}
}
/**
* Walks through unit fragments. Each fragment is represented by related text and decoration
* @param onFragment callback to get fragment info
* @param start the initial position of the fragment
* @param end the end position of the fragment (not included)
*/
@Override
public void walkFragments(BiConsumer onFragment, int start, int end) {
StringBuilder sb = new StringBuilder();
walkPieces((p, i, tp) -> {
Unit unit = p.getUnit();
sb.append(p.getInternalText());
if (start <= tp + p.length && end > tp && !unit.isEmpty()) {
String text = sb.substring(Math.max(start, tp), Math.min(end, tp + p.length));
if (!text.isEmpty()) {
onFragment.accept(unit instanceof TextUnit ? new TextUnit(text) : unit, p.getDecoration());
}
}
return (end <= tp);
});
}
@Override
public Decoration getDecorationAtCaret(int caretPosition) {
int textPosition = 0;
int index = 0;
for (; index < pieces.size(); index++) {
Piece piece = pieces.get(index);
if (textPosition < caretPosition && caretPosition <= textPosition + piece.length) {
return piece.getDecoration();
}
textPosition += piece.length;
}
return previousPieceDecoration(index);
}
@Override
public ParagraphDecoration getParagraphDecorationAtCaret(int caretPosition) {
int textPosition = 0;
int index = 0;
for (; index < pieces.size(); index++) {
Piece piece = pieces.get(index);
if (textPosition <= caretPosition && caretPosition < textPosition + piece.length) {
return piece.getParagraphDecoration();
}
textPosition += piece.length;
}
ParagraphDecoration prevDecoration = previousPieceParagraphDecoration(index);
if (prevDecoration.hasTableDecoration()) {
// remove table decoration from the previous paragraph
return ParagraphDecoration.builder().fromDecoration(prevDecoration).tableDecoration(new TableDecoration()).build();
}
return prevDecoration;
}
@Override
public void setDecorationAtCaret(TextDecoration decoration) {
this.decorationAtCaret = decoration;
}
/**
* Inserts text at insertPosition
* @param text to insert
* @param insertPosition to insert text at
* @throws IllegalArgumentException if insertPosition is not valid
*/
@Override
public void insert(final String text, final int insertPosition) {
commander.execute(new InsertCmd(text, insertPosition));
}
/**
* Deletes text with 'length' starting at 'deletePosition'
* @param deletePosition deletePosition to start deletion from
* @param length length of text to delete
* @throws IllegalArgumentException if deletePosition is not valid
*/
@Override
public void delete(final int deletePosition, int length) {
commander.execute(new DeleteCmd(deletePosition, length));
}
/**
* Undo latest text modification
*/
@Override
public void undo() {
commander.undo();
}
@Override
public void redo() {
commander.redo();
}
/**
* Piece Table
* Piece A Piece B Piece C
* |_____||_______||__________|
*
* piece | pieceIndex | textPosition
* A | 0 | 0
* B | 1 | 5 (length of Piece A)
* C | 2 | 12 (length of Piece A + Piece B)
*/
@FunctionalInterface
interface WalkStep {
// process step, return true of walk has to be interrupted
boolean process(final Piece piece, final int pieceIndex, final int textPosition);
}
// Walks through pieces. Returns true if process was interrupted
void walkPieces(WalkStep step) {
int textPosition = 0;
for (int i = 0; i < pieces.size(); i++) {
Piece piece = pieces.get(i);
if (step.process(piece, i, textPosition)) {
return;
}
textPosition += piece.length;
}
}
// Normalized list of pieces
// Empty pieces purged
static Collection normalize(Collection pieces) {
return Objects.requireNonNull(pieces)
.stream()
.filter(b -> b == null || !b.isEmpty())
.collect(Collectors.toList());
}
// TODO is there standard APIs?
static boolean inRange( int index, int start, int length ) {
return index >= start && index < start+length;
}
Decoration previousPieceDecoration(int index) {
return pieces.isEmpty() || !(pieces.get(index > 0 ? index - 1 : 0).getDecoration() instanceof TextDecoration) ?
TextDecoration.builder().presets().build() : pieces.get(index > 0 ? index - 1 : 0).getDecoration();
}
ParagraphDecoration previousPieceParagraphDecoration(int index) {
return pieces.isEmpty() ?
ParagraphDecoration.builder().presets().build() : pieces.get(index > 0 ? index - 1 : 0).getParagraphDecoration();
}
@Override
public String toString() {
String p = pieces.stream().map(piece -> " - " + piece.toString()).collect(Collectors.joining("\n", "\n", ""));
return "PieceTable{\n O=\"" + Tools.formatTextWithAnchors(originalText.getInternalText()) + "\"" + "" +
",\n A=\"" + Tools.formatTextWithAnchors(additionBuffer.getInternalText()) + "\"" +
",\n L=" + getTextLength() +
", pieces ->" + p +
",\n OU -> " + originalText.getUnitList() +
",\n AU -> " + additionBuffer.getUnitList() +
"\n}";
}
}
class PieceCharacterIterator implements CharacterIterator {
private static final char LF = 0x0a;
private final PieceTable pt;
private int begin;
private int end;
private int pos;
private int[] posArray;
private List lineFeedList;
public PieceCharacterIterator(PieceTable pt) {
this.pt = Objects.requireNonNull(pt);
reset();
}
public void reset() {
this.begin = 0;
this.end = pt.getTextLength();
this.pos = 0;
posArray = new int[pt.pieces.size() + 1];
lineFeedList = new ArrayList<>();
StringBuilder sb = new StringBuilder();
pt.walkPieces((p, i, tp) -> {
sb.append(p.getInternalText());
String text = sb.substring(tp);
IntStream.iterate(text.indexOf(LF),
index -> index >= 0,
index -> text.indexOf(LF, index + 1))
.boxed()
.forEach(index -> lineFeedList.add(tp + index));
posArray[i] = tp;
return false;
});
posArray[pt.pieces.size()] = end;
}
public char charAt(int pos) {
if (pos < 0 || pos >= pt.getTextLength()) {
throw new IllegalArgumentException("Invalid pos value");
}
for (int i = 0; i < posArray.length; i++) {
if (posArray[i] <= pos && pos < posArray[i + 1]) {
char c = pt.pieces.get(i).getInternalText().charAt(pos - posArray[i]);
return c == ZERO_WIDTH_TABLE_SEPARATOR ? ' ' : c;
}
}
return 0;
}
public List getLineFeedList() {
return lineFeedList;
}
@Override
public char first() {
pos = begin;
return current();
}
@Override
public char last() {
if (end != begin) {
pos = end - 1;
} else {
pos = end;
}
return current();
}
@Override
public char current() {
if (pos >= begin && pos < end) {
return pt.charAt(pos);
} else {
return DONE;
}
}
@Override
public char next() {
if (pos < end - 1) {
pos++;
return pt.charAt(pos);
} else {
pos = end;
return DONE;
}
}
@Override
public char previous() {
if (pos > begin) {
pos--;
return pt.charAt(pos);
} else {
return DONE;
}
}
@Override
public char setIndex(int position) {
if (position < begin || position > end) {
throw new IllegalArgumentException("Invalid index");
}
pos = position;
return current();
}
@Override
public int getIndex() {
return pos;
}
@Override
public int getBeginIndex() {
return begin;
}
@Override
public int getEndIndex() {
return end;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PieceCharacterIterator that = (PieceCharacterIterator) o;
return begin == that.begin && end == that.end && pos == that.pos && Objects.equals(pt, that.pt);
}
@Override
public int hashCode() {
return Objects.hash(pt, begin, end, pos);
}
@Override
public Object clone() {
try {
return super.clone();
} catch (CloneNotSupportedException e) {
throw new IllegalArgumentException("Clone exception");
}
}
}
abstract class AbstractPTCmd extends AbstractCommand {}
class AppendCmd extends AbstractPTCmd {
private final UnitBuffer unitBuffer;
private List newPieces;
private boolean execSuccess = false;
AppendCmd(String text) {
this.unitBuffer = UnitBuffer.convertTextToUnits(Objects.requireNonNull(text));
}
@Override
protected void doUndo(PieceTable pt) {
if (execSuccess) {
pt.pieces.removeAll(newPieces);
pt.fire(new TextBuffer.DeleteEvent(pt.getTextLength() - unitBuffer.length(), unitBuffer.length()));
pt.textLengthProperty.set(pt.getTextLength() - unitBuffer.length());
}
}
@Override
protected void doRedo(PieceTable pt) {
if (!unitBuffer.isEmpty()) {
int pos = pt.getTextLength();
newPieces = pt.appendInternal(unitBuffer,
pt.decorationAtCaret != null ?
pt.decorationAtCaret : pt.previousPieceDecoration(pt.pieces.size()),
pt.getParagraphDecorationAtCaret(pos) != null ?
pt.getParagraphDecorationAtCaret(pos) : pt.previousPieceParagraphDecoration(pt.pieces.size()));
pt.pieces.addAll(newPieces);
pt.fire(new TextBuffer.InsertEvent(unitBuffer.getInternalText(), pos));
execSuccess = true;
}
}
@Override
public String toString() {
return "AppendCmd[\"" + unitBuffer + "\"]";
}
}
class InsertCmd extends AbstractCommand {
private final UnitBuffer unitBuffer;
private final int insertPosition;
private Collection newPieces;
private Piece oldPiece;
private int opPieceIndex;
private boolean execSuccess = false;
InsertCmd(String text, int insertPosition) {
this.unitBuffer = UnitBuffer.convertTextToUnits(Objects.requireNonNull(text));
this.insertPosition = insertPosition;
}
@Override
protected void doUndo(PieceTable pt) {
if (execSuccess) {
pt.pieces.add(opPieceIndex, oldPiece);
pt.pieces.removeAll(newPieces);
pt.fire(new TextBuffer.DeleteEvent(insertPosition, unitBuffer.length()));
pt.textLengthProperty.set(pt.getTextLength() - unitBuffer.length());
}
}
@Override
protected void doRedo(PieceTable pt) {
if (unitBuffer.isEmpty()) {
return; // no need to insert empty text
}
if (insertPosition < 0 || insertPosition > pt.getTextLength()) {
throw new IllegalArgumentException("Position is outside text bounds");
}
if (insertPosition == pt.getTextLength()) {
pt.append(unitBuffer.getInternalText());
} else {
pt.walkPieces((piece, pieceIndex, textPosition) -> {
if (PieceTable.inRange(insertPosition, textPosition, piece.length)) {
int pieceOffset = insertPosition - textPosition;
final Decoration decoration = pieceOffset > 0 ? (TextDecoration) piece.getDecoration() : pt.previousPieceDecoration(pieceIndex);
final ParagraphDecoration paragraphDecoration = pt.getParagraphDecorationAtCaret(insertPosition) != null ?
pt.getParagraphDecorationAtCaret(insertPosition) : pt.previousPieceParagraphDecoration(pieceIndex);
List pieces = pt.appendInternal(unitBuffer, pt.decorationAtCaret != null ? pt.decorationAtCaret : decoration, paragraphDecoration);
List allPieces = new ArrayList<>(List.of(piece.pieceBefore(pieceOffset)));
allPieces.addAll(pieces);
allPieces.add(piece.pieceFrom(pieceOffset));
newPieces = PieceTable.normalize(allPieces);
oldPiece = piece;
pt.pieces.addAll(pieceIndex, newPieces);
pt.pieces.remove(oldPiece);
opPieceIndex = pieceIndex;
pt.fire(new TextBuffer.InsertEvent(unitBuffer.getInternalText(), insertPosition));
execSuccess = true;
return true;
}
return false;
});
}
}
@Override
public String toString() {
return "InsertCmd[\"" + unitBuffer + "\" at " + insertPosition + "]";
}
}
class DeleteCmd extends AbstractCommand {
private final int deletePosition;
private int length;
private boolean execSuccess = false;
private int pieceIndex = -1;
private Collection newPieces;
private Collection oldPieces;
/**
* Command to delete units starting from an index position to a given length.
* @param deletePosition position from where delete operation is to be executed. Normally, this is position of the caret.
* @param length Length of the unit following the deletePosition to delete.
*/
DeleteCmd(int deletePosition, int length) {
this.deletePosition = deletePosition;
this.length = length;
}
@Override
protected void doUndo(PieceTable pt) {
if (execSuccess) {
pt.pieces.addAll(pieceIndex, oldPieces);
pt.pieces.removeAll(newPieces);
String text = oldPieces.stream()
.map(Piece::getInternalText)
.reduce( "", (id, s) -> id + s );
pt.textLengthProperty.set(pt.getTextLength() + length);
pt.fire(new TextBuffer.InsertEvent(text, deletePosition));
}
}
@Override
protected void doRedo(PieceTable pt) {
if (deletePosition < 0 || deletePosition > pt.getTextLength()) {
throw new IllegalArgumentException("Position " + deletePosition + " is outside of text bounds [0," + pt.getTextLength() +"]");
}
// Accept length larger than actual and adjust it to actual
if ((deletePosition + length) >= pt.getTextLength()) {
length = pt.getTextLength() - deletePosition;
}
int endPosition = deletePosition + length;
final int[] startPieceIndex = new int[1];
final List additions = new ArrayList<>(); // start and end pieces
final List removals = new ArrayList<>();
pt.walkPieces((piece, pieceIndex, textPosition) -> {
if (PieceTable.inRange(deletePosition, textPosition, piece.length)) {
int pieceOffset = deletePosition - textPosition;
startPieceIndex[0] = pieceIndex;
additions.add(piece.pieceBefore(pieceOffset));
removals.add(piece);
}
if (!additions.isEmpty()) {
if (!removals.contains(piece)) {
removals.add(piece);
}
if (PieceTable.inRange(endPosition, textPosition, piece.length)) {
// the next piece after the deletion point should use the paragraph decoration from the previous piece, if any
int offset = endPosition - textPosition;
ParagraphDecoration paragraphDecoration = additions.get(additions.size() - 1).getParagraphDecoration();
Piece nextPiece = piece.copy(piece.start + offset, piece.length - offset, piece.decoration,
paragraphDecoration == null ? piece.paragraphDecoration : paragraphDecoration);
additions.add(nextPiece);
return true;
}
}
return false;
});
newPieces = PieceTable.normalize(additions);
oldPieces = removals;
if (newPieces.size() > 0 || oldPieces.size() > 0) { // split actually happened
pieceIndex = startPieceIndex[0];
pt.pieces.addAll(pieceIndex, newPieces);
pt.pieces.removeAll(oldPieces);
pt.textLengthProperty.set(pt.getTextLength() - length);
pt.fire(new TextBuffer.DeleteEvent(deletePosition, length));
execSuccess = true;
}
}
@Override
public String toString() {
return "DeleteCmd[" + deletePosition + " x " + length + "]";
}
}
class ImageDecorateCmd extends AbstractCommand {
private final ImageDecoration decoration;
private final UnitBuffer unitBuffer;
private final int insertPosition;
private boolean execSuccess = false;
private List newPiece;
private Piece oldPiece;
private int opPieceIndex;
private Collection newPieces = new ArrayList<>();
/**
* Inserts an image at the given insertion point
* @param decoration the image decoration
* @param insertPosition index of the character to decorate
*/
ImageDecorateCmd(ImageDecoration decoration, int insertPosition) {
this.decoration = decoration;
this.insertPosition = insertPosition;
this.unitBuffer = new UnitBuffer(new ImageUnit(decoration.getUrl()));
}
@Override
protected void doUndo(PieceTable pt) {
if (execSuccess) {
if (newPiece != null) {
pt.pieces.removeAll(newPiece);
pt.fire(new TextBuffer.DeleteEvent(pt.getTextLength() - 1, unitBuffer.length()));
} else {
pt.pieces.add(opPieceIndex, oldPiece);
pt.pieces.removeAll(newPieces);
pt.fire(new TextBuffer.DeleteEvent(insertPosition, unitBuffer.length()));
}
pt.textLengthProperty.set(pt.getTextLength() - 1);
}
}
@Override
protected void doRedo(PieceTable pt) {
if (insertPosition < 0 || insertPosition > pt.getTextLength()) {
throw new IllegalArgumentException("Position " + insertPosition + " is outside of text bounds [0, " + pt.getTextLength() + "]");
}
final ParagraphDecoration paragraphDecoration = pt.getParagraphDecorationAtCaret(insertPosition) != null ?
pt.getParagraphDecorationAtCaret(insertPosition) : pt.previousPieceParagraphDecoration(insertPosition);
if (insertPosition == pt.getTextLength()) {
int pos = pt.getTextLength();
newPiece = pt.appendInternal(unitBuffer, decoration, paragraphDecoration);
pt.pieces.addAll(newPiece);
pt.fire(new TextBuffer.InsertEvent(ZERO_WIDTH_TEXT, pos));
execSuccess = true;
} else {
pt.walkPieces((piece, pieceIndex, textPosition) -> {
if (PieceTable.inRange(insertPosition, textPosition, piece.length)) {
int pieceOffset = insertPosition - textPosition;
List pieces = pt.appendInternal(unitBuffer, decoration, paragraphDecoration);
List allPieces = new ArrayList<>(List.of(piece.pieceBefore(pieceOffset)));
allPieces.addAll(pieces);
allPieces.add(piece.pieceFrom(pieceOffset));
newPieces = PieceTable.normalize(allPieces);
oldPiece = piece;
pt.pieces.addAll(pieceIndex, newPieces);
pt.pieces.remove(oldPiece);
opPieceIndex = pieceIndex;
pt.fire(new TextBuffer.InsertEvent(unitBuffer.getInternalText(), insertPosition));
execSuccess = true;
return true;
}
return false;
});
}
}
@Override
public String toString() {
return "ImageDecorateCmd[" + decoration + " at " + insertPosition + "]";
}
}
class TextDecorateCmd extends AbstractCommand {
private int start;
private int end;
private final Decoration decoration;
private boolean execSuccess = false;
private int pieceIndex = -1;
private Collection newPieces = new ArrayList<>();
private Collection oldPieces = new ArrayList<>();
/**
* Decorates the text within the given range with the supplied decoration.
* @param start index of the first character to decorate
* @param end index of the last character to decorate
* @param decoration Decorations to apply on the selected text
*/
TextDecorateCmd(int start, int end, Decoration decoration) {
this.start = start;
this.end = end;
this.decoration = decoration;
}
@Override
protected void doUndo(PieceTable pt) {
if (execSuccess) {
pt.pieces.addAll(pieceIndex, oldPieces);
pt.pieces.removeAll(newPieces);
oldPieces.forEach(piece -> {
pt.fire(new TextBuffer.DecorateEvent(piece.start, piece.start + piece.length, piece.decoration));
});
}
}
@Override
protected void doRedo(PieceTable pt) {
if (!PieceTable.inRange(start, 0, pt.getTextLength())) {
throw new IllegalArgumentException("Position " + start + " is outside of text bounds [0, " + pt.getTextLength() + ")");
}
// Accept length larger than actual and adjust it to actual
if (end >= pt.getTextLength()) {
end = pt.getTextLength();
}
final int[] startPieceIndex = new int[1];
final List additions = new ArrayList<>(); // start and end pieces
final List removals = new ArrayList<>();
pt.walkPieces((piece, pieceIndex, textPosition) -> {
if (isPieceInSelection(piece, textPosition)) {
startPieceIndex[0] = pieceIndex;
if (textPosition <= start) {
int offset = start - textPosition;
int length;
if (textPosition + piece.length > end) {
length = Math.min(end - start, piece.length); // selection ends in current piece
} else {
length = piece.length - offset; // selection spans over next piece(s)
}
if (offset > 0) {
additions.add(piece.pieceBefore(offset));
}
additions.add(piece.copy(piece.start + offset, length, decoration));
if (end < textPosition + piece.length) {
additions.add(piece.pieceFrom(end - textPosition));
}
removals.add(piece);
} else if (textPosition + piece.length <= end) { // entire piece is in selection
additions.add(piece.copy(piece.start, piece.length, decoration));
removals.add(piece);
} else if (textPosition < end) {
int offset = end - textPosition;
additions.add(piece.copy(piece.start, offset, decoration));
additions.add(piece.pieceFrom(offset));
removals.add(piece);
}
}
return false;
});
newPieces = PieceTable.normalize(additions);
oldPieces = removals;
if (newPieces.size() > 0 || oldPieces.size() > 0) {
pieceIndex = startPieceIndex[0];
pt.pieces.addAll(pieceIndex, newPieces);
pt.pieces.removeAll(oldPieces);
pt.fire(new TextBuffer.DecorateEvent(start, end, decoration));
execSuccess = true;
}
}
private boolean isPieceInSelection(Piece piece, int textPosition) {
int pieceEndPosition = textPosition + piece.length - 1;
return start <= pieceEndPosition && (end >= pieceEndPosition || end >= textPosition);
}
@Override
public String toString() {
return "TextDecorateCmd[" + start +
" x " + end + "]";
}
}
class ParagraphDecorateCmd extends AbstractCommand {
private int start;
private int end;
private final ParagraphDecoration paragraphDecoration;
private boolean execSuccess = false;
private int pieceIndex = -1;
private Collection newPieces = new ArrayList<>();
private Collection oldPieces = new ArrayList<>();
/**
* Decorates the text within the given paragraph with the supplied decoration.
* @param start index of the first character to decorate
* @param end index of the last character to decorate
* @param paragraphDecoration Decorations to apply on the selected paragraph
*/
ParagraphDecorateCmd(int start, int end, ParagraphDecoration paragraphDecoration) {
this.start = start;
this.end = end;
this.paragraphDecoration = paragraphDecoration;
}
@Override
protected void doUndo(PieceTable pt) {
if (execSuccess) {
pt.pieces.addAll(pieceIndex, oldPieces);
pt.pieces.removeAll(newPieces);
oldPieces.forEach(piece -> {
pt.fire(new TextBuffer.DecorateEvent(piece.start, piece.start + piece.length, piece.decoration));
});
}
}
@Override
protected void doRedo(PieceTable pt) {
if (!PieceTable.inRange(start, 0, pt.getTextLength() + 1)) {
throw new IllegalArgumentException("Position " + start + " is outside of text bounds [0, " + pt.getTextLength() + ")");
}
// Accept length larger than actual and adjust it to actual
if (end >= pt.getTextLength()) {
end = pt.getTextLength();
}
final int[] startPieceIndex = new int[1];
final List additions = new ArrayList<>(); // start and end pieces
final List removals = new ArrayList<>();
pt.walkPieces((piece, pieceIndex, textPosition) -> {
int pieceEndPosition = textPosition + piece.length + (start == pt.getTextLength() ? 0 : - 1);
if (start <= pieceEndPosition && (end >= pieceEndPosition || end >= textPosition)) {
startPieceIndex[0] = pieceIndex;
if (start == pt.getTextLength()) {
int offset = start - textPosition;
if (offset > 0) {
additions.add(piece.copy(piece.start, offset));
}
additions.add(piece.copy(start, 0, piece.decoration, paragraphDecoration));
removals.add(piece);
} else if (textPosition <= start) {
int offset = start - textPosition;
int length;
if (textPosition + piece.length > end) {
length = Math.min(end - start, piece.length); // selection ends in current piece
} else {
length = piece.length - offset; // selection spans over next piece(s)
}
if (offset > 0) {
additions.add(piece.pieceBefore(offset));
}
additions.add(piece.copy(piece.start + offset, length, piece.decoration, paragraphDecoration));
if (end < textPosition + piece.length) {
additions.add(piece.pieceFrom(end - textPosition));
}
removals.add(piece);
} else if (textPosition + piece.length <= end) { // entire piece is in selection
additions.add(piece.copy(piece.start, piece.length, piece.decoration, paragraphDecoration));
removals.add(piece);
} else if (textPosition < end) {
int offset = end - textPosition;
additions.add(piece.copy(piece.start, offset, piece.decoration, paragraphDecoration));
additions.add(piece.pieceFrom(offset));
removals.add(piece);
}
}
return false;
});
newPieces = additions.stream()
.filter(Objects::nonNull)
.collect(Collectors.toList());
oldPieces = removals;
if (newPieces.size() > 0 || oldPieces.size() > 0) {
pieceIndex = startPieceIndex[0];
pt.pieces.addAll(pieceIndex, newPieces);
pt.pieces.removeAll(oldPieces);
pt.fire(new TextBuffer.DecorateEvent(start, end, paragraphDecoration));
execSuccess = true;
}
}
private boolean isPieceInSelection(Piece piece, int textPosition) {
int pieceEndPosition = textPosition + piece.length - 1;
return start <= pieceEndPosition && (end >= pieceEndPosition || end >= textPosition);
}
@Override
public String toString() {
return "ParagraphDecorateCmd[" + start + " x " + end + "]";
}
}