diff --git a/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/hyperlink/Hyperlink.java b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/hyperlink/Hyperlink.java new file mode 100644 index 000000000..b9aaf5454 --- /dev/null +++ b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/hyperlink/Hyperlink.java @@ -0,0 +1,75 @@ +package org.fxmisc.richtext.demo.hyperlink; + +public class Hyperlink { + + private final String originalDisplayedText; + private final String displayedText; + private final S style; + private final String link; + + Hyperlink(String originalDisplayedText, String displayedText, S style, String link) { + this.originalDisplayedText = originalDisplayedText; + this.displayedText = displayedText; + this.style = style; + this.link = link; + } + + public boolean isEmpty() { + return length() == 0; + } + + public boolean isReal() { + return length() > 0; + } + + public boolean shareSameAncestor(Hyperlink other) { + return link.equals(other.link) && originalDisplayedText.equals(other.originalDisplayedText); + } + + public int length() { + return displayedText.length(); + } + + public char charAt(int index) { + return isEmpty() ? '\0' : displayedText.charAt(index); + } + + public String getOriginalDisplayedText() { return originalDisplayedText; } + + public String getDisplayedText() { + return displayedText; + } + + public String getLink() { + return link; + } + + public Hyperlink subSequence(int start, int end) { + return new Hyperlink<>(originalDisplayedText, displayedText.substring(start, end), style, link); + } + + public Hyperlink subSequence(int start) { + return new Hyperlink<>(originalDisplayedText, displayedText.substring(start), style, link); + } + + public S getStyle() { + return style; + } + + public Hyperlink setStyle(S style) { + return new Hyperlink<>(originalDisplayedText, displayedText, style, link); + } + + public Hyperlink mapDisplayedText(String text) { + return new Hyperlink<>(originalDisplayedText, text, style, link); + } + + @Override + public String toString() { + return isEmpty() + ? String.format("EmptyHyperlink[original=%s style=%s link=%s]", originalDisplayedText, style, link) + : String.format("RealHyperlink[original=%s displayedText=%s, style=%s, link=%s]", + originalDisplayedText, displayedText, style, link); + } + +} diff --git a/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/hyperlink/HyperlinkDemo.java b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/hyperlink/HyperlinkDemo.java new file mode 100644 index 000000000..e49dbf7d4 --- /dev/null +++ b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/hyperlink/HyperlinkDemo.java @@ -0,0 +1,38 @@ +package org.fxmisc.richtext.demo.hyperlink; + +import com.sun.deploy.uitoolkit.impl.fx.HostServicesFactory; +import com.sun.javafx.application.HostServicesDelegate; +import javafx.application.Application; +import javafx.application.HostServices; +import javafx.scene.Scene; +import javafx.stage.Stage; +import org.fxmisc.flowless.VirtualizedScrollPane; + +import java.util.function.Consumer; + +/** + * Demonstrates the minimum needed to support custom objects (in this case, hyperlinks) alongside of text. + * + * Note: demo does not handle cases where the link changes its state when it has already been visited + */ +public class HyperlinkDemo extends Application { + + public static void main(String[] args) { + launch(args); + } + + @Override + public void start(Stage primaryStage) { + Consumer showLink = HostServicesFactory.getInstance(this)::showDocument; + TextHyperlinkArea area = new TextHyperlinkArea(showLink); + + area.appendText("Some text in the area\n"); + area.appendWithLink("Google.com", "http://www.google.com"); + + VirtualizedScrollPane vsPane = new VirtualizedScrollPane<>(area); + + Scene scene = new Scene(vsPane, 500, 500); + primaryStage.setScene(scene); + primaryStage.show(); + } +} diff --git a/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/hyperlink/HyperlinkOps.java b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/hyperlink/HyperlinkOps.java new file mode 100644 index 000000000..954f90883 --- /dev/null +++ b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/hyperlink/HyperlinkOps.java @@ -0,0 +1,97 @@ +package org.fxmisc.richtext.demo.hyperlink; + +import org.fxmisc.richtext.model.SegmentOps; + +import java.util.Optional; + +public class HyperlinkOps implements SegmentOps, S> { + + @Override + public int length(Hyperlink hyperlink) { + return hyperlink.length(); + } + + @Override + public char charAt(Hyperlink hyperlink, int index) { + return hyperlink.charAt(index); + } + + @Override + public String getText(Hyperlink hyperlink) { + return hyperlink.getDisplayedText(); + } + + @Override + public Hyperlink subSequence(Hyperlink hyperlink, int start, int end) { + return hyperlink.subSequence(start, end); + } + + @Override + public Hyperlink subSequence(Hyperlink hyperlink, int start) { + return hyperlink.subSequence(start); + } + + @Override + public S getStyle(Hyperlink hyperlink) { + return hyperlink.getStyle(); + } + + @Override + public Hyperlink setStyle(Hyperlink hyperlink, S style) { + return hyperlink.setStyle(style); + } + + @Override + public Optional> join(Hyperlink currentSeg, Hyperlink nextSeg) { + if (currentSeg.isEmpty()) { + if (nextSeg.isEmpty()) { + return Optional.empty(); + } else { + return Optional.of(nextSeg); + } + } else { + if (nextSeg.isEmpty()) { + return Optional.of(currentSeg); + } else { + return concatHyperlinks(currentSeg, nextSeg); + } + } + } + + private Optional> concatHyperlinks(Hyperlink leftSeg, Hyperlink rightSeg) { + if (!leftSeg.shareSameAncestor(rightSeg)) { + return Optional.empty(); + } + + String original = leftSeg.getOriginalDisplayedText(); + String leftText = leftSeg.getDisplayedText(); + String rightText = rightSeg.getDisplayedText(); + int leftOffset = 0; + int rightOffset = 0; + for (int i = 0; i <= original.length() - leftText.length(); i++) { + if (original.regionMatches(i, leftText, 0, leftText.length())) { + leftOffset = i; + break; + } + } + for (int i = 0; i <= original.length() - rightText.length(); i++) { + if (original.regionMatches(i, rightText, 0, rightText.length())) { + rightOffset = i; + break; + } + } + + if (rightOffset + rightText.length() == leftOffset) { + return Optional.of(leftSeg.mapDisplayedText(rightText + leftText)); + } else if (leftOffset + leftText.length() == rightOffset) { + return Optional.of(leftSeg.mapDisplayedText(leftText + rightText)); + } else { + return Optional.empty(); + } + } + + @Override + public Hyperlink createEmpty() { + return new Hyperlink<>("", "", null, ""); + } +} diff --git a/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/hyperlink/TextHyperlinkArea.java b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/hyperlink/TextHyperlinkArea.java new file mode 100644 index 000000000..77b6e6473 --- /dev/null +++ b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/hyperlink/TextHyperlinkArea.java @@ -0,0 +1,69 @@ +package org.fxmisc.richtext.demo.hyperlink; + +import javafx.geometry.VPos; +import org.fxmisc.richtext.GenericStyledArea; +import org.fxmisc.richtext.TextExt; +import org.fxmisc.richtext.model.ReadOnlyStyledDocument; +import org.fxmisc.richtext.model.StyledText; +import org.fxmisc.richtext.model.TextOps; +import org.reactfx.util.Either; + +import java.util.function.Consumer; + +public class TextHyperlinkArea extends GenericStyledArea, Hyperlink>, TextStyle> { + + private static final TextOps, TextStyle> STYLED_TEXT_OPS = StyledText.textOps(); + private static final HyperlinkOps HYPERLINK_OPS = new HyperlinkOps<>(); + private static final TextOps, Hyperlink>, TextStyle> EITHER_OPS = STYLED_TEXT_OPS._or(HYPERLINK_OPS); + + public TextHyperlinkArea(Consumer showLink) { + super( + null, + (t, p) -> {}, + TextStyle.EMPTY, + EITHER_OPS, + e -> e.unify( + styledText -> + createStyledTextNode(t -> { + t.setText(styledText.getText()); + t.setStyle(styledText.getStyle().toCss()); + }), + hyperlink -> + createStyledTextNode(t -> { + if (hyperlink.isReal()) { + t.setText(hyperlink.getDisplayedText()); + t.getStyleClass().add("hyperlink"); + t.setOnMouseClicked(ae -> showLink.accept(hyperlink.getLink())); + } + }) + ) + ); + + getStyleClass().add("text-hyperlink-area"); + getStylesheets().add(TextHyperlinkArea.class.getResource("text-hyperlink-area.css").toExternalForm()); + } + + public void appendWithLink(String displayedText, String link) { + replaceWithLink(getLength(), getLength(), displayedText, link); + } + + public void replaceWithLink(int start, int end, String displayedText, String link) { + replace(start, end, ReadOnlyStyledDocument.fromSegment( + Either.right(new Hyperlink<>(displayedText, displayedText, TextStyle.EMPTY, link)), + null, + TextStyle.EMPTY, + EITHER_OPS + )); + } + + public static TextExt createStyledTextNode(Consumer applySegment) { + TextExt t = new TextExt(); + t.setTextOrigin(VPos.TOP); + applySegment.accept(t); + + // XXX: binding selectionFill to textFill, + // see the note at highlightTextFill + t.impl_selectionFillProperty().bind(t.fillProperty()); + return t; + } +} diff --git a/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/hyperlink/TextStyle.java b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/hyperlink/TextStyle.java new file mode 100644 index 000000000..cd817d1a3 --- /dev/null +++ b/richtextfx-demos/src/main/java/org/fxmisc/richtext/demo/hyperlink/TextStyle.java @@ -0,0 +1,297 @@ +package org.fxmisc.richtext.demo.hyperlink; + +import javafx.scene.paint.Color; +import org.fxmisc.richtext.model.Codec; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.charset.MalformedInputException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Holds information about the style of a text fragment. + */ +class TextStyle { + + public static final TextStyle EMPTY = new TextStyle(); + + public static final Codec CODEC = new Codec() { + + private final Codec> OPT_STRING_CODEC = + Codec.optionalCodec(Codec.STRING_CODEC); + private final Codec> OPT_COLOR_CODEC = + Codec.optionalCodec(Codec.COLOR_CODEC); + + @Override + public String getName() { + return "text-style"; + } + + @Override + public void encode(DataOutputStream os, TextStyle s) + throws IOException { + os.writeByte(encodeBoldItalicUnderlineStrikethrough(s)); + os.writeInt(encodeOptionalUint(s.fontSize)); + OPT_STRING_CODEC.encode(os, s.fontFamily); + OPT_COLOR_CODEC.encode(os, s.textColor); + OPT_COLOR_CODEC.encode(os, s.backgroundColor); + } + + @Override + public TextStyle decode(DataInputStream is) throws IOException { + byte bius = is.readByte(); + Optional fontSize = decodeOptionalUint(is.readInt()); + Optional fontFamily = OPT_STRING_CODEC.decode(is); + Optional textColor = OPT_COLOR_CODEC.decode(is); + Optional bgrColor = OPT_COLOR_CODEC.decode(is); + return new TextStyle( + bold(bius), italic(bius), underline(bius), strikethrough(bius), + fontSize, fontFamily, textColor, bgrColor); + } + + private int encodeBoldItalicUnderlineStrikethrough(TextStyle s) { + return encodeOptionalBoolean(s.bold) << 6 | + encodeOptionalBoolean(s.italic) << 4 | + encodeOptionalBoolean(s.underline) << 2 | + encodeOptionalBoolean(s.strikethrough); + } + + private Optional bold(byte bius) throws IOException { + return decodeOptionalBoolean((bius >> 6) & 3); + } + + private Optional italic(byte bius) throws IOException { + return decodeOptionalBoolean((bius >> 4) & 3); + } + + private Optional underline(byte bius) throws IOException { + return decodeOptionalBoolean((bius >> 2) & 3); + } + + private Optional strikethrough(byte bius) throws IOException { + return decodeOptionalBoolean((bius >> 0) & 3); + } + + private int encodeOptionalBoolean(Optional ob) { + return ob.map(b -> 2 + (b ? 1 : 0)).orElse(0); + } + + private Optional decodeOptionalBoolean(int i) throws IOException { + switch(i) { + case 0: return Optional.empty(); + case 2: return Optional.of(false); + case 3: return Optional.of(true); + } + throw new MalformedInputException(0); + } + + private int encodeOptionalUint(Optional oi) { + return oi.orElse(-1); + } + + private Optional decodeOptionalUint(int i) { + return (i < 0) ? Optional.empty() : Optional.of(i); + } + }; + + public static TextStyle bold(boolean bold) { return EMPTY.updateBold(bold); } + public static TextStyle italic(boolean italic) { return EMPTY.updateItalic(italic); } + public static TextStyle underline(boolean underline) { return EMPTY.updateUnderline(underline); } + public static TextStyle strikethrough(boolean strikethrough) { return EMPTY.updateStrikethrough(strikethrough); } + public static TextStyle fontSize(int fontSize) { return EMPTY.updateFontSize(fontSize); } + public static TextStyle fontFamily(String family) { return EMPTY.updateFontFamily(family); } + public static TextStyle textColor(Color color) { return EMPTY.updateTextColor(color); } + public static TextStyle backgroundColor(Color color) { return EMPTY.updateBackgroundColor(color); } + + static String cssColor(Color color) { + int red = (int) (color.getRed() * 255); + int green = (int) (color.getGreen() * 255); + int blue = (int) (color.getBlue() * 255); + return "rgb(" + red + ", " + green + ", " + blue + ")"; + } + + final Optional bold; + final Optional italic; + final Optional underline; + final Optional strikethrough; + final Optional fontSize; + final Optional fontFamily; + final Optional textColor; + final Optional backgroundColor; + + public TextStyle() { + this( + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty() + ); + } + + public TextStyle( + Optional bold, + Optional italic, + Optional underline, + Optional strikethrough, + Optional fontSize, + Optional fontFamily, + Optional textColor, + Optional backgroundColor) { + this.bold = bold; + this.italic = italic; + this.underline = underline; + this.strikethrough = strikethrough; + this.fontSize = fontSize; + this.fontFamily = fontFamily; + this.textColor = textColor; + this.backgroundColor = backgroundColor; + } + + @Override + public int hashCode() { + return Objects.hash( + bold, italic, underline, strikethrough, + fontSize, fontFamily, textColor, backgroundColor); + } + + @Override + public boolean equals(Object other) { + if(other instanceof TextStyle) { + TextStyle that = (TextStyle) other; + return Objects.equals(this.bold, that.bold) && + Objects.equals(this.italic, that.italic) && + Objects.equals(this.underline, that.underline) && + Objects.equals(this.strikethrough, that.strikethrough) && + Objects.equals(this.fontSize, that.fontSize) && + Objects.equals(this.fontFamily, that.fontFamily) && + Objects.equals(this.textColor, that.textColor) && + Objects.equals(this.backgroundColor, that.backgroundColor); + } else { + return false; + } + } + + @Override + public String toString() { + List styles = new ArrayList<>(); + + bold .ifPresent(b -> styles.add(b.toString())); + italic .ifPresent(i -> styles.add(i.toString())); + underline .ifPresent(u -> styles.add(u.toString())); + strikethrough .ifPresent(s -> styles.add(s.toString())); + fontSize .ifPresent(s -> styles.add(s.toString())); + fontFamily .ifPresent(f -> styles.add(f.toString())); + textColor .ifPresent(c -> styles.add(c.toString())); + backgroundColor.ifPresent(b -> styles.add(b.toString())); + + return String.join(",", styles); + } + + public String toCss() { + StringBuilder sb = new StringBuilder(); + + if(bold.isPresent()) { + if(bold.get()) { + sb.append("-fx-font-weight: bold;"); + } else { + sb.append("-fx-font-weight: normal;"); + } + } + + if(italic.isPresent()) { + if(italic.get()) { + sb.append("-fx-font-style: italic;"); + } else { + sb.append("-fx-font-style: normal;"); + } + } + + if(underline.isPresent()) { + if(underline.get()) { + sb.append("-fx-underline: true;"); + } else { + sb.append("-fx-underline: false;"); + } + } + + if(strikethrough.isPresent()) { + if(strikethrough.get()) { + sb.append("-fx-strikethrough: true;"); + } else { + sb.append("-fx-strikethrough: false;"); + } + } + + if(fontSize.isPresent()) { + sb.append("-fx-font-size: " + fontSize.get() + "pt;"); + } + + if(fontFamily.isPresent()) { + sb.append("-fx-font-family: " + fontFamily.get() + ";"); + } + + if(textColor.isPresent()) { + Color color = textColor.get(); + sb.append("-fx-fill: " + cssColor(color) + ";"); + } + + if(backgroundColor.isPresent()) { + Color color = backgroundColor.get(); + sb.append("-rtfx-background-color: " + cssColor(color) + ";"); + } + + return sb.toString(); + } + + public TextStyle updateWith(TextStyle mixin) { + return new TextStyle( + mixin.bold.isPresent() ? mixin.bold : bold, + mixin.italic.isPresent() ? mixin.italic : italic, + mixin.underline.isPresent() ? mixin.underline : underline, + mixin.strikethrough.isPresent() ? mixin.strikethrough : strikethrough, + mixin.fontSize.isPresent() ? mixin.fontSize : fontSize, + mixin.fontFamily.isPresent() ? mixin.fontFamily : fontFamily, + mixin.textColor.isPresent() ? mixin.textColor : textColor, + mixin.backgroundColor.isPresent() ? mixin.backgroundColor : backgroundColor); + } + + public TextStyle updateBold(boolean bold) { + return new TextStyle(Optional.of(bold), italic, underline, strikethrough, fontSize, fontFamily, textColor, backgroundColor); + } + + public TextStyle updateItalic(boolean italic) { + return new TextStyle(bold, Optional.of(italic), underline, strikethrough, fontSize, fontFamily, textColor, backgroundColor); + } + + public TextStyle updateUnderline(boolean underline) { + return new TextStyle(bold, italic, Optional.of(underline), strikethrough, fontSize, fontFamily, textColor, backgroundColor); + } + + public TextStyle updateStrikethrough(boolean strikethrough) { + return new TextStyle(bold, italic, underline, Optional.of(strikethrough), fontSize, fontFamily, textColor, backgroundColor); + } + + public TextStyle updateFontSize(int fontSize) { + return new TextStyle(bold, italic, underline, strikethrough, Optional.of(fontSize), fontFamily, textColor, backgroundColor); + } + + public TextStyle updateFontFamily(String fontFamily) { + return new TextStyle(bold, italic, underline, strikethrough, fontSize, Optional.of(fontFamily), textColor, backgroundColor); + } + + public TextStyle updateTextColor(Color textColor) { + return new TextStyle(bold, italic, underline, strikethrough, fontSize, fontFamily, Optional.of(textColor), backgroundColor); + } + + public TextStyle updateBackgroundColor(Color backgroundColor) { + return new TextStyle(bold, italic, underline, strikethrough, fontSize, fontFamily, textColor, Optional.of(backgroundColor)); + } +} \ No newline at end of file diff --git a/richtextfx-demos/src/main/resources/org/fxmisc/richtext/demo/hyperlink/text-hyperlink-area.css b/richtextfx-demos/src/main/resources/org/fxmisc/richtext/demo/hyperlink/text-hyperlink-area.css new file mode 100644 index 000000000..528431a8b --- /dev/null +++ b/richtextfx-demos/src/main/resources/org/fxmisc/richtext/demo/hyperlink/text-hyperlink-area.css @@ -0,0 +1,3 @@ +.hyperlink { + -fx-fill: blue; +} \ No newline at end of file