diff --git a/richtextfx-demos/sample.png b/richtextfx-demos/sample.png new file mode 100644 index 000000000..5cdf307df Binary files /dev/null and b/richtextfx-demos/sample.png differ diff --git a/richtextfx-demos/sample.rtfx b/richtextfx-demos/sample.rtfx new file mode 100644 index 000000000..0404f763b Binary files /dev/null and b/richtextfx-demos/sample.rtfx differ diff --git a/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/richtext/LinkedImage.java b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/richtext/LinkedImage.java new file mode 100644 index 000000000..4a3155a98 --- /dev/null +++ b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/richtext/LinkedImage.java @@ -0,0 +1,100 @@ +package org.fxmisc.richtext.demo.richtext; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; + +import javafx.scene.Node; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; + +import org.fxmisc.richtext.model.Codec; + + +/** + * A custom object which contains a file path to an image file. + * When rendered in the rich text editor, the image is loaded from the + * specified file. + */ +public class LinkedImage { + + public static Codec> codec(Codec styleCodec) { + return new Codec>() { + + @Override + public String getName() { + return "LinkedImage<" + styleCodec.getName() + ">"; + } + + @Override + public void encode(DataOutputStream os, LinkedImage i) throws IOException { + // external path rep should use forward slashes only + String externalPath = i.imagePath.replace("\\", "/"); + Codec.STRING_CODEC.encode(os, externalPath); + styleCodec.encode(os, i.style); + } + + @Override + public LinkedImage decode(DataInputStream is) throws IOException { + // Sanitize path - make sure that forward slashes only are used + String imagePath = Codec.STRING_CODEC.decode(is); + imagePath = imagePath.replace("\\", "/"); + S style = styleCodec.decode(is); + return new LinkedImage<>(imagePath, style); + } + + }; + } + + private final String imagePath; + private final S style; + + /** + * Creates a new linked image object. + * + * @param imagePath The path to the image file. + * @param style The text style to apply to the corresponding segment. + */ + public LinkedImage(String imagePath, S style) { + + // if the image is below the current working directory, + // then store as relative path name. + String currentDir = System.getProperty("user.dir") + File.separatorChar; + if (imagePath.startsWith(currentDir)) { + imagePath = imagePath.substring(currentDir.length()); + } + + this.imagePath = imagePath; + this.style = style; + } + + public LinkedImage setStyle(S style) { + return new LinkedImage<>(imagePath, style); + } + + + /** + * @return The path of the image to render. + */ + public String getImagePath() { + return imagePath; + } + + public S getStyle() { + return style; + } + + + @Override + public String toString() { + return String.format("LinkedImage[path=%s]", imagePath); + } + + public Node createNode() { + Image image = new Image("file:" + imagePath); // XXX: No need to create new Image objects each time - + // could be cached in the model layer + ImageView result = new ImageView(image); + return result; + } +} diff --git a/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/richtext/LinkedImageOps.java b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/richtext/LinkedImageOps.java new file mode 100644 index 000000000..c286c14ef --- /dev/null +++ b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/richtext/LinkedImageOps.java @@ -0,0 +1,68 @@ +package org.fxmisc.richtext.demo.richtext; + +import java.util.Optional; + +import org.fxmisc.richtext.model.SegmentOps; + +public class LinkedImageOps implements SegmentOps, S> { + + private final LinkedImage emptySeg = new LinkedImage<>("", null); + + @Override + public int length(LinkedImage seg) { + return seg == emptySeg ? 0 : 1; + } + + @Override + public char charAt(LinkedImage seg, int index) { + return seg == emptySeg ? '\0' : '\ufffc'; + } + + @Override + public String getText(LinkedImage seg) { + return seg == emptySeg ? "" : "\ufffc"; + } + + @Override + public LinkedImage subSequence(LinkedImage seg, int start, int end) { + if (start < 0) { + throw new IllegalArgumentException("Start cannot be negative. Start = " + start); + } + if (end > length(seg)) { + throw new IllegalArgumentException("End cannot be greater than segment's length"); + } + return start == 0 && end == 1 + ? seg + : emptySeg; + } + + @Override + public LinkedImage subSequence(LinkedImage seg, int start) { + if (start < 0) { + throw new IllegalArgumentException("Start cannot be negative. Start = " + start); + } + return start == 0 + ? seg + : emptySeg; + } + + @Override + public S getStyle(LinkedImage seg) { + return seg.getStyle(); + } + + @Override + public LinkedImage setStyle(LinkedImage seg, S style) { + return seg == emptySeg ? emptySeg : seg.setStyle(style); + } + + @Override + public Optional> join(LinkedImage currentSeg, LinkedImage nextSeg) { + return Optional.empty(); + } + + @Override + public LinkedImage createEmpty() { + return emptySeg; + } +} diff --git a/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/richtext/RichText.java b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/richtext/RichText.java index 7c07f46cd..7341ec050 100644 --- a/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/richtext/RichText.java +++ b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/richtext/RichText.java @@ -8,14 +8,22 @@ import static org.fxmisc.richtext.model.TwoDimensional.Bias.*; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; import java.util.List; import java.util.Optional; +import java.util.function.BiConsumer; import java.util.function.Function; import javafx.application.Application; import javafx.beans.binding.Bindings; import javafx.beans.binding.BooleanBinding; import javafx.collections.FXCollections; +import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.CheckBox; @@ -31,33 +39,61 @@ import javafx.scene.paint.Color; import javafx.scene.text.Font; import javafx.scene.text.TextAlignment; +import javafx.stage.FileChooser; import javafx.stage.Stage; import org.fxmisc.flowless.VirtualizedScrollPane; +import org.fxmisc.richtext.GenericStyledArea; import org.fxmisc.richtext.StyledTextArea; +import org.fxmisc.richtext.TextExt; +import org.fxmisc.richtext.model.Codec; import org.fxmisc.richtext.model.Paragraph; +import org.fxmisc.richtext.model.ReadOnlyStyledDocument; import org.fxmisc.richtext.model.StyleSpans; +import org.fxmisc.richtext.model.StyledDocument; +import org.fxmisc.richtext.model.StyledText; +import org.fxmisc.richtext.model.TextOps; import org.reactfx.SuspendableNo; +import org.reactfx.util.Either; +import org.reactfx.util.Tuple2; public class RichText extends Application { public static void main(String[] args) { + // The following properties are required on Linux for improved text rendering + //System.setProperty("prism.lcdtext", "false"); + //System.setProperty("prism.text", "t2k"); launch(args); } - private final StyledTextArea area = new StyledTextArea<>( - ParStyle.EMPTY, ( paragraph, style) -> paragraph.setStyle(style.toCss()), - TextStyle.EMPTY.updateFontSize(12).updateFontFamily("Serif").updateTextColor(Color.BLACK), - ( text, style) -> text.setStyle(style.toCss())); + private final TextOps, TextStyle> styledTextOps = StyledText.textOps(); + private final LinkedImageOps linkedImageOps = new LinkedImageOps<>(); + + private final GenericStyledArea, LinkedImage>, TextStyle> area = + new GenericStyledArea<>( + ParStyle.EMPTY, // default paragraph style + (paragraph, style) -> paragraph.setStyle(style.toCss()), // paragraph style setter + + TextStyle.EMPTY.updateFontSize(12).updateFontFamily("Serif").updateTextColor(Color.BLACK), // default segment style + styledTextOps._or(linkedImageOps), // segment operations + seg -> createNode(seg, (text, style) -> text.setStyle(style.toCss()))); // Node creator and segment style setter { area.setWrapText(true); - area.setStyleCodecs(ParStyle.CODEC, TextStyle.CODEC); + area.setStyleCodecs( + ParStyle.CODEC, + Codec.eitherCodec(StyledText.codec(TextStyle.CODEC), LinkedImage.codec(TextStyle.CODEC))); } + private Stage mainStage; + private final SuspendableNo updatingToolbar = new SuspendableNo(); @Override public void start(Stage primaryStage) { + mainStage = primaryStage; + + Button loadBtn = createButton("loadfile", this::loadDocument, "Load document"); + Button saveBtn = createButton("savefile", this::saveDocument, "Save document"); CheckBox wrapToggle = new CheckBox("Wrap"); wrapToggle.setSelected(true); area.wrapTextProperty().bind(wrapToggle.selectedProperty()); @@ -70,6 +106,7 @@ public void start(Stage primaryStage) { Button italicBtn = createButton("italic", this::toggleItalic); Button underlineBtn = createButton("underline", this::toggleUnderline); Button strikeBtn = createButton("strikethrough", this::toggleStrikethrough); + Button insertImageBtn = createButton("insertimage", this::insertImage, "Insert Image"); ToggleGroup alignmentGrp = new ToggleGroup(); ToggleButton alignLeftBtn = createToggleButton(alignmentGrp, "align-left", this::alignLeft); ToggleButton alignCenterBtn = createToggleButton(alignmentGrp, "align-center", this::alignCenter); @@ -147,7 +184,7 @@ protected boolean computeValue() { int startPar = area.offsetToPosition(selection.getStart(), Forward).getMajor(); int endPar = area.offsetToPosition(selection.getEnd(), Backward).getMajor(); - List> pars = area.getParagraphs().subList(startPar, endPar + 1); + List,LinkedImage>, TextStyle>> pars = area.getParagraphs().subList(startPar, endPar + 1); @SuppressWarnings("unchecked") Optional[] alignments = pars.stream().map(p -> p.getParagraphStyle().alignment).distinct().toArray(Optional[]::new); @@ -228,13 +265,14 @@ protected boolean computeValue() { HBox panel1 = new HBox(3.0); HBox panel2 = new HBox(3.0); panel1.getChildren().addAll( + loadBtn, saveBtn, wrapToggle, undoBtn, redoBtn, cutBtn, copyBtn, pasteBtn, boldBtn, italicBtn, underlineBtn, strikeBtn, - alignLeftBtn, alignCenterBtn, alignRightBtn, alignJustifyBtn, + alignLeftBtn, alignCenterBtn, alignRightBtn, alignJustifyBtn, insertImageBtn, paragraphBackgroundPicker); panel2.getChildren().addAll(sizeCombo, familyCombo, textColorPicker, backgroundColorPicker); - VirtualizedScrollPane> vsPane = new VirtualizedScrollPane<>(area); + VirtualizedScrollPane,LinkedImage>, TextStyle>> vsPane = new VirtualizedScrollPane<>(area); VBox vbox = new VBox(); VBox.setVgrow(vsPane, Priority.ALWAYS); vbox.getChildren().addAll(panel1, panel2, vsPane); @@ -247,7 +285,22 @@ protected boolean computeValue() { primaryStage.show(); } + + private Node createNode(Either, LinkedImage> seg, + BiConsumer applyStyle) { + if (seg.isLeft()) { + return StyledTextArea.createStyledTextNode(seg.getLeft(), styledTextOps, applyStyle); + } else { + return seg.getRight().createNode(); + } + } + + @Deprecated private Button createButton(String styleClass, Runnable action) { + return createButton(styleClass, action, null); + } + + private Button createButton(String styleClass, Runnable action, String toolTip) { Button button = new Button(); button.getStyleClass().add(styleClass); button.setOnAction(evt -> { @@ -256,6 +309,9 @@ private Button createButton(String styleClass, Runnable action) { }); button.setPrefWidth(20); button.setPrefHeight(20); + if (toolTip != null) { + button.setTooltip(new Tooltip(toolTip)); + } return button; } @@ -304,6 +360,91 @@ private void alignJustify() { updateParagraphStyleInSelection(ParStyle.alignJustify()); } + private void loadDocument() { + String initialDir = System.getProperty("user.dir"); + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Load document"); + fileChooser.setInitialDirectory(new File(initialDir)); + File selectedFile = fileChooser.showOpenDialog(mainStage); + if (selectedFile != null) { + area.clear(); + load(selectedFile); + } + } + + private void load(File file) { + if(area.getStyleCodecs().isPresent()) { + Tuple2, Codec, LinkedImage>>> codecs = area.getStyleCodecs().get(); + Codec, LinkedImage>, TextStyle>> + codec = ReadOnlyStyledDocument.codec(codecs._1, codecs._2, area.getSegOps()); + + try { + FileInputStream fis = new FileInputStream(file); + DataInputStream dis = new DataInputStream(fis); + StyledDocument, LinkedImage>, TextStyle> doc = codec.decode(dis); + fis.close(); + + if(doc != null) { + area.replaceSelection(doc); + return; + } + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + + private void saveDocument() { + String initialDir = System.getProperty("user.dir"); + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Save document"); + fileChooser.setInitialDirectory(new File(initialDir)); + File selectedFile = fileChooser.showSaveDialog(mainStage); + if (selectedFile != null) { + save(selectedFile); + } + } + + + private void save(File file) { + StyledDocument, LinkedImage>, TextStyle> doc = area.getDocument(); + + // Use the Codec to save the document in a binary format + area.getStyleCodecs().ifPresent(codecs -> { + Codec, LinkedImage>, TextStyle>> codec = + ReadOnlyStyledDocument.codec(codecs._1, codecs._2, area.getSegOps()); + try { + FileOutputStream fos = new FileOutputStream(file); + DataOutputStream dos = new DataOutputStream(fos); + codec.encode(dos, doc); + fos.close(); + } catch (IOException fnfe) { + fnfe.printStackTrace(); + } + }); + } + + + /** + * Action listener which inserts a new image at the current caret position. + */ + private void insertImage() { + String initialDir = System.getProperty("user.dir"); + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Insert image"); + fileChooser.setInitialDirectory(new File(initialDir)); + File selectedFile = fileChooser.showOpenDialog(mainStage); + if (selectedFile != null) { + String imagePath = selectedFile.getAbsolutePath(); + imagePath = imagePath.replace('\\', '/'); + ReadOnlyStyledDocument, LinkedImage>, TextStyle> ros = + ReadOnlyStyledDocument.fromSegment(Either.right(new LinkedImage<>(imagePath, TextStyle.EMPTY)), + ParStyle.EMPTY, TextStyle.EMPTY, area.getSegOps()); + area.replaceSelection(ros); + } + } + private void updateStyleInSelection(Function, TextStyle> mixinGetter) { IndexRange selection = area.getSelection(); if(selection.getLength() != 0) { @@ -328,7 +469,7 @@ private void updateParagraphStyleInSelection(Function update int startPar = area.offsetToPosition(selection.getStart(), Forward).getMajor(); int endPar = area.offsetToPosition(selection.getEnd(), Backward).getMajor(); for(int i = startPar; i <= endPar; ++i) { - Paragraph paragraph = area.getParagraph(i); + Paragraph,LinkedImage>, TextStyle> paragraph = area.getParagraph(i); area.setParagraphStyle(i, updater.apply(paragraph.getParagraphStyle())); } } diff --git a/richtextfx-demos/src/main/resources/org/fxmisc/richtext/demo/richtext/insertimage.png b/richtextfx-demos/src/main/resources/org/fxmisc/richtext/demo/richtext/insertimage.png new file mode 100644 index 000000000..5cdf307df Binary files /dev/null and b/richtextfx-demos/src/main/resources/org/fxmisc/richtext/demo/richtext/insertimage.png differ diff --git a/richtextfx-demos/src/main/resources/org/fxmisc/richtext/demo/richtext/loadfile.png b/richtextfx-demos/src/main/resources/org/fxmisc/richtext/demo/richtext/loadfile.png new file mode 100644 index 000000000..bbf988123 Binary files /dev/null and b/richtextfx-demos/src/main/resources/org/fxmisc/richtext/demo/richtext/loadfile.png differ diff --git a/richtextfx-demos/src/main/resources/org/fxmisc/richtext/demo/richtext/rich-text.css b/richtextfx-demos/src/main/resources/org/fxmisc/richtext/demo/richtext/rich-text.css index 946700495..66efd31c8 100644 --- a/richtextfx-demos/src/main/resources/org/fxmisc/richtext/demo/richtext/rich-text.css +++ b/richtextfx-demos/src/main/resources/org/fxmisc/richtext/demo/richtext/rich-text.css @@ -5,6 +5,8 @@ -fx-background-repeat: no-repeat; } +.button.loadfile { -fx-background-image: url(loadfile.png); } +.button.savefile { -fx-background-image: url(savefile.png); } .button.undo { -fx-background-image: url(undo.png); } .button.redo { -fx-background-image: url(redo.png); } .button.cut { -fx-background-image: url(cut.png); } @@ -14,6 +16,7 @@ .button.italic { -fx-background-image: url(italic.png); } .button.underline { -fx-background-image: url(underline.png); } .button.strikethrough { -fx-background-image: url(strikethrough.png); } +.button.insertimage { -fx-background-image: url(insertimage.png); } .toggle-button.align-left { -fx-background-image: url(align-left.png); } .toggle-button.align-center { -fx-background-image: url(align-center.png); } diff --git a/richtextfx-demos/src/main/resources/org/fxmisc/richtext/demo/richtext/savefile.png b/richtextfx-demos/src/main/resources/org/fxmisc/richtext/demo/richtext/savefile.png new file mode 100644 index 000000000..d0cfbb7f8 Binary files /dev/null and b/richtextfx-demos/src/main/resources/org/fxmisc/richtext/demo/richtext/savefile.png differ diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/ClipboardActions.java b/richtextfx/src/main/java/org/fxmisc/richtext/ClipboardActions.java index f6f90f48b..b9667a5e8 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/ClipboardActions.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/ClipboardActions.java @@ -17,6 +17,7 @@ import org.fxmisc.richtext.model.Codec; import org.fxmisc.richtext.model.EditActions; import org.fxmisc.richtext.model.ReadOnlyStyledDocument; +import org.fxmisc.richtext.model.SegmentOps; import org.fxmisc.richtext.model.StyledDocument; import org.fxmisc.richtext.model.TextEditingArea; import org.reactfx.util.Tuple2; @@ -24,9 +25,11 @@ /** * Clipboard actions for {@link TextEditingArea}. */ -public interface ClipboardActions extends EditActions { +public interface ClipboardActions extends EditActions { - Optional, Codec>> getStyleCodecs(); + Optional, Codec>> getStyleCodecs(); + + SegmentOps getSegOps(); /** * Transfers the currently selected text to the clipboard, @@ -50,9 +53,9 @@ default void copy() { content.putString(getSelectedText()); getStyleCodecs().ifPresent(codecs -> { - Codec> codec = ReadOnlyStyledDocument.codec(codecs._1, codecs._2); + Codec> codec = ReadOnlyStyledDocument.codec(codecs._1, codecs._2, getSegOps()); DataFormat format = dataFormat(codec.getName()); - StyledDocument doc = subDocument(selection.getStart(), selection.getEnd()); + StyledDocument doc = subDocument(selection.getStart(), selection.getEnd()); ByteArrayOutputStream os = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(os); try { @@ -77,14 +80,14 @@ default void paste() { Clipboard clipboard = Clipboard.getSystemClipboard(); if(getStyleCodecs().isPresent()) { - Tuple2, Codec> codecs = getStyleCodecs().get(); - Codec> codec = ReadOnlyStyledDocument.codec(codecs._1, codecs._2); + Tuple2, Codec> codecs = getStyleCodecs().get(); + Codec> codec = ReadOnlyStyledDocument.codec(codecs._1, codecs._2, getSegOps()); DataFormat format = dataFormat(codec.getName()); if(clipboard.hasContent(format)) { byte[] bytes = (byte[]) clipboard.getContent(format); ByteArrayInputStream is = new ByteArrayInputStream(bytes); DataInputStream dis = new DataInputStream(is); - StyledDocument doc = null; + StyledDocument doc = null; try { doc = codec.decode(dis); } catch (IOException e) { diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/CodeArea.java b/richtextfx/src/main/java/org/fxmisc/richtext/CodeArea.java index 6d59f0bf7..7badd7f08 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/CodeArea.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/CodeArea.java @@ -4,6 +4,7 @@ import java.util.Collection; import org.fxmisc.richtext.model.EditableStyledDocument; +import org.fxmisc.richtext.model.StyledText; /** * A convenience subclass of {@link StyleClassedTextArea} @@ -22,7 +23,7 @@ public class CodeArea extends StyleClassedTextArea { setUseInitialStyleForInsertion(true); } - public CodeArea(EditableStyledDocument, Collection> document) { + public CodeArea(EditableStyledDocument, StyledText>, Collection> document) { super(document, false); } diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/CssProperties.java b/richtextfx/src/main/java/org/fxmisc/richtext/CssProperties.java index 6cb7505a4..4ed204729 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/CssProperties.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/CssProperties.java @@ -12,7 +12,7 @@ /** - * CSS stuff related to {@link StyledTextArea}. + * CSS stuff related to {@link GenericStyledArea}. */ class CssProperties { diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java b/richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java new file mode 100644 index 000000000..47a6ffdd2 --- /dev/null +++ b/richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java @@ -0,0 +1,1364 @@ +package org.fxmisc.richtext; + +import static javafx.util.Duration.*; +import static org.fxmisc.richtext.PopupAlignment.*; +import static org.reactfx.EventStreams.*; +import static org.reactfx.util.Tuples.*; + +import java.time.Duration; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.IntConsumer; +import java.util.function.IntFunction; +import java.util.function.IntSupplier; +import java.util.function.IntUnaryOperator; +import java.util.function.UnaryOperator; +import java.util.stream.Stream; + +import javafx.beans.binding.Binding; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.ObjectBinding; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.Property; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.value.ObservableBooleanValue; +import javafx.beans.value.ObservableValue; +import javafx.collections.FXCollections; +import javafx.collections.ObservableSet; +import javafx.css.PseudoClass; +import javafx.css.StyleableObjectProperty; +import javafx.event.Event; +import javafx.geometry.BoundingBox; +import javafx.geometry.Bounds; +import javafx.geometry.Insets; +import javafx.geometry.Point2D; +import javafx.scene.Node; +import javafx.scene.control.IndexRange; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundFill; +import javafx.scene.layout.CornerRadii; +import javafx.scene.layout.Region; +import javafx.scene.paint.Color; +import javafx.scene.paint.Paint; +import javafx.scene.text.TextFlow; +import javafx.stage.PopupWindow; + +import org.fxmisc.flowless.Cell; +import org.fxmisc.flowless.VirtualFlow; +import org.fxmisc.flowless.VirtualFlowHit; +import org.fxmisc.flowless.Virtualized; +import org.fxmisc.flowless.VirtualizedScrollPane; +import org.fxmisc.richtext.CssProperties.EditableProperty; +import org.fxmisc.richtext.model.Codec; +import org.fxmisc.richtext.model.EditActions; +import org.fxmisc.richtext.model.EditableStyledDocument; +import org.fxmisc.richtext.model.GenericEditableStyledDocument; +import org.fxmisc.richtext.model.Paragraph; +import org.fxmisc.richtext.model.StyledTextAreaModel; +import org.fxmisc.richtext.model.NavigationActions; +import org.fxmisc.richtext.model.PlainTextChange; +import org.fxmisc.richtext.model.RichTextChange; +import org.fxmisc.richtext.model.SegmentOps; +import org.fxmisc.richtext.model.StyleSpans; +import org.fxmisc.richtext.model.StyledDocument; +import org.fxmisc.richtext.model.TextEditingArea; +import org.fxmisc.richtext.model.TextOps; +import org.fxmisc.richtext.model.TwoDimensional; +import org.fxmisc.richtext.model.TwoLevelNavigator; +import org.fxmisc.richtext.model.UndoActions; +import org.fxmisc.undo.UndoManager; +import org.fxmisc.undo.UndoManagerFactory; +import org.reactfx.EventStream; +import org.reactfx.EventStreams; +import org.reactfx.StateMachine; +import org.reactfx.Subscription; +import org.reactfx.collection.LiveList; +import org.reactfx.util.Tuple2; +import org.reactfx.value.Val; +import org.reactfx.value.Var; + +/** + * Text editing control. Accepts user input (keyboard, mouse) and + * provides API to assign style to text ranges. It is suitable for + * syntax highlighting and rich-text editors. + * + *

Subclassing is allowed to define the type of style, e.g. inline + * style or style classes.

+ * + *

Note: Scroll bars no longer appear when the content spans outside + * of the viewport. To add scroll bars, the area needs to be wrapped in + * a {@link VirtualizedScrollPane}. For example,

+ *
+ * {@code
+ * // shows area without scroll bars
+ * InlineCssTextArea area = new InlineCssTextArea();
+ *
+ * // add scroll bars that will display as needed
+ * VirtualizedScrollPane vsPane = new VirtualizedScrollPane(area);
+ *
+ * Parent parent = //;
+ * parent.getChildren().add(vsPane)
+ * }
+ * 
+ * + *

Auto-Scrolling to the Caret

+ * + *

Every time the underlying {@link EditableStyledDocument} changes via user interaction (e.g. typing) through + * the {@code StyledTextArea}, the area will scroll to insure the caret is kept in view. However, this does not + * occur if changes are done programmatically. For example, let's say the area is displaying the bottom part + * of the area's {@link EditableStyledDocument} and some code changes something in the top part of the document + * that is not currently visible. If there is no call to {@link #requestFollowCaret()} at the end of that code, + * the area will not auto-scroll to that section of the document. The change will occur, and the user will continue + * to see the bottom part of the document as before. If such a call is there, then the area will scroll + * to the top of the document and no longer display the bottom part of it.

+ * + *

Additionally, when overriding the default user-interaction behavior, remember to include a call + * to {@link #requestFollowCaret()}.

+ * + *

Overriding keyboard shortcuts

+ * + * {@code StyledTextArea} uses {@code KEY_TYPED} handler to handle ordinary + * character input and {@code KEY_PRESSED} handler to handle control key + * combinations (including Enter and Tab). To add or override some keyboard + * shortcuts, while keeping the rest in place, you would combine the default + * event handler with a new one that adds or overrides some of the default + * key combinations. This is how to bind {@code Ctrl+S} to the {@code save()} + * operation: + *
+ * {@code
+ * import static javafx.scene.input.KeyCode.*;
+ * import static javafx.scene.input.KeyCombination.*;
+ * import static org.fxmisc.wellbehaved.event.EventPattern.*;
+ * import static org.fxmisc.wellbehaved.event.InputMap.*;
+ *
+ * import org.fxmisc.wellbehaved.event.Nodes;
+ *
+ * Nodes.addInputMap(area, consume(keyPressed(S, CONTROL_DOWN), event -> save()));
+ * }
+ * 
+ * + * @param type of style that can be applied to text. + */ +public class GenericStyledArea extends Region + implements + TextEditingArea, + EditActions, + ClipboardActions, + NavigationActions, + UndoActions, + TwoDimensional, + Virtualized { + + /** + * Index range [0, 0). + */ + public static final IndexRange EMPTY_RANGE = new IndexRange(0, 0); + + private static final PseudoClass HAS_CARET = PseudoClass.getPseudoClass("has-caret"); + private static final PseudoClass FIRST_PAR = PseudoClass.getPseudoClass("first-paragraph"); + private static final PseudoClass LAST_PAR = PseudoClass.getPseudoClass("last-paragraph"); + + + /* ********************************************************************** * + * * + * Properties * + * * + * Properties affect behavior and/or appearance of this control. * + * * + * They are readable and writable by the client code and never change by * + * other means, i.e. they contain either the default value or the value * + * set by the client code. * + * * + * ********************************************************************** */ + + /** + * Background fill for highlighted text. + */ + private final StyleableObjectProperty highlightFill + = new CssProperties.HighlightFillProperty(this, Color.DODGERBLUE); + + /** + * Text color for highlighted text. + */ + private final StyleableObjectProperty highlightTextFill + = new CssProperties.HighlightTextFillProperty(this, Color.WHITE); + + /** + * Controls the blink rate of the caret, when one is displayed. Setting + * the duration to zero disables blinking. + */ + private final StyleableObjectProperty caretBlinkRate + = new CssProperties.CaretBlinkRateProperty(this, javafx.util.Duration.millis(500)); + + // editable property + /** + * Indicates whether this text area can be edited by the user. + * Note that this property doesn't affect editing through the API. + */ + private final BooleanProperty editable = new EditableProperty<>(this); + public final boolean isEditable() { return editable.get(); } + public final void setEditable(boolean value) { editable.set(value); } + public final BooleanProperty editableProperty() { return editable; } + + // wrapText property + /** + * When a run of text exceeds the width of the text region, + * then this property indicates whether the text should wrap + * onto another line. + */ + private final BooleanProperty wrapText = new SimpleBooleanProperty(this, "wrapText"); + public final boolean isWrapText() { return wrapText.get(); } + public final void setWrapText(boolean value) { wrapText.set(value); } + public final BooleanProperty wrapTextProperty() { return wrapText; } + + // showCaret property + /** + * Indicates when this text area should display a caret. + */ + private final Var showCaret = Var.newSimpleVar(CaretVisibility.AUTO); + public final CaretVisibility getShowCaret() { return showCaret.getValue(); } + public final void setShowCaret(CaretVisibility value) { showCaret.setValue(value); } + public final Var showCaretProperty() { return showCaret; } + + public static enum CaretVisibility { + /** Caret is displayed. */ + ON, + /** Caret is displayed when area is focused, enabled, and editable. */ + AUTO, + /** Caret is not displayed. */ + OFF + } + + // undo manager + @Override public UndoManager getUndoManager() { return model.getUndoManager(); } + @Override public void setUndoManager(UndoManagerFactory undoManagerFactory) { + model.setUndoManager(undoManagerFactory); + } + + /** + * Popup window that will be positioned by this text area relative to the + * caret or selection. Use {@link #popupAlignmentProperty()} to specify + * how the popup should be positioned relative to the caret or selection. + * Use {@link #popupAnchorOffsetProperty()} or + * {@link #popupAnchorAdjustmentProperty()} to further adjust the position. + */ + private final ObjectProperty popupWindow = new SimpleObjectProperty<>(); + public void setPopupWindow(PopupWindow popup) { popupWindow.set(popup); } + public PopupWindow getPopupWindow() { return popupWindow.get(); } + public ObjectProperty popupWindowProperty() { return popupWindow; } + + /** @deprecated Use {@link #setPopupWindow(PopupWindow)}. */ + @Deprecated + public void setPopupAtCaret(PopupWindow popup) { popupWindow.set(popup); } + /** @deprecated Use {@link #getPopupWindow()}. */ + @Deprecated + public PopupWindow getPopupAtCaret() { return popupWindow.get(); } + /** @deprecated Use {@link #popupWindowProperty()}. */ + @Deprecated + public ObjectProperty popupAtCaretProperty() { return popupWindow; } + + /** + * Specifies further offset (in pixels) of the popup window from the + * position specified by {@link #popupAlignmentProperty()}. + * + *

If {@link #popupAnchorAdjustmentProperty()} is also specified, then + * it overrides the offset set by this property. + */ + private final ObjectProperty popupAnchorOffset = new SimpleObjectProperty<>(); + public void setPopupAnchorOffset(Point2D offset) { popupAnchorOffset.set(offset); } + public Point2D getPopupAnchorOffset() { return popupAnchorOffset.get(); } + public ObjectProperty popupAnchorOffsetProperty() { return popupAnchorOffset; } + + /** + * Specifies how to adjust the popup window's anchor point. The given + * operator is invoked with the screen position calculated according to + * {@link #popupAlignmentProperty()} and should return a new screen + * position. This position will be used as the popup window's anchor point. + * + *

Setting this property overrides {@link #popupAnchorOffsetProperty()}. + */ + private final ObjectProperty> popupAnchorAdjustment = new SimpleObjectProperty<>(); + public void setPopupAnchorAdjustment(UnaryOperator f) { popupAnchorAdjustment.set(f); } + public UnaryOperator getPopupAnchorAdjustment() { return popupAnchorAdjustment.get(); } + public ObjectProperty> popupAnchorAdjustmentProperty() { return popupAnchorAdjustment; } + + /** + * Defines where the popup window given in {@link #popupWindowProperty()} + * is anchored, i.e. where its anchor point is positioned. This position + * can further be adjusted by {@link #popupAnchorOffsetProperty()} or + * {@link #popupAnchorAdjustmentProperty()}. + */ + private final ObjectProperty popupAlignment = new SimpleObjectProperty<>(CARET_TOP); + public void setPopupAlignment(PopupAlignment pos) { popupAlignment.set(pos); } + public PopupAlignment getPopupAlignment() { return popupAlignment.get(); } + public ObjectProperty popupAlignmentProperty() { return popupAlignment; } + + /** + * Defines how long the mouse has to stay still over the text before a + * {@link MouseOverTextEvent} of type {@code MOUSE_OVER_TEXT_BEGIN} is + * fired on this text area. When set to {@code null}, no + * {@code MouseOverTextEvent}s are fired on this text area. + * + *

Default value is {@code null}. + */ + private final ObjectProperty mouseOverTextDelay = new SimpleObjectProperty<>(null); + public void setMouseOverTextDelay(Duration delay) { mouseOverTextDelay.set(delay); } + public Duration getMouseOverTextDelay() { return mouseOverTextDelay.get(); } + public ObjectProperty mouseOverTextDelayProperty() { return mouseOverTextDelay; } + + /** + * Defines how to handle an event in which the user has selected some text, dragged it to a + * new location within the area, and released the mouse at some character {@code index} + * within the area. + * + *

By default, this will relocate the selected text to the character index where the mouse + * was released. To override it, use {@link #setOnSelectionDrop(IntConsumer)}. + */ + private Property onSelectionDrop = new SimpleObjectProperty<>(this::moveSelectedText); + public final void setOnSelectionDrop(IntConsumer consumer) { onSelectionDrop.setValue(consumer); } + public final IntConsumer getOnSelectionDrop() { return onSelectionDrop.getValue(); } + + private final ObjectProperty> paragraphGraphicFactory = new SimpleObjectProperty<>(null); + public void setParagraphGraphicFactory(IntFunction factory) { paragraphGraphicFactory.set(factory); } + public IntFunction getParagraphGraphicFactory() { return paragraphGraphicFactory.get(); } + public ObjectProperty> paragraphGraphicFactoryProperty() { return paragraphGraphicFactory; } + + /** + * Indicates whether the initial style should also be used for plain text + * inserted into this text area. When {@code false}, the style immediately + * preceding the insertion position is used. Default value is {@code false}. + */ + public BooleanProperty useInitialStyleForInsertionProperty() { return model.useInitialStyleForInsertionProperty(); } + public void setUseInitialStyleForInsertion(boolean value) { model.setUseInitialStyleForInsertion(value); } + public boolean getUseInitialStyleForInsertion() { return model.getUseInitialStyleForInsertion(); } + + private Optional, Codec>> styleCodecs = Optional.empty(); + /** + * Sets codecs to encode/decode style information to/from binary format. + * Providing codecs enables clipboard actions to retain the style information. + */ + public void setStyleCodecs(Codec paragraphStyleCodec, Codec textStyleCodec) { + styleCodecs = Optional.of(t(paragraphStyleCodec, textStyleCodec)); + } + @Override + public Optional, Codec>> getStyleCodecs() { + return styleCodecs; + } + + /** + * The estimated scrollX value. This can be set in order to scroll the content. + * Value is only accurate when area does not wrap lines and uses the same font size + * throughout the entire area. + */ + @Override + public Var estimatedScrollXProperty() { return virtualFlow.estimatedScrollXProperty(); } + public double getEstimatedScrollX() { return virtualFlow.estimatedScrollXProperty().getValue(); } + public void setEstimatedScrollX(double value) { virtualFlow.estimatedScrollXProperty().setValue(value); } + + /** + * The estimated scrollY value. This can be set in order to scroll the content. + * Value is only accurate when area does not wrap lines and uses the same font size + * throughout the entire area. + */ + @Override + public Var estimatedScrollYProperty() { return virtualFlow.estimatedScrollYProperty(); } + public double getEstimatedScrollY() { return virtualFlow.estimatedScrollYProperty().getValue(); } + public void setEstimatedScrollY(double value) { virtualFlow.estimatedScrollYProperty().setValue(value); } + + + /* ********************************************************************** * + * * + * Observables * + * * + * Observables are "dynamic" (i.e. changing) characteristics of this * + * control. They are not directly settable by the client code, but change * + * in response to user input and/or API actions. * + * * + * ********************************************************************** */ + + // text + @Override public final String getText() { return model.getText(); } + @Override public final ObservableValue textProperty() { return model.textProperty(); } + + // rich text + @Override public final StyledDocument getDocument() { return model.getDocument(); } + + // length + @Override public final int getLength() { return model.getLength(); } + @Override public final ObservableValue lengthProperty() { return model.lengthProperty(); } + + // caret position + @Override public final int getCaretPosition() { return model.getCaretPosition(); } + @Override public final ObservableValue caretPositionProperty() { return model.caretPositionProperty(); } + + // selection anchor + @Override public final int getAnchor() { return model.getAnchor(); } + @Override public final ObservableValue anchorProperty() { return model.anchorProperty(); } + + // selection + @Override public final IndexRange getSelection() { return model.getSelection(); } + @Override public final ObservableValue selectionProperty() { return model.selectionProperty(); } + + // selected text + @Override public final String getSelectedText() { return model.getSelectedText(); } + @Override public final ObservableValue selectedTextProperty() { return model.selectedTextProperty(); } + + // current paragraph index + @Override public final int getCurrentParagraph() { return model.getCurrentParagraph(); } + @Override public final ObservableValue currentParagraphProperty() { return model.currentParagraphProperty(); } + + // caret column + @Override public final int getCaretColumn() { return model.getCaretColumn(); } + @Override public final ObservableValue caretColumnProperty() { return model.caretColumnProperty(); } + + // paragraphs + @Override public LiveList> getParagraphs() { return model.getParagraphs(); } + + // beingUpdated + public ObservableBooleanValue beingUpdatedProperty() { return model.beingUpdatedProperty(); } + public boolean isBeingUpdated() { return model.isBeingUpdated(); } + + // total width estimate + /** + * The estimated width of the entire document. Accurate when area does not wrap lines and + * uses the same font size throughout the entire area. Value is only supposed to be set by + * the skin, not the user. + */ + @Override + public Val totalWidthEstimateProperty() { return virtualFlow.totalWidthEstimateProperty(); } + public double getTotalWidthEstimate() { return virtualFlow.totalWidthEstimateProperty().getValue(); } + + // total height estimate + /** + * The estimated height of the entire document. Accurate when area does not wrap lines and + * uses the same font size throughout the entire area. Value is only supposed to be set by + * the skin, not the user. + */ + @Override + public Val totalHeightEstimateProperty() { return virtualFlow.totalHeightEstimateProperty(); } + public double getTotalHeightEstimate() { return virtualFlow.totalHeightEstimateProperty().getValue(); } + + /* ********************************************************************** * + * * + * Event streams * + * * + * ********************************************************************** */ + + // text changes + @Override public final EventStream plainTextChanges() { return model.plainTextChanges(); } + + // rich text changes + @Override public final EventStream> richChanges() { return model.richChanges(); } + + /* ********************************************************************** * + * * + * Private fields * + * * + * ********************************************************************** */ + + private Subscription subscriptions = () -> {}; + + // Remembers horizontal position when traversing up / down. + private Optional targetCaretOffset = Optional.empty(); + + private final Binding caretVisible; + + private final Val> _popupAnchorAdjustment; + + private final VirtualFlow, Cell, ParagraphBox>> virtualFlow; + + // used for two-level navigation, where on the higher level are + // paragraphs and on the lower level are lines within a paragraph + private final TwoLevelNavigator navigator; + + private boolean followCaretRequested = false; + + /** + * model + */ + private final StyledTextAreaModel model; + + /** + * @return this area's {@link StyledTextAreaModel} + */ + final StyledTextAreaModel getModel() { + return model; + } + + /* ********************************************************************** * + * * + * Fields necessary for Cloning * + * * + * ********************************************************************** */ + + /** + * The underlying document that can be displayed by multiple {@code StyledTextArea}s. + */ + public final EditableStyledDocument getContent() { return model.getContent(); } + + /** + * Style used by default when no other style is provided. + */ + public final S getInitialTextStyle() { return model.getInitialTextStyle(); } + + /** + * Style used by default when no other style is provided. + */ + public final PS getInitialParagraphStyle() { return model.getInitialParagraphStyle(); } + + /** + * Style applicator used by the default skin. + */ + private final BiConsumer applyParagraphStyle; + public final BiConsumer getApplyParagraphStyle() { return applyParagraphStyle; } + + /** + * Indicates whether style should be preserved on undo/redo, + * copy/paste and text move. + * TODO: Currently, only undo/redo respect this flag. + */ + public final boolean isPreserveStyle() { return model.isPreserveStyle(); } + + /* ********************************************************************** * + * * + * Miscellaneous * + * * + * ********************************************************************** */ + + private final TextOps segmentOps; + @Override public final SegmentOps getSegOps() { return segmentOps; } + + /* ********************************************************************** * + * * + * Constructors * + * * + * ********************************************************************** */ + + /** + * Creates a text area with empty text content. + * + * @param initialParagraphStyle style to use in places where no other style is + * specified (yet). + * @param applyParagraphStyle function that, given a {@link TextFlow} node and + * a style, applies the style to the paragraph node. This function is + * used by the default skin to apply style to paragraph nodes. + * @param initialTextStyle style to use in places where no other style is + * specified (yet). + * @param segmentOps The operations which are defined on the text segment objects. + * @param nodeFactory A function which is used to create the JavaFX scene nodes for a + * particular segment. + */ + public GenericStyledArea(PS initialParagraphStyle, BiConsumer applyParagraphStyle, + S initialTextStyle, TextOps segmentOps, + Function nodeFactory) { + this(initialParagraphStyle, applyParagraphStyle, initialTextStyle, segmentOps, true, nodeFactory); + } + + public GenericStyledArea(PS initialParagraphStyle, BiConsumer applyParagraphStyle, + S initialTextStyle, TextOps segmentOps, + boolean preserveStyle, Function nodeFactory) { + this(initialParagraphStyle, applyParagraphStyle, initialTextStyle, + new GenericEditableStyledDocument<>(initialParagraphStyle, initialTextStyle, segmentOps), segmentOps, preserveStyle, nodeFactory); + } + + /** + * The same as {@link #GenericStyledArea(Object, BiConsumer, Object, TextOps, Function)} except that + * this constructor can be used to create another {@code GenericStyledArea} object that + * shares the same {@link EditableStyledDocument}. + */ + public GenericStyledArea( + PS initialParagraphStyle, + BiConsumer applyParagraphStyle, + S initialTextStyle, + EditableStyledDocument document, + TextOps textOps, + Function nodeFactory) { + this(initialParagraphStyle, applyParagraphStyle, initialTextStyle, document, textOps, true, nodeFactory); + + } + + public GenericStyledArea( + PS initialParagraphStyle, + BiConsumer applyParagraphStyle, + S initialTextStyle, + EditableStyledDocument document, + TextOps textOps, + boolean preserveStyle, + Function nodeFactory) { + this.model = new StyledTextAreaModel<>(initialParagraphStyle, initialTextStyle, document, textOps, preserveStyle); + this.applyParagraphStyle = applyParagraphStyle; + this.segmentOps = textOps; + + // allow tab traversal into area + setFocusTraversable(true); + + this.setBackground(new Background(new BackgroundFill(Color.WHITE, CornerRadii.EMPTY, Insets.EMPTY))); + getStyleClass().add("styled-text-area"); + getStylesheets().add(StyledTextArea.class.getResource("styled-text-area.css").toExternalForm()); + + // keeps track of currently used non-empty cells + @SuppressWarnings("unchecked") + ObservableSet> nonEmptyCells = FXCollections.observableSet(); + + // Initialize content + virtualFlow = VirtualFlow.createVertical( + getParagraphs(), + par -> { + Cell, ParagraphBox> cell = createCell( + par, + applyParagraphStyle, + nodeFactory); + nonEmptyCells.add(cell.getNode()); + return cell.beforeReset(() -> nonEmptyCells.remove(cell.getNode())) + .afterUpdateItem(p -> nonEmptyCells.add(cell.getNode())); + }); + getChildren().add(virtualFlow); + + // initialize navigator + IntSupplier cellCount = () -> getParagraphs().size(); + IntUnaryOperator cellLength = i -> virtualFlow.getCell(i).getNode().getLineCount(); + navigator = new TwoLevelNavigator(cellCount, cellLength); + + // relayout the popup when any of its settings values change (besides the caret being dirty) + EventStream popupAlignmentDirty = invalidationsOf(popupAlignmentProperty()); + EventStream popupAnchorAdjustmentDirty = invalidationsOf(popupAnchorAdjustmentProperty()); + EventStream popupAnchorOffsetDirty = invalidationsOf(popupAnchorOffsetProperty()); + EventStream popupDirty = merge(popupAlignmentDirty, popupAnchorAdjustmentDirty, popupAnchorOffsetDirty); + subscribeTo(popupDirty, x -> layoutPopup()); + + // follow the caret every time the caret position or paragraphs change + EventStream caretPosDirty = invalidationsOf(caretPositionProperty()); + EventStream paragraphsDirty = invalidationsOf(getParagraphs()); + EventStream selectionDirty = invalidationsOf(selectionProperty()); + // need to reposition popup even when caret hasn't moved, but selection has changed (been deselected) + EventStream caretDirty = merge(caretPosDirty, paragraphsDirty, selectionDirty); + + // whether or not to display the caret + EventStream blinkCaret = EventStreams.valuesOf(showCaretProperty()) + .flatMap(mode -> { + switch (mode) { + case ON: + return EventStreams.valuesOf(Val.constant(true)); + case OFF: + return EventStreams.valuesOf(Val.constant(false)); + default: + case AUTO: + return EventStreams.valuesOf(focusedProperty() + .and(editableProperty()) + .and(disabledProperty().not())); + } + }); + + // the rate at which to display the caret + EventStream blinkRate = EventStreams.valuesOf(caretBlinkRate); + + // The caret is visible in periodic intervals, + // but only when blinkCaret is true. + caretVisible = EventStreams.combine(blinkCaret, blinkRate) + .flatMap(tuple -> { + Boolean blink = tuple.get1(); + javafx.util.Duration rate = tuple.get2(); + if(blink) { + return rate.lessThanOrEqualTo(ZERO) + ? EventStreams.valuesOf(Val.constant(true)) + : booleanPulse(rate, caretDirty); + } else { + return EventStreams.valuesOf(Val.constant(false)); + } + }) + .toBinding(false); + manageBinding(caretVisible); + + // Adjust popup anchor by either a user-provided function, + // or user-provided offset, or don't adjust at all. + Val> userOffset = Val.map( + popupAnchorOffsetProperty(), + offset -> anchor -> anchor.add(offset)); + _popupAnchorAdjustment = + Val.orElse( + popupAnchorAdjustmentProperty(), + userOffset) + .orElseConst(UnaryOperator.identity()); + + // dispatch MouseOverTextEvents when mouseOverTextDelay is not null + EventStreams.valuesOf(mouseOverTextDelayProperty()) + .flatMap(delay -> delay != null + ? mouseOverTextEvents(nonEmptyCells, delay) + : EventStreams.never()) + .subscribe(evt -> Event.fireEvent(this, evt)); + + new StyledTextAreaBehavior(this); + } + + + /* ********************************************************************** * + * * + * Queries * + * * + * Queries are parameterized observables. * + * * + * ********************************************************************** */ + + /** + * Returns caret bounds relative to the viewport, i.e. the visual bounds + * of the embedded VirtualFlow. + */ + Optional getCaretBounds() { + return virtualFlow.getCellIfVisible(getCurrentParagraph()) + .map(c -> { + Bounds cellBounds = c.getNode().getCaretBounds(); + return virtualFlow.cellToViewport(c, cellBounds); + }); + } + + /** + * Returns x coordinate of the caret in the current paragraph. + */ + ParagraphBox.CaretOffsetX getCaretOffsetX() { + int idx = getCurrentParagraph(); + return getCell(idx).getCaretOffsetX(); + } + + double getViewportHeight() { + return virtualFlow.getHeight(); + } + + CharacterHit hit(ParagraphBox.CaretOffsetX x, TwoDimensional.Position targetLine) { + int parIdx = targetLine.getMajor(); + ParagraphBox cell = virtualFlow.getCell(parIdx).getNode(); + CharacterHit parHit = cell.hitTextLine(x, targetLine.getMinor()); + return parHit.offset(getParagraphOffset(parIdx)); + } + + CharacterHit hit(ParagraphBox.CaretOffsetX x, double y) { + VirtualFlowHit, ParagraphBox>> hit = virtualFlow.hit(0.0, y); + if(hit.isBeforeCells()) { + return CharacterHit.insertionAt(0); + } else if(hit.isAfterCells()) { + return CharacterHit.insertionAt(getLength()); + } else { + int parIdx = hit.getCellIndex(); + int parOffset = getParagraphOffset(parIdx); + ParagraphBox cell = hit.getCell().getNode(); + Point2D cellOffset = hit.getCellOffset(); + CharacterHit parHit = cell.hitText(x, cellOffset.getY()); + return parHit.offset(parOffset); + } + } + + /** + * Helpful for determining which letter is at point x, y: + *

+     *     {@code
+     *     StyledTextArea area = // creation code
+     *     area.addEventHandler(MouseEvent.MOUSE_PRESSED, (MouseEvent e) -> {
+     *         CharacterHit hit = area.hit(e.getX(), e.getY());
+     *         int characterPosition = hit.getInsertionIndex();
+     *
+     *         // move the caret to that character's position
+     *         area.moveTo(characterPosition, SelectionPolicy.CLEAR);
+     *     }}
+     * 
+ */ + public CharacterHit hit(double x, double y) { + VirtualFlowHit, ParagraphBox>> hit = virtualFlow.hit(x, y); + if(hit.isBeforeCells()) { + return CharacterHit.insertionAt(0); + } else if(hit.isAfterCells()) { + return CharacterHit.insertionAt(getLength()); + } else { + int parIdx = hit.getCellIndex(); + int parOffset = getParagraphOffset(parIdx); + ParagraphBox cell = hit.getCell().getNode(); + Point2D cellOffset = hit.getCellOffset(); + CharacterHit parHit = cell.hit(cellOffset); + return parHit.offset(parOffset); + } + } + + /** + * Returns the current line as a two-level index. + * The major number is the paragraph index, the minor + * number is the line number within the paragraph. + * + *

This method has a side-effect of bringing the current + * paragraph to the viewport if it is not already visible. + */ + TwoDimensional.Position currentLine() { + int parIdx = getCurrentParagraph(); + Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx); + int lineIdx = cell.getNode().getCurrentLineIndex(); + return _position(parIdx, lineIdx); + } + + TwoDimensional.Position _position(int par, int line) { + return navigator.position(par, line); + } + + @Override + public final String getText(int start, int end) { + return model.getText(start, end); + } + + @Override + public String getText(int paragraph) { + return model.getText(paragraph); + } + + public Paragraph getParagraph(int index) { + return model.getParagraph(index); + } + + @Override + public StyledDocument subDocument(int start, int end) { + return model.subDocument(start, end); + } + + @Override + public StyledDocument subDocument(int paragraphIndex) { + return model.subDocument(paragraphIndex); + } + + /** + * Returns the selection range in the given paragraph. + */ + public IndexRange getParagraphSelection(int paragraph) { + return model.getParagraphSelection(paragraph); + } + + /** + * Returns the style of the character with the given index. + * If {@code index} points to a line terminator character, + * the last style used in the paragraph terminated by that + * line terminator is returned. + */ + public S getStyleOfChar(int index) { + return model.getStyleOfChar(index); + } + + /** + * Returns the style at the given position. That is the style of the + * character immediately preceding {@code position}, except when + * {@code position} points to a paragraph boundary, in which case it + * is the style at the beginning of the latter paragraph. + * + *

In other words, most of the time {@code getStyleAtPosition(p)} + * is equivalent to {@code getStyleOfChar(p-1)}, except when {@code p} + * points to a paragraph boundary, in which case it is equivalent to + * {@code getStyleOfChar(p)}. + */ + public S getStyleAtPosition(int position) { + return model.getStyleAtPosition(position); + } + + /** + * 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. If {@code position} + * points to a boundary between two paragraphs, then the first styled range + * of the latter paragraph is returned. + */ + public IndexRange getStyleRangeAtPosition(int position) { + return model.getStyleRangeAtPosition(position); + } + + /** + * Returns the styles in the given character range. + */ + public StyleSpans getStyleSpans(int from, int to) { + return model.getStyleSpans(from, to); + } + + /** + * Returns the styles in the given character range. + */ + public StyleSpans getStyleSpans(IndexRange range) { + return getStyleSpans(range.getStart(), range.getEnd()); + } + + /** + * Returns the style of the character with the given index in the given + * paragraph. If {@code index} is beyond the end of the paragraph, the + * style at the end of line is returned. If {@code index} is negative, it + * is the same as if it was 0. + */ + public S getStyleOfChar(int paragraph, int index) { + return model.getStyleOfChar(paragraph, index); + } + + /** + * Returns the style at the given position in the given paragraph. + * This is equivalent to {@code getStyleOfChar(paragraph, position-1)}. + */ + public S getStyleAtPosition(int paragraph, int position) { + return model.getStyleAtPosition(paragraph, position); + } + + /** + * Returns the range of homogeneous style that includes the given position + * in the given paragraph. If {@code position} points to a boundary between + * two styled ranges, then the range preceding {@code position} is returned. + */ + public IndexRange getStyleRangeAtPosition(int paragraph, int position) { + return model.getStyleRangeAtPosition(paragraph, position); + } + + /** + * Returns styles of the whole paragraph. + */ + public StyleSpans getStyleSpans(int paragraph) { + return model.getStyleSpans(paragraph); + } + + /** + * Returns the styles in the given character range of the given paragraph. + */ + public StyleSpans getStyleSpans(int paragraph, int from, int to) { + return model.getStyleSpans(paragraph, from, to); + } + + /** + * Returns the styles in the given character range of the given paragraph. + */ + public StyleSpans getStyleSpans(int paragraph, IndexRange range) { + return getStyleSpans(paragraph, range.getStart(), range.getEnd()); + } + + @Override + public int getAbsolutePosition(int paragraphIndex, int columnIndex) { + return model.getAbsolutePosition(paragraphIndex, columnIndex); + } + + @Override + public Position position(int row, int col) { + return model.position(row, col); + } + + @Override + public Position offsetToPosition(int charOffset, Bias bias) { + return model.offsetToPosition(charOffset, bias); + } + + + /* ********************************************************************** * + * * + * Actions * + * * + * Actions change the state of this control. They typically cause a * + * change of one or more observables and/or produce an event. * + * * + * ********************************************************************** */ + + void scrollBy(Point2D deltas) { + virtualFlow.scrollXBy(deltas.getX()); + virtualFlow.scrollYBy(deltas.getY()); + } + + void show(double y) { + virtualFlow.show(y); + } + + void showCaretAtBottom() { + int parIdx = getCurrentParagraph(); + Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx); + Bounds caretBounds = cell.getNode().getCaretBounds(); + double y = caretBounds.getMaxY(); + virtualFlow.showAtOffset(parIdx, getViewportHeight() - y); + } + + void showCaretAtTop() { + int parIdx = getCurrentParagraph(); + Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx); + Bounds caretBounds = cell.getNode().getCaretBounds(); + double y = caretBounds.getMinY(); + virtualFlow.showAtOffset(parIdx, -y); + } + + /** + * If the caret is not visible within the area's view, the area will scroll so that caret + * is visible in the next layout pass. Use this method when you wish to "follow the caret" + * (i.e. auto-scroll to caret) after making a change (add/remove/modify area's segments). + */ + public void requestFollowCaret() { + followCaretRequested = true; + requestLayout(); + } + + private void followCaret() { + int parIdx = getCurrentParagraph(); + Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx); + Bounds caretBounds = cell.getNode().getCaretBounds(); + double graphicWidth = cell.getNode().getGraphicPrefWidth(); + Bounds region = extendLeft(caretBounds, graphicWidth); + virtualFlow.show(parIdx, region); + } + + /** + * Moves caret to the previous page (i.e. page up) + * @param selectionPolicy use {@link SelectionPolicy#CLEAR} when no selection is desired and + * {@link SelectionPolicy#ADJUST} when a selection from starting point + * to the place to where the caret is moved is desired. + */ + public void prevPage(SelectionPolicy selectionPolicy) { + showCaretAtBottom(); + CharacterHit hit = hit(getTargetCaretOffset(), 1.0); + model.moveTo(hit.getInsertionIndex(), selectionPolicy); + } + + /** + * Moves caret to the next page (i.e. page down) + * @param selectionPolicy use {@link SelectionPolicy#CLEAR} when no selection is desired and + * {@link SelectionPolicy#ADJUST} when a selection from starting point + * to the place to where the caret is moved is desired. + */ + public void nextPage(SelectionPolicy selectionPolicy) { + showCaretAtTop(); + CharacterHit hit = hit(getTargetCaretOffset(), getViewportHeight() - 1.0); + model.moveTo(hit.getInsertionIndex(), selectionPolicy); + } + + /** + * Sets style for the given character range. + */ + public void setStyle(int from, int to, S style) { + model.setStyle(from, to, style); + } + + /** + * Sets style for the whole paragraph. + */ + public void setStyle(int paragraph, S style) { + model.setStyle(paragraph, style); + } + + /** + * Sets style for the given range relative in the given paragraph. + */ + public void setStyle(int paragraph, int from, int to, S style) { + model.setStyle(paragraph, from, to, style); + } + + /** + * Set multiple style ranges at once. This is equivalent to + *

+     * for(StyleSpan{@code } span: styleSpans) {
+     *     setStyle(from, from + span.getLength(), span.getStyle());
+     *     from += span.getLength();
+     * }
+     * 
+ * but the actual implementation is more efficient. + */ + public void setStyleSpans(int from, StyleSpans styleSpans) { + model.setStyleSpans(from, styleSpans); + } + + /** + * Set multiple style ranges of a paragraph at once. This is equivalent to + *
+     * for(StyleSpan{@code } span: styleSpans) {
+     *     setStyle(paragraph, from, from + span.getLength(), span.getStyle());
+     *     from += span.getLength();
+     * }
+     * 
+ * but the actual implementation is more efficient. + */ + public void setStyleSpans(int paragraph, int from, StyleSpans styleSpans) { + model.setStyleSpans(paragraph, from, styleSpans); + } + + /** + * Sets style for the whole paragraph. + */ + public void setParagraphStyle(int paragraph, PS paragraphStyle) { + model.setParagraphStyle(paragraph, paragraphStyle); + } + + /** + * Resets the style of the given range to the initial style. + */ + public void clearStyle(int from, int to) { + model.clearStyle(from, to); + } + + /** + * Resets the style of the given paragraph to the initial style. + */ + public void clearStyle(int paragraph) { + model.clearStyle(paragraph); + } + + /** + * Resets the style of the given range in the given paragraph + * to the initial style. + */ + public void clearStyle(int paragraph, int from, int to) { + model.clearStyle(paragraph, from, to); + } + + /** + * Resets the style of the given paragraph to the initial style. + */ + public void clearParagraphStyle(int paragraph) { + model.clearParagraphStyle(paragraph); + } + + @Override + public void replaceText(int start, int end, String text) { + model.replaceText(start, end, text); + } + + @Override + public void replace(int start, int end, StyledDocument replacement) { + model.replace(start, end, replacement); + } + + @Override + public void selectRange(int anchor, int caretPosition) { + model.selectRange(anchor, caretPosition); + } + + /** + * {@inheritDoc} + * @deprecated You probably meant to use {@link #moveTo(int)}. This method will be made + * package-private in the future + */ + @Deprecated + @Override + public void positionCaret(int pos) { + model.positionCaret(pos); + } + + /* ********************************************************************** * + * * + * Public API * + * * + * ********************************************************************** */ + + public void dispose() { + subscriptions.unsubscribe(); + model.dispose(); + virtualFlow.dispose(); + } + + /* ********************************************************************** * + * * + * Layout * + * * + * ********************************************************************** */ + + @Override + protected void layoutChildren() { + virtualFlow.resize(getWidth(), getHeight()); + if(followCaretRequested) { + followCaretRequested = false; + followCaret(); + } + + // position popup + layoutPopup(); + } + + /* ********************************************************************** * + * * + * Private methods * + * * + * ********************************************************************** */ + + private Cell, ParagraphBox> createCell( + Paragraph paragraph, + BiConsumer applyParagraphStyle, + Function nodeFactory) { + + ParagraphBox box = new ParagraphBox<>(paragraph, applyParagraphStyle, nodeFactory); + + box.highlightFillProperty().bind(highlightFill); + box.highlightTextFillProperty().bind(highlightTextFill); + box.wrapTextProperty().bind(wrapTextProperty()); + box.graphicFactoryProperty().bind(paragraphGraphicFactoryProperty()); + box.graphicOffset.bind(virtualFlow.breadthOffsetProperty()); + + Val hasCaret = Val.combine( + box.indexProperty(), + currentParagraphProperty(), + (bi, cp) -> bi.intValue() == cp.intValue()); + + Subscription hasCaretPseudoClass = hasCaret.values().subscribe(value -> box.pseudoClassStateChanged(HAS_CARET, value)); + Subscription firstParPseudoClass = box.indexProperty().values().subscribe(idx -> box.pseudoClassStateChanged(FIRST_PAR, idx == 0)); + Subscription lastParPseudoClass = EventStreams.combine( + box.indexProperty().values(), + getParagraphs().sizeProperty().values() + ).subscribe(in -> in.exec((i, n) -> box.pseudoClassStateChanged(LAST_PAR, i == n-1))); + + // caret is visible only in the paragraph with the caret + Val cellCaretVisible = hasCaret.flatMap(x -> x ? caretVisible : Val.constant(false)); + box.caretVisibleProperty().bind(cellCaretVisible); + + // bind cell's caret position to area's caret column, + // when the cell is the one with the caret + box.caretPositionProperty().bind(hasCaret.flatMap(has -> has + ? caretColumnProperty() + : Val.constant(0))); + + // keep paragraph selection updated + ObjectBinding cellSelection = Bindings.createObjectBinding(() -> { + int idx = box.getIndex(); + return idx != -1 + ? getParagraphSelection(idx) + : StyledTextArea.EMPTY_RANGE; + }, selectionProperty(), box.indexProperty()); + box.selectionProperty().bind(cellSelection); + + return new Cell, ParagraphBox>() { + @Override + public ParagraphBox getNode() { + return box; + } + + @Override + public void updateIndex(int index) { + box.setIndex(index); + } + + @Override + public void dispose() { + box.highlightFillProperty().unbind(); + box.highlightTextFillProperty().unbind(); + box.wrapTextProperty().unbind(); + box.graphicFactoryProperty().unbind(); + box.graphicOffset.unbind(); + + hasCaretPseudoClass.unsubscribe(); + firstParPseudoClass.unsubscribe(); + lastParPseudoClass.unsubscribe(); + + box.caretVisibleProperty().unbind(); + box.caretPositionProperty().unbind(); + + box.selectionProperty().unbind(); + cellSelection.dispose(); + } + }; + } + + private ParagraphBox getCell(int index) { + return virtualFlow.getCell(index).getNode(); + } + + private EventStream mouseOverTextEvents(ObservableSet> cells, Duration delay) { + return merge(cells, c -> c.stationaryIndices(delay).map(e -> e.unify( + l -> l.map((pos, charIdx) -> MouseOverTextEvent.beginAt(c.localToScreen(pos), getParagraphOffset(c.getIndex()) + charIdx)), + r -> MouseOverTextEvent.end()))); + } + + private int getParagraphOffset(int parIdx) { + return position(parIdx, 0).toOffset(); + } + + private void layoutPopup() { + PopupWindow popup = getPopupWindow(); + PopupAlignment alignment = getPopupAlignment(); + UnaryOperator adjustment = _popupAnchorAdjustment.getValue(); + if(popup != null) { + positionPopup(popup, alignment, adjustment); + } + } + + private void positionPopup( + PopupWindow popup, + PopupAlignment alignment, + UnaryOperator adjustment) { + Optional bounds = null; + switch(alignment.getAnchorObject()) { + case CARET: bounds = getCaretBoundsOnScreen(); break; + case SELECTION: bounds = getSelectionBoundsOnScreen(); break; + } + bounds.ifPresent(b -> { + double x = 0, y = 0; + switch(alignment.getHorizontalAlignment()) { + case LEFT: x = b.getMinX(); break; + case H_CENTER: x = (b.getMinX() + b.getMaxX()) / 2; break; + case RIGHT: x = b.getMaxX(); break; + } + switch(alignment.getVerticalAlignment()) { + case TOP: y = b.getMinY(); + case V_CENTER: y = (b.getMinY() + b.getMaxY()) / 2; break; + case BOTTOM: y = b.getMaxY(); break; + } + Point2D anchor = adjustment.apply(new Point2D(x, y)); + popup.setAnchorX(anchor.getX()); + popup.setAnchorY(anchor.getY()); + }); + } + + private Optional getCaretBoundsOnScreen() { + return virtualFlow.getCellIfVisible(getCurrentParagraph()) + .map(c -> c.getNode().getCaretBoundsOnScreen()); + } + + private Optional getSelectionBoundsOnScreen() { + IndexRange selection = getSelection(); + if(selection.getLength() == 0) { + return getCaretBoundsOnScreen(); + } + + Bounds[] bounds = virtualFlow.visibleCells().stream() + .map(c -> c.getNode().getSelectionBoundsOnScreen()) + .filter(Optional::isPresent) + .map(Optional::get) + .toArray(Bounds[]::new); + + if(bounds.length == 0) { + return Optional.empty(); + } + double minX = Stream.of(bounds).mapToDouble(Bounds::getMinX).min().getAsDouble(); + double maxX = Stream.of(bounds).mapToDouble(Bounds::getMaxX).max().getAsDouble(); + double minY = Stream.of(bounds).mapToDouble(Bounds::getMinY).min().getAsDouble(); + double maxY = Stream.of(bounds).mapToDouble(Bounds::getMaxY).max().getAsDouble(); + return Optional.of(new BoundingBox(minX, minY, maxX-minX, maxY-minY)); + } + + private void subscribeTo(EventStream src, Consumer cOnsumer) { + manageSubscription(src.subscribe(cOnsumer)); + } + + private void manageSubscription(Subscription subscription) { + subscriptions = subscriptions.and(subscription); + } + + private void manageBinding(Binding binding) { + subscriptions = subscriptions.and(binding::dispose); + } + + private static Bounds extendLeft(Bounds b, double w) { + if(w == 0) { + return b; + } else { + return new BoundingBox( + b.getMinX() - w, b.getMinY(), + b.getWidth() + w, b.getHeight()); + } + } + + void clearTargetCaretOffset() { + targetCaretOffset = Optional.empty(); + } + + ParagraphBox.CaretOffsetX getTargetCaretOffset() { + if(!targetCaretOffset.isPresent()) + targetCaretOffset = Optional.of(getCaretOffsetX()); + return targetCaretOffset.get(); + } + + private static EventStream booleanPulse(javafx.util.Duration javafxDuration, EventStream restartImpulse) { + Duration duration = Duration.ofMillis(Math.round(javafxDuration.toMillis())); + EventStream ticks = EventStreams.restartableTicks(duration, restartImpulse); + return StateMachine.init(false) + .on(restartImpulse.withDefaultEvent(null)).transition((state, impulse) -> true) + .on(ticks).transition((state, tick) -> !state) + .toStateStream(); + } +} diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/InlineCssTextArea.java b/richtextfx/src/main/java/org/fxmisc/richtext/InlineCssTextArea.java index 1fce6daa1..003a76a09 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/InlineCssTextArea.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/InlineCssTextArea.java @@ -1,11 +1,12 @@ package org.fxmisc.richtext; +import javafx.scene.text.TextFlow; + import org.fxmisc.richtext.model.Codec; import org.fxmisc.richtext.model.EditableStyledDocument; import org.fxmisc.richtext.model.SimpleEditableStyledDocument; - -import javafx.scene.text.TextFlow; +import org.fxmisc.richtext.model.StyledText; /** * Text area that uses inline css to define style of text segments and paragraph segments. @@ -16,7 +17,7 @@ public InlineCssTextArea() { this(new SimpleEditableStyledDocument<>("", "")); } - public InlineCssTextArea(EditableStyledDocument document) { + public InlineCssTextArea(EditableStyledDocument, String> document) { super( "", TextFlow::setStyle, "", TextExt::setStyle, @@ -38,7 +39,7 @@ public InlineCssTextArea(String text) { getUndoManager().forgetHistory(); getUndoManager().mark(); - setStyleCodecs(Codec.STRING_CODEC, Codec.STRING_CODEC); + setStyleCodecs(Codec.STRING_CODEC, StyledText.codec(Codec.STRING_CODEC)); // position the caret at the beginning selectRange(0, 0); diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphBox.java b/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphBox.java index ca7c4fd26..b7ed80c32 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphBox.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphBox.java @@ -6,6 +6,7 @@ import java.util.Optional; import java.util.OptionalInt; import java.util.function.BiConsumer; +import java.util.function.Function; import java.util.function.IntFunction; import javafx.beans.property.BooleanProperty; @@ -32,7 +33,7 @@ import org.reactfx.value.Val; import org.reactfx.value.Var; -class ParagraphBox extends Region { +class ParagraphBox extends Region { /** * An opaque class representing horizontal caret offset. @@ -47,7 +48,7 @@ private CaretOffsetX(double value) { } } - private final ParagraphText text; + private final ParagraphText text; private final ObjectProperty> graphicFactory = new SimpleObjectProperty<>(null); @@ -70,9 +71,10 @@ public ObjectProperty> graphicFactoryProperty() { public void setIndex(int index) { this.index.setValue(index); } public int getIndex() { return index.getValue(); } - ParagraphBox(Paragraph par, BiConsumer applyParagraphStyle, BiConsumer applyStyle) { + ParagraphBox(Paragraph par, BiConsumer applyParagraphStyle, + Function nodeFactory) { this.getStyleClass().add("paragraph-box"); - this.text = new ParagraphText<>(par, applyStyle); + this.text = new ParagraphText<>(par, nodeFactory); applyParagraphStyle.accept(this.text, par.getParagraphStyle()); this.index = Var.newSimpleVar(0); getChildren().add(text); @@ -108,7 +110,7 @@ public String toString() { public Property selectionProperty() { return text.selectionProperty(); } - Paragraph getParagraph() { + Paragraph getParagraph() { return text.getParagraph(); } diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphText.java b/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphText.java index 2173b4616..4554af545 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphText.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/ParagraphText.java @@ -3,14 +3,13 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.function.BiConsumer; +import java.util.function.Function; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.collections.transformation.FilteredList; import javafx.geometry.Bounds; import javafx.geometry.Insets; -import javafx.geometry.VPos; import javafx.scene.Node; import javafx.scene.control.IndexRange; import javafx.scene.paint.Color; @@ -20,11 +19,10 @@ import javafx.scene.shape.StrokeLineCap; import org.fxmisc.richtext.model.Paragraph; -import org.fxmisc.richtext.model.StyledText; import org.reactfx.value.Val; import org.reactfx.value.Var; -class ParagraphText extends TextFlowExt { +class ParagraphText extends TextFlowExt { // FIXME: changing it currently has not effect, because // Text.impl_selectionFillProperty().set(newFill) doesn't work @@ -43,7 +41,7 @@ public ObjectProperty highlightTextFillProperty() { public ObjectProperty selectionProperty() { return selection; } public void setSelection(IndexRange sel) { selection.set(sel); } - private final Paragraph paragraph; + private final Paragraph paragraph; private final Path caretShape = new Path(); private final Path selectionShape = new Path(); @@ -58,7 +56,7 @@ public ObjectProperty highlightTextFillProperty() { caretShape.visibleProperty().bind(caretVisible); } - public ParagraphText(Paragraph par, BiConsumer applyStyle) { + ParagraphText(Paragraph par, Function nodeFactory) { this.paragraph = par; getStyleClass().add("paragraph-text"); @@ -99,17 +97,10 @@ public ParagraphText(Paragraph par, BiConsumer applyS // }); // populate with text nodes - for(StyledText segment: par.getSegments()) { - TextExt t = new TextExt(segment.getText()); - t.setTextOrigin(VPos.TOP); - t.getStyleClass().add("text"); - applyStyle.accept(t, segment.getStyle()); - - // XXX: binding selectionFill to textFill, - // see the note at highlightTextFill - t.impl_selectionFillProperty().bind(t.fillProperty()); - - getChildren().add(t); + for(SEG segment: par.getSegments()) { + // create Segment + Node fxNode = nodeFactory.apply(segment); + getChildren().add(fxNode); // add corresponding background node (empty) Path backgroundShape = new Path(); @@ -131,7 +122,7 @@ public ParagraphText(Paragraph par, BiConsumer applyS } } - public Paragraph getParagraph() { + public Paragraph getParagraph() { return paragraph; } diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/StyleClassedTextArea.java b/richtextfx/src/main/java/org/fxmisc/richtext/StyleClassedTextArea.java index ed57050e7..363324971 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/StyleClassedTextArea.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/StyleClassedTextArea.java @@ -6,13 +6,14 @@ import org.fxmisc.richtext.model.Codec; import org.fxmisc.richtext.model.EditableStyledDocument; import org.fxmisc.richtext.model.SimpleEditableStyledDocument; +import org.fxmisc.richtext.model.StyledText; /** * Text area that uses style classes to define style of text segments and paragraph segments. */ public class StyleClassedTextArea extends StyledTextArea, Collection> { - public StyleClassedTextArea(EditableStyledDocument, Collection> document, boolean preserveStyle) { + public StyleClassedTextArea(EditableStyledDocument, StyledText>, Collection> document, boolean preserveStyle) { super(Collections.emptyList(), (paragraph, styleClasses) -> paragraph.getStyleClass().addAll(styleClasses), Collections.emptyList(), @@ -22,7 +23,7 @@ public StyleClassedTextArea(EditableStyledDocument, Collectio setStyleCodecs( Codec.collectionCodec(Codec.STRING_CODEC), - Codec.collectionCodec(Codec.STRING_CODEC) + StyledText.codec(Codec.collectionCodec(Codec.STRING_CODEC)) ); } public StyleClassedTextArea(boolean preserveStyle) { diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/StyledTextArea.java b/richtextfx/src/main/java/org/fxmisc/richtext/StyledTextArea.java index e1c1fa16a..3be3366a6 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/StyledTextArea.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/StyledTextArea.java @@ -1,83 +1,15 @@ package org.fxmisc.richtext; -import static javafx.util.Duration.*; -import static org.fxmisc.richtext.PopupAlignment.*; -import static org.reactfx.EventStreams.*; -import static org.reactfx.util.Tuples.*; - -import java.time.Duration; -import java.util.Optional; import java.util.function.BiConsumer; -import java.util.function.Consumer; -import java.util.function.IntConsumer; -import java.util.function.IntFunction; -import java.util.function.IntSupplier; -import java.util.function.IntUnaryOperator; -import java.util.function.UnaryOperator; -import java.util.stream.Stream; -import javafx.beans.binding.Binding; -import javafx.beans.binding.Bindings; -import javafx.beans.binding.ObjectBinding; -import javafx.beans.property.BooleanProperty; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.Property; -import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.property.SimpleObjectProperty; -import javafx.beans.value.ObservableBooleanValue; -import javafx.beans.value.ObservableValue; -import javafx.collections.FXCollections; -import javafx.collections.ObservableSet; -import javafx.css.PseudoClass; -import javafx.css.StyleableObjectProperty; -import javafx.event.Event; -import javafx.geometry.BoundingBox; -import javafx.geometry.Bounds; -import javafx.geometry.Insets; -import javafx.geometry.Point2D; +import javafx.geometry.VPos; import javafx.scene.Node; -import javafx.scene.control.IndexRange; -import javafx.scene.layout.Background; -import javafx.scene.layout.BackgroundFill; -import javafx.scene.layout.CornerRadii; -import javafx.scene.layout.Region; -import javafx.scene.paint.Color; -import javafx.scene.paint.Paint; -import javafx.scene.text.Text; import javafx.scene.text.TextFlow; -import javafx.stage.PopupWindow; -import org.fxmisc.flowless.Cell; -import org.fxmisc.flowless.VirtualFlow; -import org.fxmisc.flowless.VirtualFlowHit; -import org.fxmisc.flowless.Virtualized; -import org.fxmisc.flowless.VirtualizedScrollPane; -import org.fxmisc.richtext.CssProperties.EditableProperty; -import org.fxmisc.richtext.model.Codec; -import org.fxmisc.richtext.model.EditActions; import org.fxmisc.richtext.model.EditableStyledDocument; -import org.fxmisc.richtext.model.NavigationActions; -import org.fxmisc.richtext.model.Paragraph; -import org.fxmisc.richtext.model.PlainTextChange; -import org.fxmisc.richtext.model.RichTextChange; +import org.fxmisc.richtext.model.SegmentOps; import org.fxmisc.richtext.model.SimpleEditableStyledDocument; -import org.fxmisc.richtext.model.StyleSpans; -import org.fxmisc.richtext.model.StyledDocument; -import org.fxmisc.richtext.model.StyledTextAreaModel; -import org.fxmisc.richtext.model.TextEditingArea; -import org.fxmisc.richtext.model.TwoDimensional; -import org.fxmisc.richtext.model.TwoLevelNavigator; -import org.fxmisc.richtext.model.UndoActions; -import org.fxmisc.undo.UndoManager; -import org.fxmisc.undo.UndoManagerFactory; -import org.reactfx.EventStream; -import org.reactfx.EventStreams; -import org.reactfx.StateMachine; -import org.reactfx.Subscription; -import org.reactfx.collection.LiveList; -import org.reactfx.util.Tuple2; -import org.reactfx.value.Val; -import org.reactfx.value.Var; +import org.fxmisc.richtext.model.StyledText; /** * Text editing control. Accepts user input (keyboard, mouse) and @@ -89,7 +21,7 @@ * *

Note: Scroll bars no longer appear when the content spans outside * of the viewport. To add scroll bars, the area needs to be wrapped in - * a {@link VirtualizedScrollPane}. For example,

+ * a {@link org.fxmisc.flowless.VirtualizedScrollPane}. For example,

*
  * {@code
  * // shows area without scroll bars
@@ -103,20 +35,6 @@
  * }
  * 
* - *

Auto-Scrolling to the Caret

- * - *

Every time the underlying {@link EditableStyledDocument} changes via user interaction (e.g. typing) through - * the {@code StyledTextArea}, the area will scroll to insure the caret is kept in view. However, this does not - * occur if changes are done programmatically. For example, let's say the area is displaying the bottom part - * of the area's {@link EditableStyledDocument} and some code changes something in the top part of the document - * that is not currently visible. If there is no call to {@link #requestFollowCaret()} at the end of that code, - * the area will not auto-scroll to that section of the document. The change will occur, and the user will continue - * to see the bottom part of the document as before. If such a call is there, then the area will scroll - * to the top of the document and no longer display the bottom part of it.

- * - *

Additionally, when overriding the default user-interaction behavior, remember to include a call - * to {@link #requestFollowCaret()}.

- * *

Overriding keyboard shortcuts

* * {@code StyledTextArea} uses {@code KEY_TYPED} handler to handle ordinary @@ -141,1215 +59,53 @@ * * @param type of style that can be applied to text. */ -public class StyledTextArea extends Region - implements - TextEditingArea, - EditActions, - ClipboardActions, - NavigationActions, - UndoActions, - TwoDimensional, - Virtualized { - - /** - * Index range [0, 0). - */ - public static final IndexRange EMPTY_RANGE = new IndexRange(0, 0); - - private static final PseudoClass HAS_CARET = PseudoClass.getPseudoClass("has-caret"); - private static final PseudoClass FIRST_PAR = PseudoClass.getPseudoClass("first-paragraph"); - private static final PseudoClass LAST_PAR = PseudoClass.getPseudoClass("last-paragraph"); - - - /* ********************************************************************** * - * * - * Properties * - * * - * Properties affect behavior and/or appearance of this control. * - * * - * They are readable and writable by the client code and never change by * - * other means, i.e. they contain either the default value or the value * - * set by the client code. * - * * - * ********************************************************************** */ - - /** - * Background fill for highlighted text. - */ - private final StyleableObjectProperty highlightFill - = new CssProperties.HighlightFillProperty(this, Color.DODGERBLUE); - - /** - * Text color for highlighted text. - */ - private final StyleableObjectProperty highlightTextFill - = new CssProperties.HighlightTextFillProperty(this, Color.WHITE); - - /** - * Controls the blink rate of the caret, when one is displayed. Setting - * the duration to zero disables blinking. - */ - private final StyleableObjectProperty caretBlinkRate - = new CssProperties.CaretBlinkRateProperty(this, javafx.util.Duration.millis(500)); - - // editable property - /** - * Indicates whether this text area can be edited by the user. - * Note that this property doesn't affect editing through the API. - */ - private final BooleanProperty editable = new EditableProperty<>(this); - public final boolean isEditable() { return editable.get(); } - public final void setEditable(boolean value) { editable.set(value); } - public final BooleanProperty editableProperty() { return editable; } - - // wrapText property - /** - * When a run of text exceeds the width of the text region, - * then this property indicates whether the text should wrap - * onto another line. - */ - private final BooleanProperty wrapText = new SimpleBooleanProperty(this, "wrapText"); - public final boolean isWrapText() { return wrapText.get(); } - public final void setWrapText(boolean value) { wrapText.set(value); } - public final BooleanProperty wrapTextProperty() { return wrapText; } - - // showCaret property - /** - * Indicates when this text area should display a caret. - */ - private final Var showCaret = Var.newSimpleVar(CaretVisibility.AUTO); - public final CaretVisibility getShowCaret() { return showCaret.getValue(); } - public final void setShowCaret(CaretVisibility value) { showCaret.setValue(value); } - public final Var showCaretProperty() { return showCaret; } - - public static enum CaretVisibility { - /** Caret is displayed. */ - ON, - /** Caret is displayed when area is focused, enabled, and editable. */ - AUTO, - /** Caret is not displayed. */ - OFF - } - - // undo manager - @Override public UndoManager getUndoManager() { return model.getUndoManager(); } - @Override public void setUndoManager(UndoManagerFactory undoManagerFactory) { - model.setUndoManager(undoManagerFactory); - } - - /** - * Popup window that will be positioned by this text area relative to the - * caret or selection. Use {@link #popupAlignmentProperty()} to specify - * how the popup should be positioned relative to the caret or selection. - * Use {@link #popupAnchorOffsetProperty()} or - * {@link #popupAnchorAdjustmentProperty()} to further adjust the position. - */ - private final ObjectProperty popupWindow = new SimpleObjectProperty<>(); - public void setPopupWindow(PopupWindow popup) { popupWindow.set(popup); } - public PopupWindow getPopupWindow() { return popupWindow.get(); } - public ObjectProperty popupWindowProperty() { return popupWindow; } - - /** @deprecated Use {@link #setPopupWindow(PopupWindow)}. */ - @Deprecated - public void setPopupAtCaret(PopupWindow popup) { popupWindow.set(popup); } - /** @deprecated Use {@link #getPopupWindow()}. */ - @Deprecated - public PopupWindow getPopupAtCaret() { return popupWindow.get(); } - /** @deprecated Use {@link #popupWindowProperty()}. */ - @Deprecated - public ObjectProperty popupAtCaretProperty() { return popupWindow; } - - /** - * Specifies further offset (in pixels) of the popup window from the - * position specified by {@link #popupAlignmentProperty()}. - * - *

If {@link #popupAnchorAdjustmentProperty()} is also specified, then - * it overrides the offset set by this property. - */ - private final ObjectProperty popupAnchorOffset = new SimpleObjectProperty<>(); - public void setPopupAnchorOffset(Point2D offset) { popupAnchorOffset.set(offset); } - public Point2D getPopupAnchorOffset() { return popupAnchorOffset.get(); } - public ObjectProperty popupAnchorOffsetProperty() { return popupAnchorOffset; } - - /** - * Specifies how to adjust the popup window's anchor point. The given - * operator is invoked with the screen position calculated according to - * {@link #popupAlignmentProperty()} and should return a new screen - * position. This position will be used as the popup window's anchor point. - * - *

Setting this property overrides {@link #popupAnchorOffsetProperty()}. - */ - private final ObjectProperty> popupAnchorAdjustment = new SimpleObjectProperty<>(); - public void setPopupAnchorAdjustment(UnaryOperator f) { popupAnchorAdjustment.set(f); } - public UnaryOperator getPopupAnchorAdjustment() { return popupAnchorAdjustment.get(); } - public ObjectProperty> popupAnchorAdjustmentProperty() { return popupAnchorAdjustment; } - - /** - * Defines where the popup window given in {@link #popupWindowProperty()} - * is anchored, i.e. where its anchor point is positioned. This position - * can further be adjusted by {@link #popupAnchorOffsetProperty()} or - * {@link #popupAnchorAdjustmentProperty()}. - */ - private final ObjectProperty popupAlignment = new SimpleObjectProperty<>(CARET_TOP); - public void setPopupAlignment(PopupAlignment pos) { popupAlignment.set(pos); } - public PopupAlignment getPopupAlignment() { return popupAlignment.get(); } - public ObjectProperty popupAlignmentProperty() { return popupAlignment; } - - /** - * Defines how long the mouse has to stay still over the text before a - * {@link MouseOverTextEvent} of type {@code MOUSE_OVER_TEXT_BEGIN} is - * fired on this text area. When set to {@code null}, no - * {@code MouseOverTextEvent}s are fired on this text area. - * - *

Default value is {@code null}. - */ - private final ObjectProperty mouseOverTextDelay = new SimpleObjectProperty<>(null); - public void setMouseOverTextDelay(Duration delay) { mouseOverTextDelay.set(delay); } - public Duration getMouseOverTextDelay() { return mouseOverTextDelay.get(); } - public ObjectProperty mouseOverTextDelayProperty() { return mouseOverTextDelay; } - - /** - * Defines how to handle an event in which the user has selected some text, dragged it to a - * new location within the area, and released the mouse at some character {@code index} - * within the area. - * - *

By default, this will relocate the selected text to the character index where the mouse - * was released. To override it, use {@link #setOnSelectionDrop(IntConsumer)}. - */ - private Property onSelectionDrop = new SimpleObjectProperty<>(this::moveSelectedText); - public final void setOnSelectionDrop(IntConsumer consumer) { onSelectionDrop.setValue(consumer); } - public final IntConsumer getOnSelectionDrop() { return onSelectionDrop.getValue(); } - - private final ObjectProperty> paragraphGraphicFactory = new SimpleObjectProperty<>(null); - public void setParagraphGraphicFactory(IntFunction factory) { paragraphGraphicFactory.set(factory); } - public IntFunction getParagraphGraphicFactory() { return paragraphGraphicFactory.get(); } - public ObjectProperty> paragraphGraphicFactoryProperty() { return paragraphGraphicFactory; } - - /** - * Indicates whether the initial style should also be used for plain text - * inserted into this text area. When {@code false}, the style immediately - * preceding the insertion position is used. Default value is {@code false}. - */ - public BooleanProperty useInitialStyleForInsertionProperty() { return model.useInitialStyleForInsertionProperty(); } - public void setUseInitialStyleForInsertion(boolean value) { model.setUseInitialStyleForInsertion(value); } - public boolean getUseInitialStyleForInsertion() { return model.getUseInitialStyleForInsertion(); } - - private Optional, Codec>> styleCodecs = Optional.empty(); - /** - * Sets codecs to encode/decode style information to/from binary format. - * Providing codecs enables clipboard actions to retain the style information. - */ - public void setStyleCodecs(Codec paragraphStyleCodec, Codec textStyleCodec) { - styleCodecs = Optional.of(t(paragraphStyleCodec, textStyleCodec)); - } - @Override - public Optional, Codec>> getStyleCodecs() { - return styleCodecs; - } - - /** - * The estimated scrollX value. This can be set in order to scroll the content. - * Value is only accurate when area does not wrap lines and uses the same font size - * throughout the entire area. - */ - @Override - public Var estimatedScrollXProperty() { return virtualFlow.estimatedScrollXProperty(); } - public double getEstimatedScrollX() { return virtualFlow.estimatedScrollXProperty().getValue(); } - public void setEstimatedScrollX(double value) { virtualFlow.estimatedScrollXProperty().setValue(value); } - - /** - * The estimated scrollY value. This can be set in order to scroll the content. - * Value is only accurate when area does not wrap lines and uses the same font size - * throughout the entire area. - */ - @Override - public Var estimatedScrollYProperty() { return virtualFlow.estimatedScrollYProperty(); } - public double getEstimatedScrollY() { return virtualFlow.estimatedScrollYProperty().getValue(); } - public void setEstimatedScrollY(double value) { virtualFlow.estimatedScrollYProperty().setValue(value); } - - - /* ********************************************************************** * - * * - * Observables * - * * - * Observables are "dynamic" (i.e. changing) characteristics of this * - * control. They are not directly settable by the client code, but change * - * in response to user input and/or API actions. * - * * - * ********************************************************************** */ - - // text - @Override public final String getText() { return model.getText(); } - @Override public final ObservableValue textProperty() { return model.textProperty(); } - - // rich text - @Override public final StyledDocument getDocument() { return model.getDocument(); } - - // length - @Override public final int getLength() { return model.getLength(); } - @Override public final ObservableValue lengthProperty() { return model.lengthProperty(); } - - // caret position - @Override public final int getCaretPosition() { return model.getCaretPosition(); } - @Override public final ObservableValue caretPositionProperty() { return model.caretPositionProperty(); } - - // selection anchor - @Override public final int getAnchor() { return model.getAnchor(); } - @Override public final ObservableValue anchorProperty() { return model.anchorProperty(); } - - // selection - @Override public final IndexRange getSelection() { return model.getSelection(); } - @Override public final ObservableValue selectionProperty() { return model.selectionProperty(); } - - // selected text - @Override public final String getSelectedText() { return model.getSelectedText(); } - @Override public final ObservableValue selectedTextProperty() { return model.selectedTextProperty(); } - - // current paragraph index - @Override public final int getCurrentParagraph() { return model.getCurrentParagraph(); } - @Override public final ObservableValue currentParagraphProperty() { return model.currentParagraphProperty(); } - - // caret column - @Override public final int getCaretColumn() { return model.getCaretColumn(); } - @Override public final ObservableValue caretColumnProperty() { return model.caretColumnProperty(); } - - // paragraphs - @Override public LiveList> getParagraphs() { return model.getParagraphs(); } - - // beingUpdated - public ObservableBooleanValue beingUpdatedProperty() { return model.beingUpdatedProperty(); } - public boolean isBeingUpdated() { return model.isBeingUpdated(); } - - // total width estimate - /** - * The estimated width of the entire document. Accurate when area does not wrap lines and - * uses the same font size throughout the entire area. Value is only supposed to be set by - * the skin, not the user. - */ - @Override - public Val totalWidthEstimateProperty() { return virtualFlow.totalWidthEstimateProperty(); } - public double getTotalWidthEstimate() { return virtualFlow.totalWidthEstimateProperty().getValue(); } - - // total height estimate - /** - * The estimated height of the entire document. Accurate when area does not wrap lines and - * uses the same font size throughout the entire area. Value is only supposed to be set by - * the skin, not the user. - */ - @Override - public Val totalHeightEstimateProperty() { return virtualFlow.totalHeightEstimateProperty(); } - public double getTotalHeightEstimate() { return virtualFlow.totalHeightEstimateProperty().getValue(); } - - /* ********************************************************************** * - * * - * Event streams * - * * - * ********************************************************************** */ - - // text changes - @Override public final EventStream plainTextChanges() { return model.plainTextChanges(); } - - // rich text changes - @Override public final EventStream> richChanges() { return model.richChanges(); } - - /* ********************************************************************** * - * * - * Private fields * - * * - * ********************************************************************** */ - - private final StyledTextAreaBehavior behavior; - - private Subscription subscriptions = () -> {}; - - // Remembers horizontal position when traversing up / down. - private Optional targetCaretOffset = Optional.empty(); - - private final Binding caretVisible; - - private final Val> _popupAnchorAdjustment; - - private final VirtualFlow, Cell, ParagraphBox>> virtualFlow; - - // used for two-level navigation, where on the higher level are - // paragraphs and on the lower level are lines within a paragraph - private final TwoLevelNavigator navigator; - - private boolean followCaretRequested = false; - - /** - * model - */ - private final StyledTextAreaModel model; +public class StyledTextArea extends GenericStyledArea, S> { - /** - * @return this area's {@link StyledTextAreaModel} - */ - final StyledTextAreaModel getModel() { - return model; - } - - /* ********************************************************************** * - * * - * Fields necessary for Cloning * - * * - * ********************************************************************** */ - - /** - * The underlying document that can be displayed by multiple {@code StyledTextArea}s. - */ - public final EditableStyledDocument getContent() { return model.getContent(); } - - /** - * Style used by default when no other style is provided. - */ - public final S getInitialTextStyle() { return model.getInitialTextStyle(); } - - /** - * Style used by default when no other style is provided. - */ - public final PS getInitialParagraphStyle() { return model.getInitialParagraphStyle(); } - - /** - * Style applicator used by the default skin. - */ - private final BiConsumer applyStyle; - public final BiConsumer getApplyStyle() { return applyStyle; } - - /** - * Style applicator used by the default skin. - */ - private final BiConsumer applyParagraphStyle; - public final BiConsumer getApplyParagraphStyle() { return applyParagraphStyle; } - - /** - * Indicates whether style should be preserved on undo/redo, - * copy/paste and text move. - * TODO: Currently, only undo/redo respect this flag. - */ - public final boolean isPreserveStyle() { return model.isPreserveStyle(); } - - /* ********************************************************************** * - * * - * Constructors * - * * - * ********************************************************************** */ - - /** - * Creates a text area with empty text content. - * - * @param initialTextStyle style to use in places where no other style is - * specified (yet). - * @param applyStyle function that, given a {@link Text} node and - * a style, applies the style to the text node. This function is - * used by the default skin to apply style to text nodes. - * @param initialParagraphStyle style to use in places where no other style is - * specified (yet). - * @param applyParagraphStyle function that, given a {@link TextFlow} node and - * a style, applies the style to the paragraph node. This function is - * used by the default skin to apply style to paragraph nodes. - */ public StyledTextArea(PS initialParagraphStyle, BiConsumer applyParagraphStyle, - S initialTextStyle, BiConsumer applyStyle - ) { - this(initialParagraphStyle, applyParagraphStyle, initialTextStyle, applyStyle, true); - } - - public StyledTextArea(PS initialParagraphStyle, BiConsumer applyParagraphStyle, - S initialTextStyle, BiConsumer applyStyle, - boolean preserveStyle - ) { - this(initialParagraphStyle, applyParagraphStyle, initialTextStyle, applyStyle, - new SimpleEditableStyledDocument<>(initialParagraphStyle, initialTextStyle), preserveStyle); + S initialTextStyle, BiConsumer applyStyle, + EditableStyledDocument, S> document, boolean preserveStyle) { + super(initialParagraphStyle, applyParagraphStyle, + initialTextStyle, + document, StyledText.textOps(), preserveStyle, + seg -> createStyledTextNode(seg, StyledText.textOps(), applyStyle)); } - /** - * The same as {@link #StyledTextArea(Object, BiConsumer, Object, BiConsumer)} except that - * this constructor can be used to create another {@code StyledTextArea} object that - * shares the same {@link EditableStyledDocument}. - */ public StyledTextArea(PS initialParagraphStyle, BiConsumer applyParagraphStyle, S initialTextStyle, BiConsumer applyStyle, - EditableStyledDocument document - ) { - this(initialParagraphStyle, applyParagraphStyle, initialTextStyle, applyStyle, document, true); - + EditableStyledDocument, S> document) { + this(initialParagraphStyle, applyParagraphStyle, + initialTextStyle, applyStyle, document, true); } public StyledTextArea(PS initialParagraphStyle, BiConsumer applyParagraphStyle, S initialTextStyle, BiConsumer applyStyle, - EditableStyledDocument document, boolean preserveStyle - ) { - this.model = new StyledTextAreaModel<>(initialParagraphStyle, initialTextStyle, document, preserveStyle); - this.applyStyle = applyStyle; - this.applyParagraphStyle = applyParagraphStyle; - - // allow tab traversal into area - setFocusTraversable(true); - - this.setBackground(new Background(new BackgroundFill(Color.WHITE, CornerRadii.EMPTY, Insets.EMPTY))); - getStyleClass().add("styled-text-area"); - getStylesheets().add(StyledTextArea.class.getResource("styled-text-area.css").toExternalForm()); - - // keeps track of currently used non-empty cells - @SuppressWarnings("unchecked") - ObservableSet> nonEmptyCells = FXCollections.observableSet(); - - // Initialize content - virtualFlow = VirtualFlow.createVertical( - getParagraphs(), - par -> { - Cell, ParagraphBox> cell = createCell( - par, - applyStyle, - applyParagraphStyle); - nonEmptyCells.add(cell.getNode()); - return cell.beforeReset(() -> nonEmptyCells.remove(cell.getNode())) - .afterUpdateItem(p -> nonEmptyCells.add(cell.getNode())); - }); - getChildren().add(virtualFlow); - - // initialize navigator - IntSupplier cellCount = () -> getParagraphs().size(); - IntUnaryOperator cellLength = i -> virtualFlow.getCell(i).getNode().getLineCount(); - navigator = new TwoLevelNavigator(cellCount, cellLength); - - // relayout the popup when any of its settings values change (besides the caret being dirty) - EventStream popupAlignmentDirty = invalidationsOf(popupAlignmentProperty()); - EventStream popupAnchorAdjustmentDirty = invalidationsOf(popupAnchorAdjustmentProperty()); - EventStream popupAnchorOffsetDirty = invalidationsOf(popupAnchorOffsetProperty()); - EventStream popupDirty = merge(popupAlignmentDirty, popupAnchorAdjustmentDirty, popupAnchorOffsetDirty); - subscribeTo(popupDirty, x -> layoutPopup()); - - // follow the caret every time the caret position or paragraphs change - EventStream caretPosDirty = invalidationsOf(caretPositionProperty()); - EventStream paragraphsDirty = invalidationsOf(getParagraphs()); - EventStream selectionDirty = invalidationsOf(selectionProperty()); - // need to reposition popup even when caret hasn't moved, but selection has changed (been deselected) - EventStream caretDirty = merge(caretPosDirty, paragraphsDirty, selectionDirty); - - // whether or not to display the caret - EventStream blinkCaret = EventStreams.valuesOf(showCaretProperty()) - .flatMap(mode -> { - switch (mode) { - case ON: - return EventStreams.valuesOf(Val.constant(true)); - case OFF: - return EventStreams.valuesOf(Val.constant(false)); - default: - case AUTO: - return EventStreams.valuesOf(focusedProperty() - .and(editableProperty()) - .and(disabledProperty().not())); - } - }); - - // the rate at which to display the caret - EventStream blinkRate = EventStreams.valuesOf(caretBlinkRate); - - // The caret is visible in periodic intervals, - // but only when blinkCaret is true. - caretVisible = EventStreams.combine(blinkCaret, blinkRate) - .flatMap(tuple -> { - Boolean blink = tuple.get1(); - javafx.util.Duration rate = tuple.get2(); - if(blink) { - return rate.lessThanOrEqualTo(ZERO) - ? EventStreams.valuesOf(Val.constant(true)) - : booleanPulse(rate, caretDirty); - } else { - return EventStreams.valuesOf(Val.constant(false)); - } - }) - .toBinding(false); - manageBinding(caretVisible); - - // Adjust popup anchor by either a user-provided function, - // or user-provided offset, or don't adjust at all. - Val> userOffset = Val.map( - popupAnchorOffsetProperty(), - offset -> anchor -> anchor.add(offset)); - _popupAnchorAdjustment = - Val.orElse( - popupAnchorAdjustmentProperty(), - userOffset) - .orElseConst(UnaryOperator.identity()); - - // dispatch MouseOverTextEvents when mouseOverTextDelay is not null - EventStreams.valuesOf(mouseOverTextDelayProperty()) - .flatMap(delay -> delay != null - ? mouseOverTextEvents(nonEmptyCells, delay) - : EventStreams.never()) - .subscribe(evt -> Event.fireEvent(this, evt)); - - behavior = new StyledTextAreaBehavior(this); - } - - - /* ********************************************************************** * - * * - * Queries * - * * - * Queries are parameterized observables. * - * * - * ********************************************************************** */ - - /** - * Returns caret bounds relative to the viewport, i.e. the visual bounds - * of the embedded VirtualFlow. - */ - Optional getCaretBounds() { - return virtualFlow.getCellIfVisible(getCurrentParagraph()) - .map(c -> { - Bounds cellBounds = c.getNode().getCaretBounds(); - return virtualFlow.cellToViewport(c, cellBounds); - }); - } - - /** - * Returns x coordinate of the caret in the current paragraph. - */ - ParagraphBox.CaretOffsetX getCaretOffsetX() { - int idx = getCurrentParagraph(); - return getCell(idx).getCaretOffsetX(); - } - - double getViewportHeight() { - return virtualFlow.getHeight(); - } - - CharacterHit hit(ParagraphBox.CaretOffsetX x, TwoDimensional.Position targetLine) { - int parIdx = targetLine.getMajor(); - ParagraphBox cell = virtualFlow.getCell(parIdx).getNode(); - CharacterHit parHit = cell.hitTextLine(x, targetLine.getMinor()); - return parHit.offset(getParagraphOffset(parIdx)); - } - - CharacterHit hit(ParagraphBox.CaretOffsetX x, double y) { - VirtualFlowHit, ParagraphBox>> hit = virtualFlow.hit(0.0, y); - if(hit.isBeforeCells()) { - return CharacterHit.insertionAt(0); - } else if(hit.isAfterCells()) { - return CharacterHit.insertionAt(getLength()); - } else { - int parIdx = hit.getCellIndex(); - int parOffset = getParagraphOffset(parIdx); - ParagraphBox cell = hit.getCell().getNode(); - Point2D cellOffset = hit.getCellOffset(); - CharacterHit parHit = cell.hitText(x, cellOffset.getY()); - return parHit.offset(parOffset); - } - } - - /** - * Helpful for determining which letter is at point x, y: - *

-     *     {@code
-     *     StyledTextArea area = // creation code
-     *     area.addEventHandler(MouseEvent.MOUSE_PRESSED, (MouseEvent e) -> {
-     *         CharacterHit hit = area.hit(e.getX(), e.getY());
-     *         int characterPosition = hit.getInsertionIndex();
-     *
-     *         // move the caret to that character's position
-     *         area.moveTo(characterPosition, SelectionPolicy.CLEAR);
-     *     }}
-     * 
- */ - public CharacterHit hit(double x, double y) { - VirtualFlowHit, ParagraphBox>> hit = virtualFlow.hit(x, y); - if(hit.isBeforeCells()) { - return CharacterHit.insertionAt(0); - } else if(hit.isAfterCells()) { - return CharacterHit.insertionAt(getLength()); - } else { - int parIdx = hit.getCellIndex(); - int parOffset = getParagraphOffset(parIdx); - ParagraphBox cell = hit.getCell().getNode(); - Point2D cellOffset = hit.getCellOffset(); - CharacterHit parHit = cell.hit(cellOffset); - return parHit.offset(parOffset); - } - } - - /** - * Returns the current line as a two-level index. - * The major number is the paragraph index, the minor - * number is the line number within the paragraph. - * - *

This method has a side-effect of bringing the current - * paragraph to the viewport if it is not already visible. - */ - TwoDimensional.Position currentLine() { - int parIdx = getCurrentParagraph(); - Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx); - int lineIdx = cell.getNode().getCurrentLineIndex(); - return _position(parIdx, lineIdx); - } - - TwoDimensional.Position _position(int par, int line) { - return navigator.position(par, line); - } - - @Override - public final String getText(int start, int end) { - return model.getText(start, end); - } - - @Override - public String getText(int paragraph) { - return model.getText(paragraph); - } - - public Paragraph getParagraph(int index) { - return model.getParagraph(index); - } - - @Override - public StyledDocument subDocument(int start, int end) { - return model.subDocument(start, end); - } - - @Override - public StyledDocument subDocument(int paragraphIndex) { - return model.subDocument(paragraphIndex); - } - - /** - * Returns the selection range in the given paragraph. - */ - public IndexRange getParagraphSelection(int paragraph) { - return model.getParagraphSelection(paragraph); - } - - /** - * Returns the style of the character with the given index. - * If {@code index} points to a line terminator character, - * the last style used in the paragraph terminated by that - * line terminator is returned. - */ - public S getStyleOfChar(int index) { - return model.getStyleOfChar(index); + boolean preserveStyle) { + this( + initialParagraphStyle, + applyParagraphStyle, + initialTextStyle, + applyStyle, + new SimpleEditableStyledDocument<>(initialParagraphStyle, initialTextStyle), + preserveStyle); } - /** - * Returns the style at the given position. That is the style of the - * character immediately preceding {@code position}, except when - * {@code position} points to a paragraph boundary, in which case it - * is the style at the beginning of the latter paragraph. - * - *

In other words, most of the time {@code getStyleAtPosition(p)} - * is equivalent to {@code getStyleOfChar(p-1)}, except when {@code p} - * points to a paragraph boundary, in which case it is equivalent to - * {@code getStyleOfChar(p)}. - */ - public S getStyleAtPosition(int position) { - return model.getStyleAtPosition(position); - } - - /** - * 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. If {@code position} - * points to a boundary between two paragraphs, then the first styled range - * of the latter paragraph is returned. - */ - public IndexRange getStyleRangeAtPosition(int position) { - return model.getStyleRangeAtPosition(position); - } - - /** - * Returns the styles in the given character range. - */ - public StyleSpans getStyleSpans(int from, int to) { - return model.getStyleSpans(from, to); - } - - /** - * Returns the styles in the given character range. - */ - public StyleSpans getStyleSpans(IndexRange range) { - return getStyleSpans(range.getStart(), range.getEnd()); - } - - /** - * Returns the style of the character with the given index in the given - * paragraph. If {@code index} is beyond the end of the paragraph, the - * style at the end of line is returned. If {@code index} is negative, it - * is the same as if it was 0. - */ - public S getStyleOfChar(int paragraph, int index) { - return model.getStyleOfChar(paragraph, index); - } - - /** - * Returns the style at the given position in the given paragraph. - * This is equivalent to {@code getStyleOfChar(paragraph, position-1)}. - */ - public S getStyleAtPosition(int paragraph, int position) { - return model.getStyleAtPosition(paragraph, position); - } - - /** - * Returns the range of homogeneous style that includes the given position - * in the given paragraph. If {@code position} points to a boundary between - * two styled ranges, then the range preceding {@code position} is returned. - */ - public IndexRange getStyleRangeAtPosition(int paragraph, int position) { - return model.getStyleRangeAtPosition(paragraph, position); - } - - /** - * Returns styles of the whole paragraph. - */ - public StyleSpans getStyleSpans(int paragraph) { - return model.getStyleSpans(paragraph); - } - - /** - * Returns the styles in the given character range of the given paragraph. - */ - public StyleSpans getStyleSpans(int paragraph, int from, int to) { - return model.getStyleSpans(paragraph, from, to); - } - - /** - * Returns the styles in the given character range of the given paragraph. - */ - public StyleSpans getStyleSpans(int paragraph, IndexRange range) { - return getStyleSpans(paragraph, range.getStart(), range.getEnd()); - } - - @Override - public int getAbsolutePosition(int paragraphIndex, int columnIndex) { - return model.getAbsolutePosition(paragraphIndex, columnIndex); - } - - @Override - public Position position(int row, int col) { - return model.position(row, col); - } - - @Override - public Position offsetToPosition(int charOffset, Bias bias) { - return model.offsetToPosition(charOffset, bias); - } - - - /* ********************************************************************** * - * * - * Actions * - * * - * Actions change the state of this control. They typically cause a * - * change of one or more observables and/or produce an event. * - * * - * ********************************************************************** */ - - void scrollBy(Point2D deltas) { - virtualFlow.scrollXBy(deltas.getX()); - virtualFlow.scrollYBy(deltas.getY()); - } - - void show(double y) { - virtualFlow.show(y); - } - - void showCaretAtBottom() { - int parIdx = getCurrentParagraph(); - Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx); - Bounds caretBounds = cell.getNode().getCaretBounds(); - double y = caretBounds.getMaxY(); - virtualFlow.showAtOffset(parIdx, getViewportHeight() - y); - } - - void showCaretAtTop() { - int parIdx = getCurrentParagraph(); - Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx); - Bounds caretBounds = cell.getNode().getCaretBounds(); - double y = caretBounds.getMinY(); - virtualFlow.showAtOffset(parIdx, -y); - } - - /** - * If the caret is not visible within the area's view, the area will scroll so that caret - * is visible in the next layout pass. Use this method when you wish to "follow the caret" - * (i.e. auto-scroll to caret) after making a change (add/remove/modify area's segments). - */ - public void requestFollowCaret() { - followCaretRequested = true; - requestLayout(); - } - - private void followCaret() { - int parIdx = getCurrentParagraph(); - Cell, ParagraphBox> cell = virtualFlow.getCell(parIdx); - Bounds caretBounds = cell.getNode().getCaretBounds(); - double graphicWidth = cell.getNode().getGraphicPrefWidth(); - Bounds region = extendLeft(caretBounds, graphicWidth); - virtualFlow.show(parIdx, region); - } - - /** - * Moves caret to the previous page (i.e. page up) - * @param selectionPolicy use {@link SelectionPolicy#CLEAR} when no selection is desired and - * {@link SelectionPolicy#ADJUST} when a selection from starting point - * to the place to where the caret is moved is desired. - */ - public void prevPage(SelectionPolicy selectionPolicy) { - showCaretAtBottom(); - CharacterHit hit = hit(getTargetCaretOffset(), 1.0); - model.moveTo(hit.getInsertionIndex(), selectionPolicy); - } - - /** - * Moves caret to the next page (i.e. page down) - * @param selectionPolicy use {@link SelectionPolicy#CLEAR} when no selection is desired and - * {@link SelectionPolicy#ADJUST} when a selection from starting point - * to the place to where the caret is moved is desired. - */ - public void nextPage(SelectionPolicy selectionPolicy) { - showCaretAtTop(); - CharacterHit hit = hit(getTargetCaretOffset(), getViewportHeight() - 1.0); - model.moveTo(hit.getInsertionIndex(), selectionPolicy); - } - - /** - * Sets style for the given character range. - */ - public void setStyle(int from, int to, S style) { - model.setStyle(from, to, style); - } - - /** - * Sets style for the whole paragraph. - */ - public void setStyle(int paragraph, S style) { - model.setStyle(paragraph, style); - } - - /** - * Sets style for the given range relative in the given paragraph. - */ - public void setStyle(int paragraph, int from, int to, S style) { - model.setStyle(paragraph, from, to, style); - } - - /** - * Set multiple style ranges at once. This is equivalent to - *

-     * for(StyleSpan{@code } span: styleSpans) {
-     *     setStyle(from, from + span.getLength(), span.getStyle());
-     *     from += span.getLength();
-     * }
-     * 
- * but the actual implementation is more efficient. - */ - public void setStyleSpans(int from, StyleSpans styleSpans) { - model.setStyleSpans(from, styleSpans); - } - - /** - * Set multiple style ranges of a paragraph at once. This is equivalent to - *
-     * for(StyleSpan{@code } span: styleSpans) {
-     *     setStyle(paragraph, from, from + span.getLength(), span.getStyle());
-     *     from += span.getLength();
-     * }
-     * 
- * but the actual implementation is more efficient. - */ - public void setStyleSpans(int paragraph, int from, StyleSpans styleSpans) { - model.setStyleSpans(paragraph, from, styleSpans); - } - - /** - * Sets style for the whole paragraph. - */ - public void setParagraphStyle(int paragraph, PS paragraphStyle) { - model.setParagraphStyle(paragraph, paragraphStyle); - } - - /** - * Resets the style of the given range to the initial style. - */ - public void clearStyle(int from, int to) { - model.clearStyle(from, to); - } - - /** - * Resets the style of the given paragraph to the initial style. - */ - public void clearStyle(int paragraph) { - model.clearStyle(paragraph); - } - - /** - * Resets the style of the given range in the given paragraph - * to the initial style. - */ - public void clearStyle(int paragraph, int from, int to) { - model.clearStyle(paragraph, from, to); - } - - /** - * Resets the style of the given paragraph to the initial style. - */ - public void clearParagraphStyle(int paragraph) { - model.clearParagraphStyle(paragraph); - } - - @Override - public void replaceText(int start, int end, String text) { - model.replaceText(start, end, text); - } - - @Override - public void replace(int start, int end, StyledDocument replacement) { - model.replace(start, end, replacement); - } - - @Override - public void selectRange(int anchor, int caretPosition) { - model.selectRange(anchor, caretPosition); - } - - /** - * {@inheritDoc} - * @deprecated You probably meant to use {@link #moveTo(int)}. This method will be made - * package-private in the future - */ - @Deprecated - @Override - public void positionCaret(int pos) { - model.positionCaret(pos); - } - - /* ********************************************************************** * - * * - * Public API * - * * - * ********************************************************************** */ - - public void dispose() { - subscriptions.unsubscribe(); - model.dispose(); - virtualFlow.dispose(); - } - - /* ********************************************************************** * - * * - * Layout * - * * - * ********************************************************************** */ - - @Override - protected void layoutChildren() { - virtualFlow.resize(getWidth(), getHeight()); - if(followCaretRequested) { - followCaretRequested = false; - followCaret(); - } - - // position popup - layoutPopup(); - } - - /* ********************************************************************** * - * * - * Private methods * - * * - * ********************************************************************** */ - - private Cell, ParagraphBox> createCell( - Paragraph paragraph, - BiConsumer applyStyle, - BiConsumer applyParagraphStyle) { - - ParagraphBox box = new ParagraphBox<>(paragraph, applyParagraphStyle, applyStyle); - - box.highlightFillProperty().bind(highlightFill); - box.highlightTextFillProperty().bind(highlightTextFill); - box.wrapTextProperty().bind(wrapTextProperty()); - box.graphicFactoryProperty().bind(paragraphGraphicFactoryProperty()); - box.graphicOffset.bind(virtualFlow.breadthOffsetProperty()); - - Val hasCaret = Val.combine( - box.indexProperty(), - currentParagraphProperty(), - (bi, cp) -> bi.intValue() == cp.intValue()); - - Subscription hasCaretPseudoClass = hasCaret.values().subscribe(value -> box.pseudoClassStateChanged(HAS_CARET, value)); - Subscription firstParPseudoClass = box.indexProperty().values().subscribe(idx -> box.pseudoClassStateChanged(FIRST_PAR, idx == 0)); - Subscription lastParPseudoClass = EventStreams.combine( - box.indexProperty().values(), - getParagraphs().sizeProperty().values() - ).subscribe(in -> in.exec((i, n) -> box.pseudoClassStateChanged(LAST_PAR, i == n-1))); - - // caret is visible only in the paragraph with the caret - Val cellCaretVisible = hasCaret.flatMap(x -> x ? caretVisible : Val.constant(false)); - box.caretVisibleProperty().bind(cellCaretVisible); - - // bind cell's caret position to area's caret column, - // when the cell is the one with the caret - box.caretPositionProperty().bind(hasCaret.flatMap(has -> has - ? caretColumnProperty() - : Val.constant(0))); - - // keep paragraph selection updated - ObjectBinding cellSelection = Bindings.createObjectBinding(() -> { - int idx = box.getIndex(); - return idx != -1 - ? getParagraphSelection(idx) - : StyledTextArea.EMPTY_RANGE; - }, selectionProperty(), box.indexProperty()); - box.selectionProperty().bind(cellSelection); - - return new Cell, ParagraphBox>() { - @Override - public ParagraphBox getNode() { - return box; - } - - @Override - public void updateIndex(int index) { - box.setIndex(index); - } - - @Override - public void dispose() { - box.highlightFillProperty().unbind(); - box.highlightTextFillProperty().unbind(); - box.wrapTextProperty().unbind(); - box.graphicFactoryProperty().unbind(); - box.graphicOffset.unbind(); - - hasCaretPseudoClass.unsubscribe(); - firstParPseudoClass.unsubscribe(); - lastParPseudoClass.unsubscribe(); - - box.caretVisibleProperty().unbind(); - box.caretPositionProperty().unbind(); - - box.selectionProperty().unbind(); - cellSelection.dispose(); - } - }; - } - - private ParagraphBox getCell(int index) { - return virtualFlow.getCell(index).getNode(); - } - - private EventStream mouseOverTextEvents(ObservableSet> cells, Duration delay) { - return merge(cells, c -> c.stationaryIndices(delay).map(e -> e.unify( - l -> l.map((pos, charIdx) -> MouseOverTextEvent.beginAt(c.localToScreen(pos), getParagraphOffset(c.getIndex()) + charIdx)), - r -> MouseOverTextEvent.end()))); - } - - private int getParagraphOffset(int parIdx) { - return position(parIdx, 0).toOffset(); - } - - private void layoutPopup() { - PopupWindow popup = getPopupWindow(); - PopupAlignment alignment = getPopupAlignment(); - UnaryOperator adjustment = _popupAnchorAdjustment.getValue(); - if(popup != null) { - positionPopup(popup, alignment, adjustment); - } - } - - private void positionPopup( - PopupWindow popup, - PopupAlignment alignment, - UnaryOperator adjustment) { - Optional bounds = null; - switch(alignment.getAnchorObject()) { - case CARET: bounds = getCaretBoundsOnScreen(); break; - case SELECTION: bounds = getSelectionBoundsOnScreen(); break; - } - bounds.ifPresent(b -> { - double x = 0, y = 0; - switch(alignment.getHorizontalAlignment()) { - case LEFT: x = b.getMinX(); break; - case H_CENTER: x = (b.getMinX() + b.getMaxX()) / 2; break; - case RIGHT: x = b.getMaxX(); break; - } - switch(alignment.getVerticalAlignment()) { - case TOP: y = b.getMinY(); - case V_CENTER: y = (b.getMinY() + b.getMaxY()) / 2; break; - case BOTTOM: y = b.getMaxY(); break; - } - Point2D anchor = adjustment.apply(new Point2D(x, y)); - popup.setAnchorX(anchor.getX()); - popup.setAnchorY(anchor.getY()); - }); - } - - private Optional getCaretBoundsOnScreen() { - return virtualFlow.getCellIfVisible(getCurrentParagraph()) - .map(c -> c.getNode().getCaretBoundsOnScreen()); - } - - private Optional getSelectionBoundsOnScreen() { - IndexRange selection = getSelection(); - if(selection.getLength() == 0) { - return getCaretBoundsOnScreen(); - } - - Bounds[] bounds = virtualFlow.visibleCells().stream() - .map(c -> c.getNode().getSelectionBoundsOnScreen()) - .filter(Optional::isPresent) - .map(Optional::get) - .toArray(Bounds[]::new); - - if(bounds.length == 0) { - return Optional.empty(); - } - double minX = Stream.of(bounds).mapToDouble(Bounds::getMinX).min().getAsDouble(); - double maxX = Stream.of(bounds).mapToDouble(Bounds::getMaxX).max().getAsDouble(); - double minY = Stream.of(bounds).mapToDouble(Bounds::getMinY).min().getAsDouble(); - double maxY = Stream.of(bounds).mapToDouble(Bounds::getMaxY).max().getAsDouble(); - return Optional.of(new BoundingBox(minX, minY, maxX-minX, maxY-minY)); - } - - private void subscribeTo(EventStream src, Consumer cOnsumer) { - manageSubscription(src.subscribe(cOnsumer)); - } - - private void manageSubscription(Subscription subscription) { - subscriptions = subscriptions.and(subscription); - } - - private void manageBinding(Binding binding) { - subscriptions = subscriptions.and(binding::dispose); - } - - private static Bounds extendLeft(Bounds b, double w) { - if(w == 0) { - return b; - } else { - return new BoundingBox( - b.getMinX() - w, b.getMinY(), - b.getWidth() + w, b.getHeight()); - } + public StyledTextArea(PS initialParagraphStyle, BiConsumer applyParagraphStyle, + S initialTextStyle, BiConsumer applyStyle) { + this(initialParagraphStyle, applyParagraphStyle, + initialTextStyle, applyStyle, true); } - void clearTargetCaretOffset() { - targetCaretOffset = Optional.empty(); - } + public static Node createStyledTextNode(StyledText seg, SegmentOps, S> segOps, + BiConsumer applyStyle) { - ParagraphBox.CaretOffsetX getTargetCaretOffset() { - if(!targetCaretOffset.isPresent()) - targetCaretOffset = Optional.of(getCaretOffsetX()); - return targetCaretOffset.get(); - } + TextExt t = new TextExt(segOps.getText(seg)); + t.setTextOrigin(VPos.TOP); + t.getStyleClass().add("text"); + applyStyle.accept(t, segOps.getStyle(seg)); - private static EventStream booleanPulse(javafx.util.Duration javafxDuration, EventStream restartImpulse) { - Duration duration = Duration.ofMillis(Math.round(javafxDuration.toMillis())); - EventStream ticks = EventStreams.restartableTicks(duration, restartImpulse); - return StateMachine.init(false) - .on(restartImpulse.withDefaultEvent(null)).transition((state, impulse) -> true) - .on(ticks).transition((state, tick) -> !state) - .toStateStream(); + // XXX: binding selectionFill to textFill, + // see the note at highlightTextFill + t.impl_selectionFillProperty().bind(t.fillProperty()); + return t; } } diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/StyledTextAreaBehavior.java b/richtextfx/src/main/java/org/fxmisc/richtext/StyledTextAreaBehavior.java index 1144a6a92..e5a7ea2c9 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/StyledTextAreaBehavior.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/StyledTextAreaBehavior.java @@ -1,16 +1,13 @@ package org.fxmisc.richtext; -import static java.lang.Character.isWhitespace; +import static java.lang.Character.*; import static javafx.scene.input.KeyCode.*; import static javafx.scene.input.KeyCombination.*; import static org.fxmisc.richtext.model.TwoDimensional.Bias.*; import static org.fxmisc.wellbehaved.event.EventPattern.*; -import static org.fxmisc.wellbehaved.event.template.InputMapTemplate.consume; -import static org.fxmisc.wellbehaved.event.template.InputMapTemplate.sequence; -import static org.fxmisc.wellbehaved.event.template.InputMapTemplate.when; +import static org.fxmisc.wellbehaved.event.template.InputMapTemplate.*; import static org.reactfx.EventStreams.*; -import java.util.Optional; import java.util.function.Predicate; import javafx.event.Event; @@ -24,16 +21,14 @@ import org.fxmisc.richtext.model.StyledTextAreaModel; import org.fxmisc.richtext.model.NavigationActions.SelectionPolicy; import org.fxmisc.richtext.model.TwoDimensional.Position; -import org.fxmisc.richtext.ParagraphBox.CaretOffsetX; import org.fxmisc.wellbehaved.event.EventPattern; import org.fxmisc.wellbehaved.event.template.InputMapTemplate; import org.reactfx.EventStream; -import org.reactfx.Subscription; import org.reactfx.value.Val; import org.reactfx.value.Var; /** - * Controller for StyledTextArea. + * Controller for GenericStyledArea. */ class StyledTextAreaBehavior { @@ -209,9 +204,9 @@ private enum DragState { * Fields * * ********************************************************************** */ - private final StyledTextArea view; + private final GenericStyledArea view; - private final StyledTextAreaModel model; + private final StyledTextAreaModel model; /** * Indicates whether selection is being dragged by the user. @@ -224,7 +219,7 @@ private enum DragState { * Constructors * * ********************************************************************** */ - StyledTextAreaBehavior(StyledTextArea area) { + StyledTextAreaBehavior(GenericStyledArea area) { this.view = area; this.model = area.getModel(); diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/Codec.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/Codec.java index d9df0d249..48730f031 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/Codec.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/Codec.java @@ -9,6 +9,8 @@ import javafx.scene.paint.Color; +import org.reactfx.util.Either; + public interface Codec { String getName(); @@ -118,4 +120,33 @@ public E decode(DataInputStream is) throws IOException { }; } + + static Codec> eitherCodec(Codec lCodec, Codec rCodec) { + return new Codec>() { + + @Override + public String getName() { + return "either<" + lCodec.getName() + ", " + rCodec.getName() + ">"; + } + + @Override + public void encode(DataOutputStream os, Either e) throws IOException { + if(e.isLeft()) { + os.writeBoolean(false); + lCodec.encode(os, e.getLeft()); + } else { + os.writeBoolean(true); + rCodec.encode(os, e.getRight()); + } + } + + @Override + public Either decode(DataInputStream is) throws IOException { + boolean isRight = is.readBoolean(); + return isRight + ? Either.right(rCodec.decode(is)) + : Either.left (lCodec.decode(is)); + } + }; + } } \ No newline at end of file diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/EditActions.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/EditActions.java index 9162c5dd3..b00766e1a 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/EditActions.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/EditActions.java @@ -5,7 +5,7 @@ /** * Extended edit actions for {@link TextEditingArea}. */ -public interface EditActions extends TextEditingArea { +public interface EditActions extends TextEditingArea { /** * Appends the given text to the end of the text content. @@ -17,7 +17,7 @@ default void appendText(String text) { /** * Appends the given rich-text content to the end of this text-editing area. */ - default void append(StyledDocument document) { + default void append(StyledDocument document) { insert(getLength(), document); } @@ -51,7 +51,7 @@ default void insertText(int paragraphIndex, int columnIndex, String text) { * @param index The location to insert the text. * @param document The rich-text content to insert. */ - default void insert(int index, StyledDocument document) { + default void insert(int index, StyledDocument document) { replace(index, index, document); } @@ -64,7 +64,7 @@ default void insert(int index, StyledDocument document) { * * @param document The rich-text content to insert. */ - default void insert(int paragraphIndex, int columnIndex, StyledDocument document) { + default void insert(int paragraphIndex, int columnIndex, StyledDocument document) { int index = getAbsolutePosition(paragraphIndex, columnIndex); replace(index, index, document); } @@ -149,7 +149,7 @@ default void replaceText(String replacement) { /** * Replaces the entire content with the given rich-text content. */ - default void replace(StyledDocument replacement) { + default void replace(StyledDocument replacement) { replace(0, getLength(), replacement); } @@ -169,7 +169,7 @@ default void replaceSelection(String replacement) { * caret position. If there was a selection, then the selection is cleared * and the given replacement text is inserted. */ - default void replaceSelection(StyledDocument replacement) { + default void replaceSelection(StyledDocument replacement) { replace(getSelection(), replacement); } @@ -180,7 +180,7 @@ default void moveSelectedText(int pos) { // no move, just position the caret selectRange(pos, pos); } else { - StyledDocument text = this.subDocument(sel.getStart(), sel.getEnd()); + StyledDocument text = this.subDocument(sel.getStart(), sel.getEnd()); if(pos > sel.getEnd()) pos -= sel.getLength(); deleteText(sel); diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/EditableStyledDocument.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/EditableStyledDocument.java index 99512a299..f932e377e 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/EditableStyledDocument.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/EditableStyledDocument.java @@ -8,11 +8,11 @@ import org.reactfx.value.Val; /** - * Content model for {@link org.fxmisc.richtext.StyledTextArea}. Implements edit operations + * Content model for {@link org.fxmisc.richtext.GenericStyledArea}. Implements edit operations * on styled text, but not worrying about additional aspects such as * caret or selection, which are handled by {@link StyledTextAreaModel}. */ -public interface EditableStyledDocument extends StyledDocument { +public interface EditableStyledDocument extends StyledDocument { /* ********************************************************************** * * * @@ -30,12 +30,12 @@ public interface EditableStyledDocument extends StyledDocument { Val lengthProperty(); @Override - ObservableList> getParagraphs(); + ObservableList> getParagraphs(); /** * Read-only snapshot of the current state of this document. */ - ReadOnlyStyledDocument snapshot(); + ReadOnlyStyledDocument snapshot(); /* ********************************************************************** * * * @@ -50,7 +50,7 @@ default EventStream plainChanges() { .filter(pc -> !pc.removed.equals(pc.inserted)); } - EventStream> richChanges(); + EventStream> richChanges(); SuspendableNo beingUpdatedProperty(); boolean isBeingUpdated(); @@ -64,7 +64,7 @@ default EventStream plainChanges() { * * * ********************************************************************** */ - void replace(int start, int end, StyledDocument replacement); + void replace(int start, int end, StyledDocument replacement); void setStyle(int from, int to, S style); diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/GenericEditableStyledDocument.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/GenericEditableStyledDocument.java new file mode 100644 index 000000000..25eedda95 --- /dev/null +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/GenericEditableStyledDocument.java @@ -0,0 +1,17 @@ +package org.fxmisc.richtext.model; + +/** + * Provides a basic implementation of {@link EditableStyledDocument}. See {@link SimpleEditableStyledDocument} for + * a version that is specified for {@link StyledText}. + * @param + * @param + * @param + */ +public final class GenericEditableStyledDocument extends GenericEditableStyledDocumentBase + implements EditableStyledDocument { + + public GenericEditableStyledDocument(PS initialParagraphStyle, S initialStyle, TextOps segmentOps) { + super(initialParagraphStyle, initialStyle, segmentOps); + } + +} diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/GenericEditableStyledDocumentBase.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/GenericEditableStyledDocumentBase.java new file mode 100644 index 000000000..2260b8105 --- /dev/null +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/GenericEditableStyledDocumentBase.java @@ -0,0 +1,209 @@ +package org.fxmisc.richtext.model; + +import static org.fxmisc.richtext.model.TwoDimensional.Bias.*; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.reactfx.EventSource; +import org.reactfx.EventStream; +import org.reactfx.Subscription; +import org.reactfx.SuspendableNo; +import org.reactfx.collection.LiveList; +import org.reactfx.collection.LiveListBase; +import org.reactfx.collection.MaterializedListModification; +import org.reactfx.collection.QuasiListModification; +import org.reactfx.collection.UnmodifiableByDefaultLiveList; +import org.reactfx.util.BiIndex; +import org.reactfx.util.Lists; +import org.reactfx.value.Val; + +/** + * Provides an implementation of {@link EditableStyledDocument} + */ +class GenericEditableStyledDocumentBase implements EditableStyledDocument { + + private class ParagraphList + extends LiveListBase> + implements UnmodifiableByDefaultLiveList> { + + @Override + public Paragraph get(int index) { + return doc.getParagraph(index); + } + + @Override + public int size() { + return doc.getParagraphCount(); + } + + @Override + protected Subscription observeInputs() { + return parChanges.subscribe(mod -> { + mod = mod.trim(); + QuasiListModification> qmod = + QuasiListModification.create(mod.getFrom(), mod.getRemoved(), mod.getAddedSize()); + notifyObservers(qmod.asListChange()); + }); + } + } + + private ReadOnlyStyledDocument doc; + + private final EventSource> richChanges = new EventSource<>(); + @Override public EventStream> richChanges() { return richChanges; } + + private final Val text = Val.create(() -> doc.getText(), richChanges); + @Override public String getText() { return text.getValue(); } + @Override public Val textProperty() { return text; } + + + private final Val length = Val.create(() -> doc.length(), richChanges); + @Override public int getLength() { return length.getValue(); } + @Override public Val lengthProperty() { return length; } + @Override public int length() { return length.getValue(); } + + private final EventSource>> parChanges = + new EventSource<>(); + + private final LiveList> paragraphs = new ParagraphList(); + + @Override + public LiveList> getParagraphs() { + return paragraphs; + } + + @Override + public ReadOnlyStyledDocument snapshot() { + return doc; + } + + private final SuspendableNo beingUpdated = new SuspendableNo(); + @Override public final SuspendableNo beingUpdatedProperty() { return beingUpdated; } + @Override public final boolean isBeingUpdated() { return beingUpdated.get(); } + + GenericEditableStyledDocumentBase(Paragraph initialParagraph/*, SegmentOps segmentOps*/) { + this.doc = new ReadOnlyStyledDocument<>(Collections.singletonList(initialParagraph)); + } + + /** + * Creates an empty {@link EditableStyledDocument} + */ + public GenericEditableStyledDocumentBase(PS initialParagraphStyle, S initialStyle, TextOps segmentOps) { + this(new Paragraph<>(initialParagraphStyle, segmentOps, segmentOps.create("", initialStyle))); + } + + + @Override + public Position position(int major, int minor) { + return doc.position(major, minor); + } + + @Override + public Position offsetToPosition(int offset, Bias bias) { + return doc.offsetToPosition(offset, bias); + } + + @Override + public void replace(int start, int end, StyledDocument replacement) { + ensureValidRange(start, end); + doc.replace(start, end, ReadOnlyStyledDocument.from(replacement)).exec(this::update); + } + + @Override + public void setStyle(int from, int to, S style) { + ensureValidRange(from, to); + doc.replace(from, to, removed -> removed.mapParagraphs(par -> par.restyle(style))).exec(this::update); + } + + @Override + public void setStyle(int paragraph, S style) { + ensureValidParagraphIndex(paragraph); + doc.replaceParagraph(paragraph, p -> p.restyle(style)).exec(this::update); + } + + @Override + public void setStyle(int paragraph, int fromCol, int toCol, S style) { + ensureValidParagraphRange(paragraph, fromCol, toCol); + doc.replace( + new BiIndex(paragraph, fromCol), + new BiIndex(paragraph, toCol), + d -> d.mapParagraphs(p -> p.restyle(style)) + ).exec(this::update); + } + + @Override + public void setStyleSpans(int from, StyleSpans styleSpans) { + int len = styleSpans.length(); + ensureValidRange(from, from + len); + doc.replace(from, from + len, d -> { + Position i = styleSpans.position(0, 0); + List> pars = new ArrayList<>(d.getParagraphs().size()); + for(Paragraph p: d.getParagraphs()) { + Position j = i.offsetBy(p.length(), Backward); + StyleSpans spans = styleSpans.subView(i, j); + pars.add(p.restyle(0, spans)); + i = j.offsetBy(1, Forward); // skip the newline + } + return new ReadOnlyStyledDocument<>(pars); + }).exec(this::update); + } + + @Override + public void setStyleSpans(int paragraph, int from, StyleSpans styleSpans) { + setStyleSpans(doc.position(paragraph, from).toOffset(), styleSpans); + } + + @Override + public void setParagraphStyle(int parIdx, PS style) { + ensureValidParagraphIndex(parIdx); + doc.replaceParagraph(parIdx, p -> p.setParagraphStyle(style)).exec(this::update); + } + + @Override + public StyledDocument concat(StyledDocument that) { + return doc.concat(that); + } + + @Override + public StyledDocument subSequence(int start, int end) { + return doc.subSequence(start, end); + } + + + /* ********************************************************************** * + * * + * Private and package private methods * + * * + * ********************************************************************** */ + + private void ensureValidParagraphIndex(int parIdx) { + Lists.checkIndex(parIdx, doc.getParagraphCount()); + } + + private void ensureValidRange(int start, int end) { + Lists.checkRange(start, end, length()); + } + + private void ensureValidParagraphRange(int par, int start, int end) { + ensureValidParagraphIndex(par); + Lists.checkRange(start, end, fullLength(par)); + } + + private int fullLength(int par) { + int n = doc.getParagraphCount(); + return doc.getParagraph(par).length() + (par == n-1 ? 0 : 1); + } + + private void update( + ReadOnlyStyledDocument newValue, + RichTextChange change, + MaterializedListModification> parChange) { + this.doc = newValue; + beingUpdated.suspendWhile(() -> { + richChanges.push(change); + parChanges.push(parChange); + }); + } +} diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/NavigationActions.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/NavigationActions.java index 8ce05cee9..558899c2f 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/NavigationActions.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/NavigationActions.java @@ -7,7 +7,7 @@ /** * Navigation actions for {@link TextEditingArea}. */ -public interface NavigationActions extends TextEditingArea { +public interface NavigationActions extends TextEditingArea { /** * Indicates how to treat selection when caret is moved. diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/Paragraph.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/Paragraph.java index 093c7447f..ad985bb16 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/Paragraph.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/Paragraph.java @@ -4,14 +4,16 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.Iterator; import java.util.List; import java.util.Objects; +import java.util.Optional; import javafx.scene.control.IndexRange; import org.fxmisc.richtext.model.TwoDimensional.Position; -public final class Paragraph { +public final class Paragraph { @SafeVarargs private static List list(T head, T... tail) { @@ -25,28 +27,28 @@ private static List list(T head, T... tail) { } } - private final List> segments; + private final List segments; private final TwoLevelNavigator navigator; private final PS paragraphStyle; - public Paragraph(PS paragraphStyle, String text, S style) { - this(paragraphStyle, new StyledText<>(text, style)); - } + private final SegmentOps segmentOps; @SafeVarargs - public Paragraph(PS paragraphStyle, StyledText text, StyledText... texts) { - this(paragraphStyle, list(text, texts)); + public Paragraph(PS paragraphStyle, SegmentOps segmentOps, SEG text, SEG... texts) { + this(paragraphStyle, segmentOps, list(text, texts)); } - Paragraph(PS paragraphStyle, List> segments) { + Paragraph(PS paragraphStyle, SegmentOps segmentOps, List segments) { assert !segments.isEmpty(); + + this.segmentOps = segmentOps; this.segments = segments; this.paragraphStyle = paragraphStyle; navigator = new TwoLevelNavigator(segments::size, - i -> segments.get(i).length()); + i -> segmentOps.length(segments.get(i))); } - public List> getSegments() { + public List getSegments() { return Collections.unmodifiableList(segments); } @@ -57,14 +59,14 @@ public PS getParagraphStyle() { private int length = -1; public int length() { if(length == -1) { - length = segments.stream().mapToInt(StyledText::length).sum(); + length = segments.stream().mapToInt(segmentOps::length).sum(); } return length; } public char charAt(int index) { Position pos = navigator.offsetToPosition(index, Forward); - return segments.get(pos.getMajor()).charAt(pos.getMinor()); + return segmentOps.charAt(segments.get(pos.getMajor()), pos.getMinor()); } public String substring(int from, int to) { @@ -81,7 +83,7 @@ public String substring(int from) { * 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) { + public Paragraph concat(Paragraph p) { if(p.length() == 0) { return this; } @@ -90,20 +92,21 @@ public Paragraph concat(Paragraph p) { return p; } - StyledText left = segments.get(segments.size() - 1); - StyledText right = p.segments.get(0); - if(Objects.equals(left.getStyle(), right.getStyle())) { - StyledText segment = left.append(right.getText()); - List> segs = new ArrayList<>(segments.size() + p.segments.size() - 1); + SEG left = segments.get(segments.size() - 1); + SEG right = p.segments.get(0); + Optional joined = segmentOps.join(left, right); + if(joined.isPresent()) { + SEG segment = joined.get(); + List segs = new ArrayList<>(segments.size() + p.segments.size() - 1); segs.addAll(segments.subList(0, segments.size()-1)); segs.add(segment); segs.addAll(p.segments.subList(1, p.segments.size())); - return new Paragraph<>(paragraphStyle, segs); + return new Paragraph<>(paragraphStyle, segmentOps, segs); } else { - List> segs = new ArrayList<>(segments.size() + p.segments.size()); + List segs = new ArrayList<>(segments.size() + p.segments.size()); segs.addAll(segments); segs.addAll(p.segments); - return new Paragraph<>(paragraphStyle, segs); + return new Paragraph<>(paragraphStyle, segmentOps, segs); } } @@ -111,56 +114,33 @@ public Paragraph concat(Paragraph p) { * 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) { + Paragraph concatR(Paragraph that) { return this.length() == 0 && that.length() == 0 ? that : concat(that); } - public Paragraph append(String str) { - if(str.length() == 0) { - return this; - } - - List> segs = new ArrayList<>(segments); - int lastIdx = segments.size() - 1; - segs.set(lastIdx, segments.get(lastIdx).append(str)); - return new Paragraph<>(paragraphStyle, segs); - } - - public Paragraph insert(int offset, CharSequence str) { - if(offset < 0 || offset > length()) { - throw new IndexOutOfBoundsException(String.valueOf(offset)); - } - - Position pos = navigator.offsetToPosition(offset, Backward); - int segIdx = pos.getMajor(); - int segPos = pos.getMinor(); - StyledText seg = segments.get(segIdx); - StyledText replacement = seg.spliced(segPos, segPos, str); - List> segs = new ArrayList<>(segments); - segs.set(segIdx, replacement); - return new Paragraph<>(paragraphStyle, segs); - } - - public Paragraph subSequence(int start, int end) { + public Paragraph subSequence(int start, int end) { return trim(end).subSequence(start); } - public Paragraph trim(int length) { + 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); + List segs = new ArrayList<>(segIdx + 1); segs.addAll(segments.subList(0, segIdx)); - segs.add(segments.get(segIdx).subSequence(0, pos.getMinor())); - return new Paragraph<>(paragraphStyle, segs); + segs.add(segmentOps.subSequence(segments.get(segIdx), 0, pos.getMinor())); + if (segs.isEmpty()) { + segs.add(segmentOps.createEmpty()); + } + return new Paragraph<>(paragraphStyle, segmentOps, segs); } } - public Paragraph subSequence(int start) { + public Paragraph subSequence(int start) { if(start < 0) { throw new IllegalArgumentException("start must not be negative (was: " + start + ")"); } else if(start == 0) { @@ -168,60 +148,76 @@ public Paragraph subSequence(int start) { } else if(start <= length()) { Position pos = navigator.offsetToPosition(start, Forward); int segIdx = pos.getMajor(); - List> segs = new ArrayList<>(segments.size() - segIdx); - segs.add(segments.get(segIdx).subSequence(pos.getMinor())); + List segs = new ArrayList<>(segments.size() - segIdx); + segs.add(segmentOps.subSequence(segments.get(segIdx), pos.getMinor())); segs.addAll(segments.subList(segIdx + 1, segments.size())); - return new Paragraph<>(paragraphStyle, segs); + if (segs.isEmpty()) { + segs.add(segmentOps.createEmpty()); + } + return new Paragraph<>(paragraphStyle, segmentOps, segs); } else { throw new IndexOutOfBoundsException(start + " not in [0, " + length() + "]"); } } - public Paragraph delete(int start, int end) { + public Paragraph delete(int start, int end) { return trim(start).concat(subSequence(end)); } - public Paragraph restyle(S style) { - return new Paragraph<>(paragraphStyle, getText(), style); + public Paragraph restyle(S style) { + List segs = new ArrayList<>(); + Iterator it = segments.iterator(); + segs.add(segmentOps.setStyle(it.next(), style)); + while (it.hasNext()) { + SEG prev = segs.get(segs.size() - 1); + SEG cur = segmentOps.setStyle(it.next(), style); + Optional joined = segmentOps.join(prev, cur); + if(joined.isPresent()) { + segs.set(segs.size() - 1, joined.get()); + } else { + segs.add(cur); + } + } + return new Paragraph<>(paragraphStyle, segmentOps, segs); } - public Paragraph restyle(int from, int to, S style) { + public Paragraph restyle(int from, int to, S style) { if(from >= length()) { return this; } else { to = Math.min(to, length()); - Paragraph left = subSequence(0, from); - Paragraph middle = new Paragraph<>(paragraphStyle, substring(from, to), style); - Paragraph right = subSequence(to); + Paragraph left = subSequence(0, from); + Paragraph middle = subSequence(from, to).restyle(style); + Paragraph right = subSequence(to); return left.concat(middle).concat(right); } } - public Paragraph restyle(int from, StyleSpans styleSpans) { + public Paragraph restyle(int from, StyleSpans styleSpans) { int len = styleSpans.length(); if(styleSpans.equals(getStyleSpans(from, from + len))) { return this; } - Paragraph left = trim(from); - Paragraph right = subSequence(from + len); + Paragraph left = trim(from); + Paragraph right = subSequence(from + len); - String middleString = substring(from, from + len); - List> middleSegs = new ArrayList<>(styleSpans.getSpanCount()); + Paragraph middle = subSequence(from, from + len); + List middleSegs = new ArrayList<>(styleSpans.getSpanCount()); int offset = 0; for(StyleSpan span: styleSpans) { int end = offset + span.getLength(); - String text = middleString.substring(offset, end); - middleSegs.add(new StyledText<>(text, span.getStyle())); + Paragraph text = middle.subSequence(offset, end); + middleSegs.addAll(text.restyle(span.getStyle()).segments); offset = end; } - Paragraph middle = new Paragraph<>(paragraphStyle, middleSegs); + Paragraph newMiddle = new Paragraph<>(paragraphStyle, segmentOps, middleSegs); - return left.concat(middle).concat(right); + return left.concat(newMiddle).concat(right); } - public Paragraph setParagraphStyle(PS paragraphStyle) { - return new Paragraph<>(paragraphStyle, segments); + public Paragraph setParagraphStyle(PS paragraphStyle) { + return new Paragraph<>(paragraphStyle, segmentOps, segments); } /** @@ -231,11 +227,11 @@ public Paragraph setParagraphStyle(PS paragraphStyle) { */ public S getStyleOfChar(int charIdx) { if(charIdx < 0) { - return segments.get(0).getStyle(); + return segmentOps.getStyle(segments.get(0)); } Position pos = navigator.offsetToPosition(charIdx, Forward); - return segments.get(pos.getMajor()).getStyle(); + return segmentOps.getStyle(segments.get(pos.getMajor())); } /** @@ -256,7 +252,7 @@ public S getStyleAtPosition(int position) { } Position pos = navigator.offsetToPosition(position, Backward); - return segments.get(pos.getMajor()).getStyle(); + return segmentOps.getStyle(segments.get(pos.getMajor())); } /** @@ -267,14 +263,15 @@ public S getStyleAtPosition(int position) { public IndexRange getStyleRangeAtPosition(int position) { Position pos = navigator.offsetToPosition(position, Backward); int start = position - pos.getMinor(); - int end = start + segments.get(pos.getMajor()).length(); + int end = start + segmentOps.length(segments.get(pos.getMajor())); return new IndexRange(start, end); } public StyleSpans getStyleSpans() { StyleSpansBuilder builder = new StyleSpansBuilder<>(segments.size()); - for(StyledText seg: segments) { - builder.add(seg.getStyle(), seg.length()); + for(SEG seg: segments) { + builder.add(segmentOps.getStyle(seg), + segmentOps.length(seg)); } return builder.create(); } @@ -291,19 +288,20 @@ public StyleSpans getStyleSpans(int from, int to) { StyleSpansBuilder builder = new StyleSpansBuilder<>(n); if(startSegIdx == endSegIdx) { - StyledText seg = segments.get(startSegIdx); - builder.add(seg.getStyle(), to - from); + SEG seg = segments.get(startSegIdx); + builder.add(segmentOps.getStyle(seg), to - from); } else { - StyledText startSeg = segments.get(startSegIdx); - builder.add(startSeg.getStyle(), startSeg.length() - start.getMinor()); + SEG startSeg = segments.get(startSegIdx); + builder.add(segmentOps.getStyle(startSeg), segmentOps.length(startSeg) - start.getMinor()); for(int i = startSegIdx + 1; i < endSegIdx; ++i) { - StyledText seg = segments.get(i); - builder.add(seg.getStyle(), seg.length()); + SEG seg = segments.get(i); + builder.add(segmentOps.getStyle(seg), + segmentOps.length(seg)); } - StyledText endSeg = segments.get(endSegIdx); - builder.add(endSeg.getStyle(), end.getMinor()); + SEG endSeg = segments.get(endSegIdx); + builder.add(segmentOps.getStyle(endSeg), end.getMinor()); } return builder.create(); @@ -317,8 +315,8 @@ public StyleSpans getStyleSpans(int from, int to) { public String getText() { if(text == null) { StringBuilder sb = new StringBuilder(length()); - for(StyledText seg: segments) - sb.append(seg.getText()); + for(SEG seg: segments) + sb.append(segmentOps.getText(seg)); text = sb.toString(); } return text; @@ -328,7 +326,7 @@ public String getText() { public String toString() { return "Par[" + paragraphStyle + "; " + - segments.stream().map(StyledText::toString) + segments.stream().map(Object::toString) .reduce((s1, s2) -> s1 + "," + s2).orElse("") + "]"; } @@ -336,7 +334,7 @@ public String toString() { @Override public boolean equals(Object other) { if(other instanceof Paragraph) { - Paragraph that = (Paragraph) other; + Paragraph that = (Paragraph) other; return Objects.equals(this.paragraphStyle, that.paragraphStyle) && Objects.equals(this.segments, that.segments); } else { diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/ReadOnlyStyledDocument.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/ReadOnlyStyledDocument.java index 1093cec5c..6eec29924 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/ReadOnlyStyledDocument.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/ReadOnlyStyledDocument.java @@ -7,6 +7,7 @@ import java.io.DataOutputStream; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.function.BiFunction; @@ -24,7 +25,7 @@ import org.reactfx.util.Tuple2; import org.reactfx.util.Tuple3; -public final class ReadOnlyStyledDocument implements StyledDocument { +public final class ReadOnlyStyledDocument implements StyledDocument { private static class Summary { private final int paragraphCount; @@ -43,11 +44,11 @@ public int length() { } } - private static ToSemigroup, Summary> summaryProvider() { - return new ToSemigroup, Summary>() { + private static ToSemigroup, Summary> summaryProvider() { + return new ToSemigroup, Summary>() { @Override - public Summary apply(Paragraph p) { + public Summary apply(Paragraph p) { return new Summary(1, p.length()); } @@ -66,115 +67,98 @@ public Summary reduce(Summary left, Summary right) { private static final BiFunction> NAVIGATE = (s, i) -> i <= s.length() ? left(i) : right(i - (s.length() + 1)); - public static ReadOnlyStyledDocument fromString(String str, PS paragraphStyle, S style) { + public static ReadOnlyStyledDocument fromString(String str, PS paragraphStyle, S style, TextOps segmentOps) { Matcher m = LINE_TERMINATOR.matcher(str); int n = 1; while(m.find()) ++n; - List> res = new ArrayList<>(n); + List> res = new ArrayList<>(n); int start = 0; m.reset(); while(m.find()) { String s = str.substring(start, m.start()); - res.add(new Paragraph<>(paragraphStyle, s, style)); + res.add(new Paragraph<>(paragraphStyle, segmentOps, segmentOps.create(s, style))); start = m.end(); } String last = str.substring(start); - res.add(new Paragraph<>(paragraphStyle, last, style)); + res.add(new Paragraph<>(paragraphStyle, segmentOps, segmentOps.create(last, style))); return new ReadOnlyStyledDocument<>(res); } - public static ReadOnlyStyledDocument from(StyledDocument doc) { + public static ReadOnlyStyledDocument fromSegment(SEG segment, PS paragraphStyle, S style, SegmentOps segmentOps) { + Paragraph content = new Paragraph(paragraphStyle, segmentOps, Arrays.asList(segment)); + List> res = Arrays.asList(content); + return new ReadOnlyStyledDocument<>(res); + } + + public static ReadOnlyStyledDocument from(StyledDocument doc) { if(doc instanceof ReadOnlyStyledDocument) { - return (ReadOnlyStyledDocument) doc; + return (ReadOnlyStyledDocument) doc; } else { return new ReadOnlyStyledDocument<>(doc.getParagraphs()); } } - public static Codec> codec(Codec pCodec, Codec tCodec) { - return new Codec>() { - private final Codec>> codec = Codec.listCodec(paragraphCodec(pCodec, tCodec)); + + public static Codec> codec(Codec pCodec, Codec segCodec, SegmentOps segmentOps) { + return new Codec>() { + private final Codec>> codec = Codec.listCodec(paragraphCodec(pCodec, segCodec, segmentOps)); @Override public String getName() { - return "application/richtextfx-styled-document<" + tCodec.getName() + ";" + pCodec.getName() + ">"; + return "application/richtextfx-styled-document<" + pCodec.getName() + ";" + segCodec.getName() + ">"; } @Override - public void encode(DataOutputStream os, StyledDocument doc) throws IOException { + public void encode(DataOutputStream os, StyledDocument doc) throws IOException { codec.encode(os, doc.getParagraphs()); } @Override - public StyledDocument decode(DataInputStream is) throws IOException { + public StyledDocument decode(DataInputStream is) throws IOException { return new ReadOnlyStyledDocument<>(codec.decode(is)); } }; } - private static Codec> paragraphCodec(Codec pCodec, Codec tCodec) { - return new Codec>() { - private final Codec>> segmentsCodec = Codec.listCodec(styledTextCodec(tCodec)); + private static Codec> paragraphCodec(Codec pCodec, Codec segCodec, SegmentOps segmentOps) { + return new Codec>() { + private final Codec> segmentsCodec = Codec.listCodec(segCodec); @Override public String getName() { - return "paragraph<" + tCodec.getName() + ";" + pCodec.getName() + ">"; + return "paragraph<" + pCodec.getName() + ";" + segCodec.getName() + ">"; } @Override - public void encode(DataOutputStream os, Paragraph p) throws IOException { + public void encode(DataOutputStream os, Paragraph p) throws IOException { pCodec.encode(os, p.getParagraphStyle()); segmentsCodec.encode(os, p.getSegments()); } @Override - public Paragraph decode(DataInputStream is) throws IOException { + public Paragraph decode(DataInputStream is) throws IOException { PS paragraphStyle = pCodec.decode(is); - List> segments = segmentsCodec.decode(is); - return new Paragraph<>(paragraphStyle, segments); - } - }; - } - - private static Codec> styledTextCodec(Codec styleCodec) { - return new Codec>() { - - @Override - public String getName() { - return "styledtext<" + styleCodec.getName() + ">"; - } - - @Override - public void encode(DataOutputStream os, StyledText t) throws IOException { - STRING_CODEC.encode(os, t.getText()); - styleCodec.encode(os, t.getStyle()); + List segments = segmentsCodec.decode(is); + return new Paragraph<>(paragraphStyle, segmentOps, segments); } - - @Override - public StyledText decode(DataInputStream is) throws IOException { - String text = STRING_CODEC.decode(is); - S style = styleCodec.decode(is); - return new StyledText<>(text, style); - } - }; } - private final NonEmptyFingerTree, Summary> tree; + private final NonEmptyFingerTree, Summary> tree; private String text = null; - private List> paragraphs = null; + private List> paragraphs = null; - private ReadOnlyStyledDocument(NonEmptyFingerTree, Summary> tree) { + private ReadOnlyStyledDocument(NonEmptyFingerTree, Summary> tree) { this.tree = tree; } - ReadOnlyStyledDocument(List> paragraphs) { + ReadOnlyStyledDocument(List> paragraphs) { this.tree = FingerTree.mkTree(paragraphs, summaryProvider()).caseEmpty().unify( emptyTree -> { throw new AssertionError("Unreachable code"); }, @@ -201,12 +185,12 @@ public int getParagraphCount() { return tree.getLeafCount(); } - public Paragraph getParagraph(int index) { + public Paragraph getParagraph(int index) { return tree.getLeaf(index); } @Override - public List> getParagraphs() { + public List> getParagraphs() { if(paragraphs == null) { paragraphs = tree.asList(); } @@ -223,90 +207,90 @@ public Position offsetToPosition(int offset, Bias bias) { return position(0, 0).offsetBy(offset, bias); } - public Tuple2, ReadOnlyStyledDocument> split(int position) { + public Tuple2, ReadOnlyStyledDocument> split(int position) { return tree.locate(NAVIGATE, position).map(this::split); } - public Tuple2, ReadOnlyStyledDocument> split( + public Tuple2, ReadOnlyStyledDocument> split( int row, int col) { return tree.splitAt(row).map((l, p, r) -> { - Paragraph p1 = p.trim(col); - Paragraph p2 = p.subSequence(col); - ReadOnlyStyledDocument doc1 = new ReadOnlyStyledDocument<>(l.append(p1)); - ReadOnlyStyledDocument doc2 = new ReadOnlyStyledDocument<>(r.prepend(p2)); + Paragraph p1 = p.trim(col); + Paragraph p2 = p.subSequence(col); + ReadOnlyStyledDocument doc1 = new ReadOnlyStyledDocument<>(l.append(p1)); + ReadOnlyStyledDocument doc2 = new ReadOnlyStyledDocument<>(r.prepend(p2)); return t(doc1, doc2); }); } @Override - public ReadOnlyStyledDocument concat(StyledDocument other) { + public ReadOnlyStyledDocument concat(StyledDocument other) { return concat0(other, Paragraph::concat); } - private ReadOnlyStyledDocument concatR(StyledDocument other) { + private ReadOnlyStyledDocument concatR(StyledDocument other) { return concat0(other, Paragraph::concatR); } - private ReadOnlyStyledDocument concat0(StyledDocument other, BinaryOperator> parConcat) { + private ReadOnlyStyledDocument concat0(StyledDocument other, BinaryOperator> parConcat) { int n = tree.getLeafCount() - 1; - Paragraph p0 = tree.getLeaf(n); - Paragraph p1 = other.getParagraphs().get(0); - Paragraph p = parConcat.apply(p0, p1); - NonEmptyFingerTree, Summary> tree1 = tree.updateLeaf(n, p); - FingerTree, Summary> tree2 = (other instanceof ReadOnlyStyledDocument) - ? ((ReadOnlyStyledDocument) other).tree.split(1)._2 + Paragraph p0 = tree.getLeaf(n); + Paragraph p1 = other.getParagraphs().get(0); + Paragraph p = parConcat.apply(p0, p1); + NonEmptyFingerTree, Summary> tree1 = tree.updateLeaf(n, p); + FingerTree, Summary> tree2 = (other instanceof ReadOnlyStyledDocument) + ? ((ReadOnlyStyledDocument) other).tree.split(1)._2 : FingerTree.mkTree(other.getParagraphs().subList(1, other.getParagraphs().size()), summaryProvider()); return new ReadOnlyStyledDocument<>(tree1.join(tree2)); } @Override - public StyledDocument subSequence(int start, int end) { + public StyledDocument subSequence(int start, int end) { return split(end)._1.split(start)._2; } - public Tuple3, RichTextChange, MaterializedListModification>> replace( - int from, int to, ReadOnlyStyledDocument replacement) { + public Tuple3, RichTextChange, MaterializedListModification>> replace( + int from, int to, ReadOnlyStyledDocument replacement) { return replace(from, to, x -> replacement); } - Tuple3, RichTextChange, MaterializedListModification>> replace( - int from, int to, UnaryOperator> f) { + Tuple3, RichTextChange, MaterializedListModification>> replace( + int from, int to, UnaryOperator> f) { BiIndex start = tree.locate(NAVIGATE, from); BiIndex end = tree.locate(NAVIGATE, to); return replace(start, end, f); } - Tuple3, RichTextChange, MaterializedListModification>> replace( - BiIndex start, BiIndex end, UnaryOperator> f) { + Tuple3, RichTextChange, MaterializedListModification>> replace( + BiIndex start, BiIndex end, UnaryOperator> f) { int pos = tree.getSummaryBetween(0, start.major).map(s -> s.length() + 1).orElse(0) + start.minor; - List> removedPars = + List> removedPars = getParagraphs().subList(start.major, end.major + 1); return end.map(this::split).map((l0, r) -> { return start.map(l0::split).map((l, removed) -> { - ReadOnlyStyledDocument replacement = f.apply(removed); - ReadOnlyStyledDocument doc = l.concatR(replacement).concat(r); - RichTextChange change = new RichTextChange<>(pos, removed, replacement); - List> addedPars = doc.getParagraphs().subList(start.major, start.major + replacement.getParagraphCount()); - MaterializedListModification> parChange = + ReadOnlyStyledDocument replacement = f.apply(removed); + ReadOnlyStyledDocument doc = l.concatR(replacement).concat(r); + RichTextChange change = new RichTextChange<>(pos, removed, replacement); + List> addedPars = doc.getParagraphs().subList(start.major, start.major + replacement.getParagraphCount()); + MaterializedListModification> parChange = MaterializedListModification.create(start.major, removedPars, addedPars); return t(doc, change, parChange); }); }); } - Tuple3, RichTextChange, MaterializedListModification>> replaceParagraph( - int parIdx, UnaryOperator> f) { + Tuple3, RichTextChange, MaterializedListModification>> replaceParagraph( + int parIdx, UnaryOperator> f) { return replace( new BiIndex(parIdx, 0), new BiIndex(parIdx, tree.getLeaf(parIdx).length()), doc -> doc.mapParagraphs(f)); } - ReadOnlyStyledDocument mapParagraphs(UnaryOperator> f) { + ReadOnlyStyledDocument mapParagraphs(UnaryOperator> f) { int n = tree.getLeafCount(); - List> pars = new ArrayList<>(n); + List> pars = new ArrayList<>(n); for(int i = 0; i < n; ++i) { pars.add(f.apply(tree.getLeaf(i))); } @@ -325,7 +309,7 @@ public String toString() { @Override public final boolean equals(Object other) { if(other instanceof StyledDocument) { - StyledDocument that = (StyledDocument) other; + StyledDocument that = (StyledDocument) other; return Objects.equals(this.getParagraphs(), that.getParagraphs()); } else { return false; diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/RichTextChange.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/RichTextChange.java index b5aef4483..8db5e0a2c 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/RichTextChange.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/RichTextChange.java @@ -1,8 +1,8 @@ package org.fxmisc.richtext.model; -public class RichTextChange extends TextChange, RichTextChange> { +public class RichTextChange extends TextChange, RichTextChange> { - public RichTextChange(int position, StyledDocument removed, StyledDocument inserted) { + public RichTextChange(int position, StyledDocument removed, StyledDocument inserted) { super(position, removed, inserted); } @@ -17,17 +17,17 @@ protected int insertedLength() { } @Override - protected final StyledDocument concat(StyledDocument a, StyledDocument b) { + protected final StyledDocument concat(StyledDocument a, StyledDocument b) { return a.concat(b); } @Override - protected final StyledDocument sub(StyledDocument doc, int from, int to) { + protected final StyledDocument sub(StyledDocument doc, int from, int to) { return doc.subSequence(from, to); } @Override - protected final RichTextChange create(int position, StyledDocument removed, StyledDocument inserted) { + protected final RichTextChange create(int position, StyledDocument removed, StyledDocument inserted) { return new RichTextChange<>(position, removed, inserted); } } diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/SegmentOps.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/SegmentOps.java new file mode 100644 index 000000000..6a6281d04 --- /dev/null +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/SegmentOps.java @@ -0,0 +1,105 @@ +package org.fxmisc.richtext.model; + +import java.util.Optional; + +import org.reactfx.util.Either; + +/** + * Defines the operations which are supported on a specific segment type. + * + * @param The segment type + * @param The style type for the segment + */ +public interface SegmentOps { + public int length(SEG seg); + + public char charAt(SEG seg, int index); + + public String getText(SEG seg); + + public SEG subSequence(SEG seg, int start, int end); + + public SEG subSequence(SEG seg, int start); + + public S getStyle(SEG seg); + + public SEG setStyle(SEG seg, S style); + + public Optional join(SEG currentSeg, SEG nextSeg); + + public SEG createEmpty(); + + public default SegmentOps, S> or(SegmentOps rOps) { + return either(this, rOps); + } + + public default TextOps, S> or_(TextOps rOps) { + return TextOps.eitherR(this, rOps); + } + + public static SegmentOps, S> either(SegmentOps lOps, SegmentOps rOps) { + return new EitherSegmentOps<>(lOps, rOps); + } +} + +class EitherSegmentOps implements SegmentOps, S> { + + private final SegmentOps lOps; + private final SegmentOps rOps; + + EitherSegmentOps(SegmentOps lOps, SegmentOps rOps) { + this.lOps = lOps; + this.rOps = rOps; + } + + + @Override + public int length(Either seg) { + return seg.unify(lOps::length, rOps::length); + } + + @Override + public char charAt(Either seg, int index) { + return seg.unify(l -> lOps.charAt(l, index), + r -> rOps.charAt(r, index)); + } + + @Override + public String getText(Either seg) { + return seg.unify(lOps::getText, rOps::getText); + } + + @Override + public Either subSequence(Either seg, int start, int end) { + return seg.map(l -> lOps.subSequence(l, start, end), + r -> rOps.subSequence(r, start, end)); + } + + @Override + public Either subSequence(Either seg, int start) { + return seg.map(l -> lOps.subSequence(l, start), + r -> rOps.subSequence(r, start)); + } + + @Override + public S getStyle(Either seg) { + return seg.unify(lOps::getStyle, + rOps::getStyle); + } + + @Override + public Either setStyle(Either seg, S style) { + return seg.map(l -> lOps.setStyle(l, style), + r -> rOps.setStyle(r, style)); + } + + @Override + public Optional> join(Either left, Either right) { + return left.unify(ll -> right.unify(rl -> lOps.join(ll, rl).map(Either::left), rr -> Optional.empty()), + lr -> right.unify(rl -> Optional.empty(), rr -> rOps.join(lr, rr).map(Either::right))); + } + + public Either createEmpty() { + return Either.left(lOps.createEmpty()); + } +} \ No newline at end of file diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/SimpleEditableStyledDocument.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/SimpleEditableStyledDocument.java index 5b7e5422a..968dc639a 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/SimpleEditableStyledDocument.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/SimpleEditableStyledDocument.java @@ -1,210 +1,12 @@ package org.fxmisc.richtext.model; -import static org.fxmisc.richtext.model.TwoDimensional.Bias.*; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -import org.reactfx.EventSource; -import org.reactfx.EventStream; -import org.reactfx.Subscription; -import org.reactfx.SuspendableNo; -import org.reactfx.collection.LiveList; -import org.reactfx.collection.LiveListBase; -import org.reactfx.collection.MaterializedListModification; -import org.reactfx.collection.QuasiListModification; -import org.reactfx.collection.UnmodifiableByDefaultLiveList; -import org.reactfx.util.BiIndex; -import org.reactfx.util.Lists; -import org.reactfx.value.Val; - /** - * Provides an implementation of {@link EditableStyledDocument} + * Provides an implementation of {@link EditableStyledDocument} that is specified for {@link StyledText} as its segment. + * See also {@link GenericEditableStyledDocument}. */ -public final class SimpleEditableStyledDocument implements EditableStyledDocument { - - private class ParagraphList - extends LiveListBase> - implements UnmodifiableByDefaultLiveList> { - - @Override - public Paragraph get(int index) { - return doc.getParagraph(index); - } - - @Override - public int size() { - return doc.getParagraphCount(); - } - - @Override - protected Subscription observeInputs() { - return parChanges.subscribe(mod -> { - mod = mod.trim(); - QuasiListModification> qmod = - QuasiListModification.create(mod.getFrom(), mod.getRemoved(), mod.getAddedSize()); - notifyObservers(qmod.asListChange()); - }); - } - } - - private ReadOnlyStyledDocument doc; - - private final EventSource> richChanges = new EventSource<>(); - @Override public EventStream> richChanges() { return richChanges; } - - private final Val text = Val.create(() -> doc.getText(), richChanges); - @Override public String getText() { return text.getValue(); } - @Override public Val textProperty() { return text; } - - - private final Val length = Val.create(() -> doc.length(), richChanges); - @Override public int getLength() { return length.getValue(); } - @Override public Val lengthProperty() { return length; } - @Override public int length() { return length.getValue(); } - - private final EventSource>> parChanges = - new EventSource<>(); - - private final LiveList> paragraphs = new ParagraphList(); +public final class SimpleEditableStyledDocument extends GenericEditableStyledDocumentBase, S> { - @Override - public LiveList> getParagraphs() { - return paragraphs; - } - - @Override - public ReadOnlyStyledDocument snapshot() { - return doc; - } - - private final SuspendableNo beingUpdated = new SuspendableNo(); - @Override public final SuspendableNo beingUpdatedProperty() { return beingUpdated; } - @Override public final boolean isBeingUpdated() { return beingUpdated.get(); } - - - SimpleEditableStyledDocument(Paragraph initialParagraph) { - this.doc = new ReadOnlyStyledDocument<>(Collections.singletonList(initialParagraph)); - } - - /** - * Creates an empty {@link EditableStyledDocument} - */ public SimpleEditableStyledDocument(PS initialParagraphStyle, S initialStyle) { - this(new Paragraph<>(initialParagraphStyle, "", initialStyle)); - } - - - @Override - public Position position(int major, int minor) { - return doc.position(major, minor); - } - - @Override - public Position offsetToPosition(int offset, Bias bias) { - return doc.offsetToPosition(offset, bias); - } - - @Override - public void replace(int start, int end, StyledDocument replacement) { - ensureValidRange(start, end); - doc.replace(start, end, ReadOnlyStyledDocument.from(replacement)).exec(this::update); - } - - @Override - public void setStyle(int from, int to, S style) { - ensureValidRange(from, to); - doc.replace(from, to, removed -> removed.mapParagraphs(par -> par.restyle(style))).exec(this::update); - } - - @Override - public void setStyle(int paragraph, S style) { - ensureValidParagraphIndex(paragraph); - doc.replaceParagraph(paragraph, p -> p.restyle(style)).exec(this::update); - } - - @Override - public void setStyle(int paragraph, int fromCol, int toCol, S style) { - ensureValidParagraphRange(paragraph, fromCol, toCol); - doc.replace( - new BiIndex(paragraph, fromCol), - new BiIndex(paragraph, toCol), - d -> d.mapParagraphs(p -> p.restyle(style)) - ).exec(this::update); - } - - @Override - public void setStyleSpans(int from, StyleSpans styleSpans) { - int len = styleSpans.length(); - ensureValidRange(from, from + len); - doc.replace(from, from + len, d -> { - Position i = styleSpans.position(0, 0); - List> pars = new ArrayList<>(d.getParagraphs().size()); - for(Paragraph p: d.getParagraphs()) { - Position j = i.offsetBy(p.length(), Backward); - StyleSpans spans = styleSpans.subView(i, j); - pars.add(p.restyle(0, spans)); - i = j.offsetBy(1, Forward); // skip the newline - } - return new ReadOnlyStyledDocument<>(pars); - }).exec(this::update); - } - - @Override - public void setStyleSpans(int paragraph, int from, StyleSpans styleSpans) { - setStyleSpans(doc.position(paragraph, from).toOffset(), styleSpans); - } - - @Override - public void setParagraphStyle(int parIdx, PS style) { - ensureValidParagraphIndex(parIdx); - doc.replaceParagraph(parIdx, p -> p.setParagraphStyle(style)).exec(this::update); - } - - @Override - public StyledDocument concat(StyledDocument that) { - return doc.concat(that); - } - - @Override - public StyledDocument subSequence(int start, int end) { - return doc.subSequence(start, end); - } - - - /* ********************************************************************** * - * * - * Private and package private methods * - * * - * ********************************************************************** */ - - private void ensureValidParagraphIndex(int parIdx) { - Lists.checkIndex(parIdx, doc.getParagraphCount()); - } - - private void ensureValidRange(int start, int end) { - Lists.checkRange(start, end, length()); - } - - private void ensureValidParagraphRange(int par, int start, int end) { - ensureValidParagraphIndex(par); - Lists.checkRange(start, end, fullLength(par)); - } - - private int fullLength(int par) { - int n = doc.getParagraphCount(); - return doc.getParagraph(par).length() + (par == n-1 ? 0 : 1); - } - - private void update( - ReadOnlyStyledDocument newValue, - RichTextChange change, - MaterializedListModification> parChange) { - this.doc = newValue; - beingUpdated.suspendWhile(() -> { - richChanges.push(change); - parChanges.push(parChange); - }); + super(initialParagraphStyle, initialStyle, StyledText.textOps()); } } diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/StyleSpan.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/StyleSpan.java index 3eb9cd9fa..d70cd398c 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/StyleSpan.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/StyleSpan.java @@ -43,4 +43,9 @@ public boolean equals(Object other) { public int hashCode() { return Objects.hash(style, length); } + + @Override + public String toString() { + return String.format("StyleSpan[length=%s, style=%s]", length, style); + } } diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledDocument.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledDocument.java index 3cec28421..07a507358 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledDocument.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledDocument.java @@ -8,18 +8,17 @@ import javafx.scene.control.IndexRange; -public interface StyledDocument extends TwoDimensional { +public interface StyledDocument extends TwoDimensional { int length(); String getText(); - List> getParagraphs(); + List> getParagraphs(); - StyledDocument concat(StyledDocument that); - - StyledDocument subSequence(int start, int end); + StyledDocument concat(StyledDocument that); + StyledDocument subSequence(int start, int end); default String getText(IndexRange range) { return getText(range.getStart(), range.getEnd()); @@ -29,11 +28,11 @@ default String getText(int start, int end) { return subSequence(start, end).getText(); } - default StyledDocument subSequence(IndexRange range) { + default StyledDocument subSequence(IndexRange range) { return subSequence(range.getStart(), range.getEnd()); } - default StyledDocument subDocument(int paragraphIndex) { + default StyledDocument subDocument(int paragraphIndex) { return new ReadOnlyStyledDocument<>(Collections.singletonList(getParagraphs().get(paragraphIndex))); } @@ -95,18 +94,18 @@ default StyleSpans getStyleSpans(int from, int to) { List> subSpans = new ArrayList<>(affectedPars); if(startParIdx == endParIdx) { - Paragraph par = getParagraphs().get(startParIdx); + Paragraph par = getParagraphs().get(startParIdx); subSpans.add(par.getStyleSpans(start.getMinor(), end.getMinor())); } else { - Paragraph startPar = getParagraphs().get(startParIdx); + Paragraph startPar = getParagraphs().get(startParIdx); subSpans.add(startPar.getStyleSpans(start.getMinor(), startPar.length() + 1)); for(int i = startParIdx + 1; i < endParIdx; ++i) { - Paragraph par = getParagraphs().get(i); + Paragraph par = getParagraphs().get(i); subSpans.add(par.getStyleSpans(0, par.length() + 1)); } - Paragraph endPar = getParagraphs().get(endParIdx); + Paragraph endPar = getParagraphs().get(endParIdx); subSpans.add(endPar.getStyleSpans(0, end.getMinor())); } diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledText.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledText.java index 418138e7f..14fd9c06a 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledText.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledText.java @@ -1,53 +1,116 @@ package org.fxmisc.richtext.model; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; import java.util.Objects; +import java.util.Optional; -public class StyledText { - private final String text; - private final S style; +public class StyledText { - public StyledText(String text, S style) { - this.text = text; - this.style = style; - } + public static TextOps, S> textOps() { + return new TextOps, S>() { - public int length() { - return text.length(); - } + private final StyledText emptySeg = new StyledText<>("", null); - public char charAt(int index) { - return text.charAt(index); - } + @Override + public int length(StyledText styledText) { + return styledText.getText().length(); + } - public String getText() { - return text; - } + @Override + public char charAt(StyledText styledText, int index) { + return styledText == emptySeg ? '\0' : styledText.getText().charAt(index); + } - public StyledText subSequence(int start, int end) { - return new StyledText<>(text.substring(start, end), style); - } + @Override + public String getText(StyledText styledText) { + return styledText.getText(); + } + + @Override + public StyledText subSequence(StyledText styledText, int start, int end) { + return styledText == emptySeg ? emptySeg : new StyledText<>(styledText.getText().substring(start, end), styledText.getStyle()); + } + + @Override + public StyledText subSequence(StyledText styledText, int start) { + return styledText == emptySeg ? emptySeg : new StyledText<>(styledText.getText().substring(start), styledText.getStyle()); + } + + @Override + public S getStyle(StyledText styledText) { + return styledText.getStyle(); + } + + @Override + public StyledText setStyle(StyledText seg, S style) { + return seg == emptySeg ? emptySeg : seg.setStyle(style); + } + + @Override + public Optional> join(StyledText left, StyledText right) { + return Objects.equals(left.getStyle(), right.getStyle()) + ? Optional.of(new StyledText<>(left.getText() + right.getText(), left.getStyle())) + : Optional.empty(); + } - public StyledText subSequence(int start) { - return new StyledText<>(text.substring(start), style); + @Override + public StyledText createEmpty() { + return emptySeg; + } + + @Override + public StyledText create(String text, S style) { + return new StyledText<>(text, style); + } + }; } - public StyledText append(String str) { - return new StyledText<>(text + str, style); + public static Codec> codec(Codec styleCodec) { + return new Codec>() { + + @Override + public String getName() { + return "styled-text"; + } + + @Override + public void encode(DataOutputStream os, StyledText t) throws IOException { + Codec.STRING_CODEC.encode(os, t.text); + styleCodec.encode(os, t.style); + } + + @Override + public StyledText decode(DataInputStream is) throws IOException { + String text = Codec.STRING_CODEC.decode(is); + S style = styleCodec.decode(is); + return new StyledText<>(text, style); + } + + }; } - public StyledText spliced(int from, int to, CharSequence replacement) { - String left = text.substring(0, from); - String right = text.substring(to); - return new StyledText<>(left + replacement + right, style); + + private final String text; + + public String getText() { return text; } + + private final S style; + public S getStyle() { return style; } + + public StyledText setStyle(S style) { + return new StyledText<>(text, style); } - public S getStyle() { - return style; + public StyledText(String text, S style) { + this.text = text; + this.style = style; } @Override public String toString() { - return '"' + text + '"' + ":" + style; + return String.format("StyledText[text=\"%s\", style=%s]", text, style); } @Override @@ -65,4 +128,5 @@ public boolean equals(Object obj) { public int hashCode() { return Objects.hash(text, style); } + } diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledTextAreaModel.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledTextAreaModel.java index 301abadb9..a91b37018 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledTextAreaModel.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/StyledTextAreaModel.java @@ -28,15 +28,15 @@ import org.reactfx.value.Var; /** - * Model for {@link org.fxmisc.richtext.StyledTextArea} + * Model for {@link org.fxmisc.richtext.GenericStyledArea} * * @param type of style that can be applied to text. * @param type of style that can be applied to Paragraph */ -public class StyledTextAreaModel +public class StyledTextAreaModel implements - EditActions, - NavigationActions, + EditActions, + NavigationActions, UndoActions, TwoDimensional { @@ -103,7 +103,7 @@ private static int clamp(int min, int val, int max) { @Override public final ObservableValue textProperty() { return text; } // rich text - @Override public final StyledDocument getDocument() { return content.snapshot(); } + @Override public final StyledDocument getDocument() { return content.snapshot(); } // length private final SuspendableVal length; @@ -143,8 +143,8 @@ private static int clamp(int min, int val, int max) { @Override public final ObservableValue caretColumnProperty() { return caretColumn; } // paragraphs - private final SuspendableList> paragraphs; - @Override public LiveList> getParagraphs() { return paragraphs; } + private final SuspendableList> paragraphs; + @Override public LiveList> getParagraphs() { return paragraphs; } // beingUpdated private final SuspendableNo beingUpdated = new SuspendableNo(); @@ -163,9 +163,9 @@ private static int clamp(int min, int val, int max) { public final EventStream plainTextChanges() { return plainTextChanges; } // rich text changes - private final SuspendableEventStream> richTextChanges; + private final SuspendableEventStream> richTextChanges; @Override - public final EventStream> richChanges() { return richTextChanges; } + public final EventStream> richChanges() { return richTextChanges; } /* ********************************************************************** * * * @@ -173,6 +173,8 @@ private static int clamp(int min, int val, int max) { * * * ********************************************************************** */ + private final TextOps textOps; + private Subscription subscriptions = () -> {}; private Position selectionStart2D; @@ -181,14 +183,14 @@ private static int clamp(int min, int val, int max) { /** * content model */ - private final EditableStyledDocument content; + private final EditableStyledDocument content; /** * Usually used to create another area (View) that shares * the same document (Model). * @return this area's {@link EditableStyledDocument} */ - public final EditableStyledDocument getContent() { return content; } + public final EditableStyledDocument getContent() { return content; } /** * Style used by default when no other style is provided. @@ -225,30 +227,36 @@ private static int clamp(int min, int val, int max) { * @param initialParagraphStyle style to use in places where no other style is * specified (yet). */ - public StyledTextAreaModel(PS initialParagraphStyle, S initialTextStyle) { - this(initialParagraphStyle, initialTextStyle, true); + public StyledTextAreaModel(PS initialParagraphStyle, S initialTextStyle, TextOps segmentOps) { + this(initialParagraphStyle, initialTextStyle, segmentOps, true); } - public StyledTextAreaModel(PS initialParagraphStyle, S initialTextStyle, boolean preserveStyle + public StyledTextAreaModel(PS initialParagraphStyle, S initialTextStyle, TextOps segmentOps, boolean preserveStyle ) { this(initialParagraphStyle, initialTextStyle, - new SimpleEditableStyledDocument<>(initialParagraphStyle, initialTextStyle), preserveStyle); + new GenericEditableStyledDocumentBase<>(initialParagraphStyle, initialTextStyle, segmentOps), + segmentOps, preserveStyle); } /** - * The same as {@link #StyledTextAreaModel(Object, Object)} except that + * The same as {@link #StyledTextAreaModel(Object, Object, TextOps)} except that * this constructor can be used to create another {@code StyledTextArea} object that * shares the same {@link EditableStyledDocument}. */ public StyledTextAreaModel(PS initialParagraphStyle, S initialTextStyle, - EditableStyledDocument document + EditableStyledDocument document, TextOps textOps ) { - this(initialParagraphStyle, initialTextStyle, document, true); + this(initialParagraphStyle, initialTextStyle, document, textOps, true); } - public StyledTextAreaModel(PS initialParagraphStyle, S initialTextStyle, - EditableStyledDocument document, boolean preserveStyle + public StyledTextAreaModel( + PS initialParagraphStyle, + S initialTextStyle, + EditableStyledDocument document, + TextOps textOps, + boolean preserveStyle ) { + this.textOps = textOps; this.initialTextStyle = initialTextStyle; this.initialParagraphStyle = initialParagraphStyle; this.preserveStyle = preserveStyle; @@ -375,17 +383,17 @@ public String getText(int paragraph) { return paragraphs.get(paragraph).getText(); } - public Paragraph getParagraph(int index) { + public Paragraph getParagraph(int index) { return paragraphs.get(index); } @Override - public StyledDocument subDocument(int start, int end) { + public StyledDocument subDocument(int start, int end) { return content.subSequence(start, end); } @Override - public StyledDocument subDocument(int paragraphIndex) { + public StyledDocument subDocument(int paragraphIndex) { return content.subDocument(paragraphIndex); } @@ -507,6 +515,7 @@ public StyleSpans getStyleSpans(int paragraph, IndexRange range) { return getStyleSpans(paragraph, range.getStart(), range.getEnd()); } + @Override public int getAbsolutePosition(int paragraphIndex, int columnIndex) { return content.getAbsolutePosition(paragraphIndex, columnIndex); } @@ -618,13 +627,13 @@ public void clearParagraphStyle(int paragraph) { @Override public void replaceText(int start, int end, String text) { - StyledDocument doc = ReadOnlyStyledDocument.fromString( - text, getParagraphStyleForInsertionAt(start), getStyleForInsertionAt(start)); + StyledDocument doc = ReadOnlyStyledDocument.fromString( + text, getParagraphStyleForInsertionAt(start), getStyleForInsertionAt(start), textOps); replace(start, end, doc); } @Override - public void replace(int start, int end, StyledDocument replacement) { + public void replace(int start, int end, StyledDocument replacement) { try (Guard g = content.beingUpdatedProperty().suspend()) { start = clamp(0, start, getLength()); end = clamp(0, end, getLength()); @@ -702,8 +711,8 @@ private UndoManager createPlainUndoManager(UndoManagerFactory factory) { } private UndoManager createRichUndoManager(UndoManagerFactory factory) { - Consumer> apply = change -> replace(change.getPosition(), change.getPosition() + change.getRemoved().length(), change.getInserted()); - BiFunction, RichTextChange, Optional>> merge = RichTextChange::mergeWith; + Consumer> apply = change -> replace(change.getPosition(), change.getPosition() + change.getRemoved().length(), change.getInserted()); + BiFunction, RichTextChange, Optional>> merge = RichTextChange::mergeWith; return factory.create(richChanges(), RichTextChange::invert, apply, merge); } diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/TextEditingArea.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/TextEditingArea.java index 0c573ab58..f252dca13 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/model/TextEditingArea.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/TextEditingArea.java @@ -14,7 +14,7 @@ * * @param type of style that can be applied to text. */ -public interface TextEditingArea { +public interface TextEditingArea { /******************* * * @@ -39,7 +39,7 @@ public interface TextEditingArea { * The returned document is immutable, it does not reflect * subsequent edits of this text-editing area. */ - StyledDocument getDocument(); + StyledDocument getDocument(); /** * The current position of the caret, as a character offset in the text. @@ -90,7 +90,7 @@ public interface TextEditingArea { /** * Unmodifiable observable list of paragraphs in this text area. */ - ObservableList> getParagraphs(); + ObservableList> getParagraphs(); /********************* @@ -107,7 +107,7 @@ public interface TextEditingArea { /** * Stream of rich text changes. */ - EventStream> richChanges(); + EventStream> richChanges(); /*************** @@ -141,12 +141,12 @@ default String getText(int startParagraph, int startColumn, int endParagraph, in /** * Returns rich-text content of the given paragraph. */ - StyledDocument subDocument(int paragraphIndex); + StyledDocument subDocument(int paragraphIndex); /** * Returns rich-text content of the given character range. */ - StyledDocument subDocument(int start, int end); + StyledDocument subDocument(int start, int end); /** * Returns rich-text content of the given character range. @@ -154,7 +154,7 @@ default String getText(int startParagraph, int startColumn, int endParagraph, in *

Caution: see {@link #getAbsolutePosition(int, int)} to know how the column index argument * can affect the returned position.

*/ - default StyledDocument subDocument(int startParagraph, int startColumn, int endParagraph, int endColumn) { + default StyledDocument subDocument(int startParagraph, int startColumn, int endParagraph, int endColumn) { int start = getAbsolutePosition(startParagraph, startColumn); int end = getAbsolutePosition(endParagraph, endColumn); return subDocument(start, end); @@ -219,7 +219,7 @@ default void replaceText(int startParagraph, int startColumn, int endParagraph, /** * Replaces a range of characters with the given rich-text document. */ - void replace(int start, int end, StyledDocument replacement); + void replace(int start, int end, StyledDocument replacement); /** * Replaces a range of characters with the given rich-text document. @@ -227,7 +227,7 @@ default void replaceText(int startParagraph, int startColumn, int endParagraph, *

Caution: see {@link #getAbsolutePosition(int, int)} to know how the column index argument * can affect the returned position.

*/ - default void replace(int startParagraph, int startColumn, int endParagraph, int endColumn, StyledDocument replacement) { + default void replace(int startParagraph, int startColumn, int endParagraph, int endColumn, StyledDocument replacement) { int start = getAbsolutePosition(startParagraph, startColumn); int end = getAbsolutePosition(endParagraph, endColumn); replace(start, end, replacement); @@ -250,7 +250,7 @@ default void replaceText(IndexRange range, String text) { * Equivalent to * {@code replace(range.getStart(), range.getEnd(), replacement)}. */ - default void replace(IndexRange range, StyledDocument replacement) { + default void replace(IndexRange range, StyledDocument replacement) { replace(range.getStart(), range.getEnd(), replacement); } diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/model/TextOps.java b/richtextfx/src/main/java/org/fxmisc/richtext/model/TextOps.java new file mode 100644 index 000000000..077f23d54 --- /dev/null +++ b/richtextfx/src/main/java/org/fxmisc/richtext/model/TextOps.java @@ -0,0 +1,52 @@ +package org.fxmisc.richtext.model; + +import org.reactfx.util.Either; + +public interface TextOps extends SegmentOps { + public SEG create(String text, S style); + + public default TextOps, S> _or(SegmentOps rOps) { + return eitherL(this, rOps); + } + + public static TextOps, S> eitherL(TextOps lOps, SegmentOps rOps) { + return new LeftTextOps<>(lOps, rOps); + } + + public static TextOps, S> eitherR(SegmentOps lOps, TextOps rOps) { + return new RightTextOps<>(lOps, rOps); + } + +} + +class LeftTextOps extends EitherSegmentOps implements TextOps, S> { + + private final TextOps lOps; + + LeftTextOps(TextOps lOps, SegmentOps rOps) { + super(lOps, rOps); + this.lOps = lOps; + } + + @Override + public Either create(String text, S style) { + return Either.left(lOps.create(text, style)); + } + +} + +class RightTextOps extends EitherSegmentOps implements TextOps, S> { + + private final TextOps rOps; + + RightTextOps(SegmentOps lOps, TextOps rOps) { + super(lOps, rOps); + this.rOps = rOps; + } + + @Override + public Either create(String text, S style) { + return Either.right(rOps.create(text, style)); + } + +} \ No newline at end of file diff --git a/richtextfx/src/test/java/org/fxmisc/richtext/model/ParagraphTest.java b/richtextfx/src/test/java/org/fxmisc/richtext/model/ParagraphTest.java index 87a298147..d7ff9ec32 100644 --- a/richtextfx/src/test/java/org/fxmisc/richtext/model/ParagraphTest.java +++ b/richtextfx/src/test/java/org/fxmisc/richtext/model/ParagraphTest.java @@ -1,6 +1,7 @@ package org.fxmisc.richtext.model; import static org.junit.Assert.*; + import org.junit.Test; public class ParagraphTest { @@ -10,10 +11,11 @@ public class ParagraphTest { // This relates to merging text changes and issue #216. @Test public void concatEmptyParagraphsTest() { - Paragraph p1 = new Paragraph<>(null, "", true); - Paragraph p2 = new Paragraph<>(null, "", false); + TextOps, Boolean> segOps = StyledText.textOps(); + Paragraph, Boolean> p1 = new Paragraph<>(null, segOps, segOps.create("", true)); + Paragraph, Boolean> p2 = new Paragraph<>(null, segOps, segOps.create("", false)); - Paragraph p = p1.concat(p2); + Paragraph, Boolean> p = p1.concat(p2); assertEquals(Boolean.TRUE, p.getStyleAtPosition(0)); } diff --git a/richtextfx/src/test/java/org/fxmisc/richtext/model/ReadOnlyStyledDocumentTest.java b/richtextfx/src/test/java/org/fxmisc/richtext/model/ReadOnlyStyledDocumentTest.java index 71f2de0f2..6f70e41cb 100644 --- a/richtextfx/src/test/java/org/fxmisc/richtext/model/ReadOnlyStyledDocumentTest.java +++ b/richtextfx/src/test/java/org/fxmisc/richtext/model/ReadOnlyStyledDocumentTest.java @@ -1,18 +1,22 @@ package org.fxmisc.richtext.model; import static org.fxmisc.richtext.model.ReadOnlyStyledDocument.*; +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.*; import java.util.List; + import org.junit.Test; public class ReadOnlyStyledDocumentTest { @Test public void testUndo() { - ReadOnlyStyledDocument doc0 = fromString("", "X", "X"); + TextOps, String> segOps = StyledText.textOps(); + ReadOnlyStyledDocument, String> doc0 = fromString("", "X", "X", segOps); - doc0.replace(0, 0, fromString("abcd", "Y", "Y")).exec((doc1, chng1, pchng1) -> { + doc0.replace(0, 0, fromString("abcd", "Y", "Y", segOps)).exec((doc1, chng1, pchng1) -> { // undo chng1 doc1.replace(chng1.getPosition(), chng1.getInsertionEnd(), from(chng1.getRemoved())).exec((doc2, chng2, pchng2) -> { // we should have arrived at the original document @@ -26,16 +30,52 @@ public void testUndo() { @Test public void deleteNewlineTest() { - ReadOnlyStyledDocument doc0 = fromString("Foo\nBar", null, null); - doc0.replace(3, 4, fromString("", null, null)).exec((doc1, ch, pch) -> { - List> removed = pch.getRemoved(); - List> added = pch.getAdded(); + TextOps, Void> segOps = StyledText.textOps(); + ReadOnlyStyledDocument, Void> doc0 = fromString("Foo\nBar", null, null, segOps); + doc0.replace(3, 4, fromString("", null, null, segOps)).exec((doc1, ch, pch) -> { + List, Void>> removed = pch.getRemoved(); + List, Void>> added = pch.getAdded(); assertEquals(2, removed.size()); - assertEquals(new Paragraph(null, "Foo", null), removed.get(0)); - assertEquals(new Paragraph(null, "Bar", null), removed.get(1)); + assertEquals(new Paragraph, Void>(null, segOps, segOps.create("Foo", null)), removed.get(0)); + assertEquals(new Paragraph, Void>(null, segOps, segOps.create("Bar", null)), removed.get(1)); assertEquals(1, added.size()); - assertEquals(new Paragraph(null, "FooBar", null), added.get(0)); + assertEquals(new Paragraph, Void>(null, segOps, segOps.create("FooBar", null)), added.get(0)); }); } + @Test + public void testRestyle() { + final String fooBar = "Foo Bar"; + final String and = " and "; + final String helloWorld = "Hello World"; + TextOps, String> segOps = StyledText.textOps(); + + SimpleEditableStyledDocument doc0 = new SimpleEditableStyledDocument<>("", ""); + + ReadOnlyStyledDocument, String> text = fromString(fooBar, "", "bold", segOps); + doc0.replace(doc0.getLength(), doc0.getLength(), text); + + text = fromString(and, "", "", segOps); + doc0.replace(doc0.getLength(), doc0.getLength(), text); + + text = fromString(helloWorld, "", "bold", segOps); + doc0.replace(doc0.getLength(), doc0.getLength(), text); + + StyleSpans styles = doc0.getStyleSpans(4, 17); + assertThat("Invalid number of Spans", styles.getSpanCount(), equalTo(3)); + + StyleSpans newStyles = styles.mapStyles(style -> "italic"); + doc0.setStyleSpans(4, newStyles); + + // assert the new segment structure: + // StyledText[text="Foo ", style=bold] + // StyledText[text="Bar and Hello", style=italic] + // StyledText[text=" World", style=bold] + List> result = doc0.getParagraphs().get(0).getSegments(); + assertThat(result.size(), equalTo(3)); + assertThat(result.get(0).getText(), equalTo("Foo ")); + assertThat(result.get(1).getText(), equalTo("Bar and Hello")); + assertThat(result.get(2).getText(), equalTo(" World")); + } + } diff --git a/richtextfx/src/test/java/org/fxmisc/richtext/model/SimpleEditableStyledDocumentTest.java b/richtextfx/src/test/java/org/fxmisc/richtext/model/SimpleEditableStyledDocumentTest.java index 7a14817ec..6d68369b3 100644 --- a/richtextfx/src/test/java/org/fxmisc/richtext/model/SimpleEditableStyledDocumentTest.java +++ b/richtextfx/src/test/java/org/fxmisc/richtext/model/SimpleEditableStyledDocumentTest.java @@ -5,13 +5,15 @@ public class SimpleEditableStyledDocumentTest { + private final TextOps, String> segOps = StyledText.textOps(); + /** * The style of the inserted text will be the style at position * {@code start} in the current document. */ - private void replaceText(EditableStyledDocument doc, int start, int end, String text) { - StyledDocument styledDoc = ReadOnlyStyledDocument.fromString( - text, doc.getParagraphStyleAtPosition(start), doc.getStyleAtPosition(start)); + private void replaceText(EditableStyledDocument, String> doc, int start, int end, String text) { + StyledDocument, String> styledDoc = ReadOnlyStyledDocument.fromString( + text, doc.getParagraphStyleAtPosition(start), doc.getStyleAtPosition(start), segOps); doc.replace(start, end, styledDoc); } diff --git a/richtextfx/src/test/java/org/fxmisc/richtext/model/StyledTextAreaModelTest.java b/richtextfx/src/test/java/org/fxmisc/richtext/model/StyledTextAreaModelTest.java index ffbc6f410..b8df9131d 100644 --- a/richtextfx/src/test/java/org/fxmisc/richtext/model/StyledTextAreaModelTest.java +++ b/richtextfx/src/test/java/org/fxmisc/richtext/model/StyledTextAreaModelTest.java @@ -11,11 +11,14 @@ public class StyledTextAreaModelTest { @Test public void testUndoWithWinNewlines() { + final TextOps>, Collection> segOps = StyledText.textOps(); + String text1 = "abc\r\ndef"; String text2 = "A\r\nB\r\nC"; - StyledTextAreaModel, Collection> model = new StyledTextAreaModel<>( + StyledTextAreaModel, StyledText>, Collection> model = new StyledTextAreaModel<>( + Collections.emptyList(), Collections.emptyList(), - Collections.emptyList() + segOps ); model.replaceText(text1); @@ -29,10 +32,12 @@ public void testUndoWithWinNewlines() { @Test public void testForBug216() { + final TextOps, Boolean> segOps = StyledText.textOps(); + // set up area with some styled text content boolean initialStyle = false; - StyledTextAreaModel model = new StyledTextAreaModel<>( - "", initialStyle, new SimpleEditableStyledDocument<>("", initialStyle), true); + StyledTextAreaModel, Boolean> model = new StyledTextAreaModel<>( + "", initialStyle, new SimpleEditableStyledDocument<>("", initialStyle), segOps, true); model.replaceText("testtest"); model.setStyle(0, 8, true);