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

org.fxmisc.richtext.model.Paragraph Maven / Gradle / Ivy

package org.fxmisc.richtext.model;

import static org.fxmisc.richtext.model.TwoDimensional.Bias.*;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

import javafx.scene.control.IndexRange;

import org.fxmisc.richtext.model.TwoDimensional.Position;
import org.reactfx.util.Tuple2;
import org.reactfx.util.Tuples;

/**
 * One paragraph in the document that can itself be styled and which contains a list of styled segments.
 *
 * 

* It corresponds to a single line when the * text is not wrapped or spans multiple lines when the text is wrapped. A Paragraph * contains of a list of {@link SEG} objects which make up the individual segments of the * Paragraph. By providing a specific segment object and an associated * {@link SegmentOps segment operations} object, all required data and the necessary * operations on this data for a single segment can be provided. *

* *

For more complex requirements (for example, when both text and images shall be part * of the document), a different segment type must be provided. One should use something * like {@code Either} for their segment type. * * Note that Paragraph is an immutable class - to modify a Paragraph, a new * Paragraph object must be created. Paragraph itself contains some methods which * take care of this, such as concat(), which appends some Paragraph to the current * one and returns a new Paragraph.

* * @param The type of the paragraph style. * @param The type of the content segments in the paragraph (e.g. {@link String}). * Every paragraph, even an empty paragraph, must have at least one {@link SEG} object * (even if that {@link SEG} object itself represents an empty segment). * @param The type of the style of individual segments. */ public final class Paragraph { private static Tuple2, StyleSpans> decompose(List> list, SegmentOps segmentOps) { List segs = new ArrayList<>(); StyleSpansBuilder builder = new StyleSpansBuilder<>(); for (StyledSegment styledSegment : list) { // attempt to merge differently-styled consecutive segments into one if (segs.isEmpty()) { segs.add(styledSegment.getSegment()); } else { int lastIndex = segs.size() - 1; SEG previousSeg = segs.get(lastIndex); Optional merged = segmentOps.joinSeg(previousSeg, styledSegment.getSegment()); if (merged.isPresent()) { segs.set(lastIndex, merged.get()); } else { segs.add(styledSegment.getSegment()); } } // builder merges styles shared between consecutive different segments builder.add(styledSegment.getStyle(), segmentOps.length(styledSegment.getSegment())); } return Tuples.t(segs, builder.create()); } @SafeVarargs private static List list(T head, T... tail) { if(tail.length == 0) { return Collections.singletonList(head); } else { ArrayList list = new ArrayList<>(1 + tail.length); list.add(head); Collections.addAll(list, tail); return list; } } private final List segments; private final StyleSpans styles; private final TwoLevelNavigator navigator; private final PS paragraphStyle; private final SegmentOps segmentOps; /** * Creates a paragraph using a list of styled segments */ public Paragraph(PS paragraphStyle, SegmentOps segmentOps, List> styledSegments) { this(paragraphStyle, segmentOps, decompose(styledSegments, segmentOps)); } private Paragraph(PS paragraphStyle, SegmentOps segmentOps, Tuple2, StyleSpans> decomposedList) { this(paragraphStyle, segmentOps, decomposedList._1, decomposedList._2); } /** * Creates a paragraph that has only one segment that has the same given style throughout. */ public Paragraph(PS paragraphStyle, SegmentOps segmentOps, SEG segment, S style) { this(paragraphStyle, segmentOps, segment, StyleSpans.singleton(style, segmentOps.length(segment))); } /** * Creates a paragraph that has only one segment but a number of different styles throughout that segment */ public Paragraph(PS paragraphStyle, SegmentOps segmentOps, SEG segment, StyleSpans styles) { this(paragraphStyle, segmentOps, Collections.singletonList(segment), styles); } /** * Creates a paragraph that has multiple segments with multiple styles throughout those segments */ public Paragraph(PS paragraphStyle, SegmentOps segmentOps, List segments, StyleSpans styles) { if (segments.isEmpty()) { throw new IllegalArgumentException("Cannot construct a Paragraph with an empty list of segments"); } if (styles.getSpanCount() == 0) { throw new IllegalArgumentException( "Cannot construct a Paragraph with StyleSpans object that contains no StyleSpan objects" ); } this.segmentOps = segmentOps; this.segments = segments; this.styles = styles; this.paragraphStyle = paragraphStyle; navigator = new TwoLevelNavigator(segments::size, i -> segmentOps.length(segments.get(i)) ); } private List> styledSegments = null; /** * Since the segments and styles in a paragraph are stored separate from another, combines these two collections * into a single collection where each segment and its corresponding style are grouped into the same object. * Essentially, returns {@code List>}. */ public List> getStyledSegments() { if (styledSegments == null) { if (segments.size() == 1 && styles.getSpanCount() == 1) { styledSegments = Collections.singletonList( new StyledSegment<>(segments.get(0), styles.getStyleSpan(0).getStyle()) ); } else { styledSegments = createStyledSegments(); } } return styledSegments; } public List getSegments() { return Collections.unmodifiableList(segments); } public PS getParagraphStyle() { return paragraphStyle; } private int length = -1; public int length() { if(length == -1) { length = segments.stream().mapToInt(segmentOps::length).sum(); } return length; } public char charAt(int index) { Position pos = navigator.offsetToPosition(index, Forward); return segmentOps.charAt(segments.get(pos.getMajor()), pos.getMinor()); } public String substring(int from, int to) { return getText().substring(from, Math.min(to, length())); } public String substring(int from) { return getText().substring(from); } /** * Concatenates this paragraph with the given paragraph {@code p}. * The paragraph style of the result will be that of this paragraph, * unless this paragraph is empty and {@code p} is non-empty, in which * case the paragraph style of the result will be that of {@code p}. */ public Paragraph concat(Paragraph p) { if(p.length() == 0) { return this; } if(length() == 0) { return p; } List updatedSegs; SEG leftSeg = segments.get(segments.size() - 1); SEG rightSeg = p.segments.get(0); Optional joined = segmentOps.joinSeg(leftSeg, rightSeg); if(joined.isPresent()) { SEG segment = joined.get(); updatedSegs = new ArrayList<>(segments.size() + p.segments.size() - 1); updatedSegs.addAll(segments.subList(0, segments.size()-1)); updatedSegs.add(segment); updatedSegs.addAll(p.segments.subList(1, p.segments.size())); } else { updatedSegs = new ArrayList<>(segments.size() + p.segments.size()); updatedSegs.addAll(segments); updatedSegs.addAll(p.segments); } StyleSpans updatedStyles; StyleSpan leftSpan = styles.getStyleSpan(styles.getSpanCount() - 1); StyleSpan rightSpan = p.styles.getStyleSpan(0); Optional merge = segmentOps.joinStyle(leftSpan.getStyle(), rightSpan.getStyle()); if (merge.isPresent()) { int startOfMerge = styles.position(styles.getSpanCount() - 1, 0).toOffset(); StyleSpans updatedLeftSpan = styles.subView(0, startOfMerge); int endOfMerge = p.styles.position(1, 0).toOffset(); StyleSpans updatedRightSpan = p.styles.subView(endOfMerge, p.styles.length()); updatedStyles = updatedLeftSpan .append(merge.get(), leftSpan.getLength() + rightSpan.getLength()) .concat(updatedRightSpan); } else { updatedStyles = styles.concat(p.styles); } return new Paragraph<>(paragraphStyle, segmentOps, updatedSegs, updatedStyles); } /** * Similar to {@link #concat(Paragraph)}, except in case both paragraphs * are empty, the result's paragraph style will be that of the argument. */ Paragraph concatR(Paragraph that) { return this.length() == 0 && that.length() == 0 ? that : concat(that); } public Paragraph subSequence(int start, int end) { return trim(end).subSequence(start); } public Paragraph trim(int length) { if(length >= length()) { return this; } else { Position pos = navigator.offsetToPosition(length, Backward); int segIdx = pos.getMajor(); List segs = new ArrayList<>(segIdx + 1); segs.addAll(segments.subList(0, segIdx)); segs.add(segmentOps.subSequence(segments.get(segIdx), 0, pos.getMinor())); if (segs.isEmpty()) { segs.add(segmentOps.createEmptySeg()); } return new Paragraph<>(paragraphStyle, segmentOps, segs, styles.subView(0, length)); } } public Paragraph subSequence(int start) { if(start < 0) { throw new IllegalArgumentException("start must not be negative (was: " + start + ")"); } else if(start == 0) { return this; } else if (start == length()) { // in case one is using EitherOps, force the empty segment // to use the left ops' default empty seg, not the right one's empty seg return new Paragraph<>(paragraphStyle, segmentOps, segmentOps.createEmptySeg(), styles.subView(start,start)); } else if(start < length()) { Position pos = navigator.offsetToPosition(start, Forward); int segIdx = pos.getMajor(); List segs = new ArrayList<>(segments.size() - segIdx); segs.add(segmentOps.subSequence(segments.get(segIdx), pos.getMinor())); segs.addAll(segments.subList(segIdx + 1, segments.size())); if (segs.isEmpty()) { segs.add(segmentOps.createEmptySeg()); } return new Paragraph<>(paragraphStyle, segmentOps, segs, styles.subView(start, styles.length())); } else { throw new IndexOutOfBoundsException(start + " not in [0, " + length() + "]"); } } public Paragraph delete(int start, int end) { return trim(start).concat(subSequence(end)); } /** * Restyles every segment in the paragraph to have the given style. * * Note: because Paragraph is immutable, this method returns a new Paragraph. * The current Paragraph is unchanged. * * @param style The new style for each segment in the paragraph. * @return The new paragraph with the restyled segments. */ public Paragraph restyle(S style) { return new Paragraph<>(paragraphStyle, segmentOps, segments, StyleSpans.singleton(style, length())); } public Paragraph restyle(int from, int to, S style) { if(from >= length()) { return this; } else { StyleSpans left = styles.subView(0, from); StyleSpans right = styles.subView(to, length()); StyleSpans updatedStyles = left.append(style, to - from).concat(right); return new Paragraph<>(paragraphStyle, segmentOps, segments, updatedStyles); } } public Paragraph restyle(int from, StyleSpans styleSpans) { int len = styleSpans.length(); if(styleSpans.equals(getStyleSpans(from, from + len)) || styleSpans.getSpanCount() == 0) { return this; } if(length() == 0) { return new Paragraph<>(paragraphStyle, segmentOps, segments, (StyleSpans) styleSpans); } StyleSpans left = styles.subView(0, from); StyleSpans right = styles.subView(from + len, length()); // type issue with concat StyleSpans castedSpans = (StyleSpans) styleSpans; StyleSpans updatedStyles = left.concat(castedSpans).concat(right); return new Paragraph<>(paragraphStyle, segmentOps, segments, updatedStyles); } /** * Creates a new Paragraph which has the same contents as the current Paragraph, * but the given paragraph style. * * Note that because Paragraph is immutable, a new Paragraph is returned. * Despite the setX name, the current object is unchanged. * * @param paragraphStyle The new paragraph style * @return A new paragraph with the same segment contents, but a new paragraph style. */ public Paragraph setParagraphStyle(PS paragraphStyle) { return new Paragraph<>(paragraphStyle, segmentOps, segments, styles); } /** * Returns the style of character with the given index. * If {@code charIdx < 0}, returns the style at the beginning of this paragraph. * If {@code charIdx >= this.length()}, returns the style at the end of this paragraph. */ public S getStyleOfChar(int charIdx) { if(charIdx < 0) { return styles.getStyleSpan(0).getStyle(); } Position pos = styles.offsetToPosition(charIdx, Forward); return styles.getStyleSpan(pos.getMajor()).getStyle(); } /** * Returns the style at the given position. That is the style of the * character immediately preceding {@code position}. If {@code position} * is 0, then the style of the first character (index 0) in this paragraph * is returned. If this paragraph is empty, then some style previously used * in this paragraph is returned. * If {@code position > this.length()}, then it is equivalent to * {@code position == this.length()}. * *

In other words, {@code getStyleAtPosition(p)} is equivalent to * {@code getStyleOfChar(p-1)}. */ public S getStyleAtPosition(int position) { if(position < 0) { throw new IllegalArgumentException("Paragraph position cannot be negative (" + position + ")"); } Position pos = styles.offsetToPosition(position, Backward); return styles.getStyleSpan(pos.getMajor()).getStyle(); } /** * Returns the range of homogeneous style that includes the given position. * If {@code position} points to a boundary between two styled ranges, * then the range preceding {@code position} is returned. */ public IndexRange getStyleRangeAtPosition(int position) { Position pos = styles.offsetToPosition(position, Backward); int start = position - pos.getMinor(); int end = start + styles.getStyleSpan(pos.getMajor()).getLength(); return new IndexRange(start, end); } public StyleSpans getStyleSpans() { return styles; } public StyleSpans getStyleSpans(int from, int to) { return styles.subView(from, to); } private String text = null; /** * Returns the plain text content of this paragraph, * not including the line terminator. */ public String getText() { if(text == null) { StringBuilder sb = new StringBuilder(length()); for(SEG seg: segments) sb.append(segmentOps.getText(seg)); text = sb.toString(); } return text; } @Override public String toString() { return "Par[" + paragraphStyle + "; " + getStyledSegments().stream().map(Object::toString) .reduce((s1, s2) -> s1 + ", " + s2).orElse("") + "]"; } /** * Two paragraphs are defined to be equal if they have the same style (as defined by * PS.equals) and the same list of segments (as defined by SEG.equals). */ @Override public boolean equals(Object other) { if(other instanceof Paragraph) { Paragraph that = (Paragraph) other; return Objects.equals(this.paragraphStyle, that.paragraphStyle) && Objects.equals(this.segments, that.segments) && Objects.equals(this.styles, that.styles); } else { return false; } } @Override public int hashCode() { return Objects.hash(paragraphStyle, segments, styles); } private List> createStyledSegments() { List> styledSegments = new LinkedList<>(); Iterator segIterator = segments.iterator(); Iterator> styleIterator = styles.iterator(); SEG segCurrent = segIterator.next(); StyleSpan styleCurrent = styleIterator.next(); int segOffset = 0, styleOffset = 0; boolean finished = false; while (!finished) { int segLength = segmentOps.length(segCurrent) - segOffset; int styleLength = styleCurrent.getLength() - styleOffset; if (segLength < styleLength) { SEG splitSeg = segmentOps.subSequence(segCurrent, segOffset); styledSegments.add(new StyledSegment<>(splitSeg, styleCurrent.getStyle())); segCurrent = segIterator.next(); segOffset = 0; styleOffset += segLength; } else if (styleLength < segLength) { SEG splitSeg = segmentOps.subSequence(segCurrent, segOffset, segOffset + styleLength); styledSegments.add(new StyledSegment<>(splitSeg, styleCurrent.getStyle())); styleCurrent = styleIterator.next(); styleOffset = 0; segOffset += styleLength; } else { SEG splitSeg = segmentOps.subSequence(segCurrent, segOffset, segOffset + styleLength); styledSegments.add(new StyledSegment<>(splitSeg, styleCurrent.getStyle())); if (segIterator.hasNext() && styleIterator.hasNext()) { segCurrent = segIterator.next(); segOffset = 0; styleCurrent = styleIterator.next(); styleOffset = 0; } else { finished = true; } } } return styledSegments; } }