javafx.scene.text.TextFlow Maven / Gradle / Ivy
/*
* Copyright (c) 2012, 2023, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package javafx.scene.text;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.NodeOrientation;
import javafx.geometry.Orientation;
import javafx.geometry.VPos;
import javafx.scene.AccessibleAttribute;
import javafx.scene.AccessibleRole;
import javafx.scene.Node;
import javafx.scene.layout.Pane;
import javafx.scene.shape.PathElement;
import javafx.css.StyleableDoubleProperty;
import javafx.css.StyleableObjectProperty;
import javafx.css.CssMetaData;
import javafx.css.converter.EnumConverter;
import javafx.css.converter.SizeConverter;
import com.sun.javafx.geom.BaseBounds;
import com.sun.javafx.geom.Point2D;
import com.sun.javafx.geom.RectBounds;
import com.sun.javafx.scene.text.GlyphList;
import com.sun.javafx.scene.text.TextLayout;
import com.sun.javafx.scene.text.TextLayoutFactory;
import com.sun.javafx.scene.text.TextSpan;
import com.sun.javafx.tk.Toolkit;
import javafx.beans.property.IntegerProperty;
import javafx.css.Styleable;
import javafx.css.StyleableIntegerProperty;
import javafx.css.StyleableProperty;
/**
* A specialized layout for rich text.
* It can be used to lay out several {@link Text} nodes in a single text flow.
* {@code TextFlow} uses the text and the font of each {@code Text} node inside of it,
* plus its own width and text alignment to determine the location for each child.
* A single {@code Text} node can span over several lines due to wrapping, and
* the visual location of the {@code Text} node can differ from the logical location
* due to bidi reordering.
*
*
* Any {@code Node} other than {@code Text} will be treated as an embedded object in the
* text layout. It will be inserted in the content using its preferred width,
* height, and baseline offset.
*
*
* When a {@code Text} node is inside a {@code TextFlow}, some of its properties are ignored.
* For example, the {@code x} and {@code y} properties of the {@code Text} node are ignored since
* the location of the node is determined by the parent. Likewise, the wrapping
* width in the {@code Text} node is ignored since the width used for wrapping
* is the {@code TextFlow}'s width. The value of the {@code pickOnBounds} property
* of a {@code Text} node is set to {@code false} when it is laid out by the
* {@code TextFlow}. This happens because the content of a single {@code Text} node can be
* split and placed in different locations in the {@code TextFlow} (usually due to
* line breaking and bidi reordering).
*
*
* The wrapping width of the layout is determined by the region's current width.
* It can be specified by the application by setting the {@code TextFlow}'s preferred
* width. If no wrapping is desired, the application can either set the preferred
* with to {@code Double.MAX_VALUE} or
* {@link javafx.scene.layout.Region#USE_COMPUTED_SIZE Region.USE_COMPUTED_SIZE}.
*
*
* Paragraphs are separated by {@code '\n'} present in any {@code Text} child.
*
*
* Example of a TextFlow:
*
{@code
* Text text1 = new Text("Big italic red text");
* text1.setFill(Color.RED);
* text1.setFont(Font.font("Helvetica", FontPosture.ITALIC, 40));
* Text text2 = new Text(" little bold blue text");
* text2.setFill(Color.BLUE);
* text2.setFont(Font.font("Helvetica", FontWeight.BOLD, 10));
* TextFlow textFlow = new TextFlow(text1, text2);
* }
*
*
* {@code TextFlow} lays out each managed child regardless of the child's visible property value;
* unmanaged children are ignored for all layout calculations.
*
*
* {@code TextFlow} may be styled with backgrounds and borders using CSS. See its
* {@link javafx.scene.layout.Region Region} superclass for details.
*
* Resizable Range
*
*
* A {@code TextFlow}'s parent will resize the {@code TextFlow} within the {@code TextFlow}'s range
* during layout. By default, the {@code TextFlow} computes this range based on its content
* as outlined in the tables below.
*
*
*
* TextFlow Resize Table
* width height
* minimum
* left/right insets
* top/bottom insets plus the height of the text content
* preferred
* left/right insets plus the width of the text content
* top/bottom insets plus the height of the text content
* maximum
* {@code Double.MAX_VALUE} {@code Double.MAX_VALUE}
*
*
* A {@code TextFlow}'s unbounded maximum width and height are an indication to the parent that
* it may be resized beyond its preferred size to fill whatever space is assigned to it.
*
* {@code TextFlow} provides properties for setting the size range directly. These
* properties default to the sentinel value {@code Region.USE_COMPUTED_SIZE}, however the
* application may set them to other values as needed:
*
* textflow.setMaxWidth(500);
*
* Applications may restore the computed values by setting these properties back
* to {@code Region.USE_COMPUTED_SIZE}.
*
* {@code TextFlow} does not clip its content by default, so it is possible that children's
* bounds may extend outside of its own bounds if a child's preferred size is larger than
* the space the {@code TextFlow} has to allocate for it.
*
* @since JavaFX 8.0
*/
public class TextFlow extends Pane {
private TextLayout layout;
private boolean needsContent;
private boolean inLayout;
/**
* Creates an empty TextFlow layout.
*/
public TextFlow() {
super();
effectiveNodeOrientationProperty().addListener(observable -> checkOrientation());
setAccessibleRole(AccessibleRole.TEXT);
}
/**
* Creates a TextFlow layout with the given children.
*
* @param children children.
*/
public TextFlow(Node... children) {
this();
getChildren().addAll(children);
}
private void checkOrientation() {
NodeOrientation orientation = getEffectiveNodeOrientation();
boolean rtl = orientation == NodeOrientation.RIGHT_TO_LEFT;
int dir = rtl ? TextLayout.DIRECTION_RTL : TextLayout.DIRECTION_LTR;
TextLayout layout = getTextLayout();
if (layout.setDirection(dir)) {
requestLayout();
}
}
/**
* Maps local point to {@link HitInfo} in the content.
*
* @param point the specified point to be tested
* @return a {@code HitInfo} representing the character index found
* @since 9
*/
public final HitInfo hitTest(javafx.geometry.Point2D point) {
if (point != null) {
TextLayout layout = getTextLayout();
double x = point.getX();
double y = point.getY();
TextLayout.Hit h = layout.getHitInfo((float)x, (float)y, null, 0, 0);
return new HitInfo(h.getCharIndex(), h.getInsertionIndex(), h.isLeading());
} else {
return null;
}
}
/**
* Returns shape of caret in local coordinates.
*
* @param charIndex the character index for the caret
* @param leading whether the caret is biased on the leading edge of the character
* @return an array of {@code PathElement} which can be used to create a {@code Shape}
* @since 9
*/
public PathElement[] caretShape(int charIndex, boolean leading) {
return getTextLayout().getCaretShape(charIndex, leading, 0, 0);
}
/**
* Returns shape for the range of the text in local coordinates.
*
* @param start the beginning character index for the range
* @param end the end character index (non-inclusive) for the range
* @return an array of {@code PathElement} which can be used to create a {@code Shape}
* @since 9
*/
public final PathElement[] rangeShape(int start, int end) {
return getRange(start, end, TextLayout.TYPE_TEXT);
}
/**
* Returns the shape for the underline in local coordinates.
*
* @param start the beginning character index for the range
* @param end the end character index (non-inclusive) for the range
* @return an array of {@code PathElement} which can be used to create a {@code Shape}
* @since 21
*/
public final PathElement[] underlineShape(int start, int end) {
return getRange(start, end, TextLayout.TYPE_UNDERLINE);
}
@Override
public boolean usesMirroring() {
return false;
}
@Override protected void setWidth(double value) {
if (value != getWidth()) {
TextLayout layout = getTextLayout();
Insets insets = getInsets();
double left = snapSpaceX(insets.getLeft());
double right = snapSpaceX(insets.getRight());
double width = Math.max(1, value - left - right);
layout.setWrapWidth((float)width);
super.setWidth(value);
}
}
@Override protected double computePrefWidth(double height) {
TextLayout layout = getTextLayout();
layout.setWrapWidth(0);
double width = layout.getBounds().getWidth();
Insets insets = getInsets();
double left = snapSpaceX(insets.getLeft());
double right = snapSpaceX(insets.getRight());
double wrappingWidth = Math.max(1, getWidth() - left - right);
layout.setWrapWidth((float)wrappingWidth);
return left + width + right;
}
@Override protected double computePrefHeight(double width) {
TextLayout layout = getTextLayout();
Insets insets = getInsets();
double left = snapSpaceX(insets.getLeft());
double right = snapSpaceX(insets.getRight());
if (width == USE_COMPUTED_SIZE) {
layout.setWrapWidth(0);
} else {
double wrappingWidth = Math.max(1, width - left - right);
layout.setWrapWidth((float)wrappingWidth);
}
double height = layout.getBounds().getHeight();
double wrappingWidth = Math.max(1, getWidth() - left - right);
layout.setWrapWidth((float)wrappingWidth);
double top = snapSpaceY(insets.getTop());
double bottom = snapSpaceY(insets.getBottom());
return top + height + bottom;
}
@Override protected double computeMinHeight(double width) {
return computePrefHeight(width);
}
@Override public void requestLayout() {
/* The geometry of text nodes can be changed during layout children.
* For that reason it has to call NodeHelper.geomChanged(this) causing
* requestLayout() to happen during layoutChildren().
* The inLayout flag prevents this call to cause any extra work.
*/
if (inLayout) return;
/*
* There is no need to reset the text layout's content every time
* requestLayout() is called. For example, the content needs
* to be set when:
* children add or removed
* children managed state changes
* children geomChanged (width/height of embedded node)
* children content changes (text/font of text node)
* The content does not need to set when:
* the width/height changes in the region
* the insets changes in the region
*
* Unfortunately, it is not possible to know what change invoked request
* layout. The solution is to always reset the content in the text
* layout and rely on it to preserve itself if the new content equals to
* the old one. The cost to generate the new content is not avoid.
*/
needsContent = true;
super.requestLayout();
}
@Override public Orientation getContentBias() {
return Orientation.HORIZONTAL;
}
@Override protected void layoutChildren() {
inLayout = true;
Insets insets = getInsets();
double top = snapSpaceY(insets.getTop());
double left = snapSpaceX(insets.getLeft());
GlyphList[] runs = getTextLayout().getRuns();
for (int j = 0; j < runs.length; j++) {
GlyphList run = runs[j];
TextSpan span = run.getTextSpan();
if (span instanceof EmbeddedSpan) {
Node child = ((EmbeddedSpan)span).getNode();
Point2D location = run.getLocation();
double baselineOffset = -run.getLineBounds().getMinY();
layoutInArea(child, left + location.x, top + location.y,
run.getWidth(), run.getHeight(),
baselineOffset, null, true, true,
HPos.CENTER, VPos.BASELINE);
}
}
List managed = getManagedChildren();
for (Node node: managed) {
if (node instanceof Text) {
Text text = (Text)node;
text.layoutSpan(runs);
BaseBounds spanBounds = text.getSpanBounds();
text.relocate(left + spanBounds.getMinX(),
top + spanBounds.getMinY());
}
}
inLayout = false;
}
private PathElement[] getRange(int start, int end, int type) {
TextLayout layout = getTextLayout();
return layout.getRange(start, end, type, 0, 0);
}
private static class EmbeddedSpan implements TextSpan {
RectBounds bounds;
Node node;
public EmbeddedSpan(Node node, double baseline, double width, double height) {
this.node = node;
bounds = new RectBounds(0, (float)-baseline,
(float)width, (float)(height - baseline));
}
@Override public String getText() {
return "\uFFFC";
}
@Override public Object getFont() {
return null;
}
@Override public RectBounds getBounds() {
return bounds;
}
public Node getNode() {
return node;
}
}
TextLayout getTextLayout() {
if (layout == null) {
TextLayoutFactory factory = Toolkit.getToolkit().getTextLayoutFactory();
layout = factory.createLayout();
layout.setTabSize(getTabSize());
needsContent = true;
}
if (needsContent) {
List children = getManagedChildren();
TextSpan[] spans = new TextSpan[children.size()];
for (int i = 0; i < spans.length; i++) {
Node node = children.get(i);
if (node instanceof Text) {
spans[i] = ((Text)node).getTextSpan();
} else {
/* Creating a text span every time forces text layout
* to run a full text analysis in the new content.
*/
double baseline = node.getBaselineOffset();
if (baseline == BASELINE_OFFSET_SAME_AS_HEIGHT) {
baseline = node.getLayoutBounds().getHeight();
}
double width = computeChildPrefAreaWidth(node, null);
double height = computeChildPrefAreaHeight(node, null);
spans[i] = new EmbeddedSpan(node, baseline, width, height);
}
}
layout.setContent(spans);
needsContent = false;
}
return layout;
}
/**
* Defines horizontal text alignment.
*
* @defaultValue TextAlignment.LEFT
*/
private ObjectProperty textAlignment;
public final void setTextAlignment(TextAlignment value) {
textAlignmentProperty().set(value);
}
public final TextAlignment getTextAlignment() {
return textAlignment == null ? TextAlignment.LEFT : textAlignment.get();
}
public final ObjectProperty textAlignmentProperty() {
if (textAlignment == null) {
textAlignment =
new StyleableObjectProperty(TextAlignment.LEFT) {
@Override public Object getBean() { return TextFlow.this; }
@Override public String getName() { return "textAlignment"; }
@Override public CssMetaData getCssMetaData() {
return StyleableProperties.TEXT_ALIGNMENT;
}
@Override public void invalidated() {
TextAlignment align = get();
if (align == null) align = TextAlignment.LEFT;
TextLayout layout = getTextLayout();
layout.setAlignment(align.ordinal());
requestLayout();
}
};
}
return textAlignment;
}
/**
* Defines the vertical space in pixel between lines.
*
* @defaultValue 0
*
* @since JavaFX 8.0
*/
private DoubleProperty lineSpacing;
public final void setLineSpacing(double spacing) {
lineSpacingProperty().set(spacing);
}
public final double getLineSpacing() {
return lineSpacing == null ? 0 : lineSpacing.get();
}
public final DoubleProperty lineSpacingProperty() {
if (lineSpacing == null) {
lineSpacing =
new StyleableDoubleProperty(0) {
@Override public Object getBean() { return TextFlow.this; }
@Override public String getName() { return "lineSpacing"; }
@Override public CssMetaData getCssMetaData() {
return StyleableProperties.LINE_SPACING;
}
@Override public void invalidated() {
TextLayout layout = getTextLayout();
if (layout.setLineSpacing((float)get())) {
requestLayout();
}
}
};
}
return lineSpacing;
}
/**
* The size of a tab stop in spaces.
* Values less than 1 are treated as 1. This value overrides the
* {@code tabSize} of contained {@link Text} nodes.
*
* @defaultValue 8
*
* @since 14
*/
private IntegerProperty tabSize;
public final IntegerProperty tabSizeProperty() {
if (tabSize == null) {
tabSize = new StyleableIntegerProperty(TextLayout.DEFAULT_TAB_SIZE) {
@Override public Object getBean() { return TextFlow.this; }
@Override public String getName() { return "tabSize"; }
@Override public CssMetaData getCssMetaData() {
return StyleableProperties.TAB_SIZE;
}
@Override protected void invalidated() {
TextLayout layout = getTextLayout();
if (layout.setTabSize(get())) {
requestLayout();
}
}
};
}
return tabSize;
}
public final int getTabSize() {
return tabSize == null ? TextLayout.DEFAULT_TAB_SIZE : tabSize.get();
}
public final void setTabSize(int spaces) {
tabSizeProperty().set(spaces);
}
@Override public final double getBaselineOffset() {
Insets insets = getInsets();
double top = snapSpaceY(insets.getTop());
return top - getTextLayout().getBounds().getMinY();
}
/* *************************************************************************
* *
* Stylesheet Handling *
* *
**************************************************************************/
/*
* Super-lazy instantiation pattern from Bill Pugh.
*/
private static class StyleableProperties {
private static final
CssMetaData TEXT_ALIGNMENT =
new CssMetaData<>("-fx-text-alignment",
new EnumConverter<>(TextAlignment.class),
TextAlignment.LEFT) {
@Override public boolean isSettable(TextFlow node) {
return node.textAlignment == null || !node.textAlignment.isBound();
}
@Override public StyleableProperty getStyleableProperty(TextFlow node) {
return (StyleableProperty)node.textAlignmentProperty();
}
};
private static final
CssMetaData LINE_SPACING =
new CssMetaData<>("-fx-line-spacing",
SizeConverter.getInstance(), 0) {
@Override public boolean isSettable(TextFlow node) {
return node.lineSpacing == null || !node.lineSpacing.isBound();
}
@Override public StyleableProperty getStyleableProperty(TextFlow node) {
return (StyleableProperty)node.lineSpacingProperty();
}
};
private static final CssMetaData TAB_SIZE =
new CssMetaData<>("-fx-tab-size",
SizeConverter.getInstance(), TextLayout.DEFAULT_TAB_SIZE) {
@Override
public boolean isSettable(TextFlow node) {
return node.tabSize == null || !node.tabSize.isBound();
}
@Override
public StyleableProperty getStyleableProperty(TextFlow node) {
return (StyleableProperty)node.tabSizeProperty();
}
};
private static final List> STYLEABLES;
static {
final List> styleables =
new ArrayList<>(Pane.getClassCssMetaData());
styleables.add(TEXT_ALIGNMENT);
styleables.add(LINE_SPACING);
styleables.add(TAB_SIZE);
STYLEABLES = Collections.unmodifiableList(styleables);
}
}
/**
* Gets the {@code CssMetaData} associated with this class, which may include the
* {@code CssMetaData} of its superclasses.
* @return the {@code CssMetaData}
*/
public static List> getClassCssMetaData() {
return StyleableProperties.STYLEABLES;
}
@Override
public List> getCssMetaData() {
return getClassCssMetaData();
}
/* The methods in this section are copied from Region due to package visibility restriction */
static double boundedSize(double min, double pref, double max) {
double a = pref >= min ? pref : min;
double b = min >= max ? min : max;
return a <= b ? a : b;
}
double computeChildPrefAreaWidth(Node child, Insets margin) {
return computeChildPrefAreaWidth(child, margin, -1);
}
double computeChildPrefAreaWidth(Node child, Insets margin, double height) {
double top = margin != null? snapSpaceY(margin.getTop()) : 0;
double bottom = margin != null? snapSpaceY(margin.getBottom()) : 0;
double left = margin != null? snapSpaceX(margin.getLeft()) : 0;
double right = margin != null? snapSpaceX(margin.getRight()) : 0;
double alt = -1;
if (child.getContentBias() == Orientation.VERTICAL) { // width depends on height
alt = snapSizeY(boundedSize(
child.minHeight(-1), height != -1? height - top - bottom :
child.prefHeight(-1), child.maxHeight(-1)));
}
return left + snapSizeX(boundedSize(child.minWidth(alt), child.prefWidth(alt), child.maxWidth(alt))) + right;
}
double computeChildPrefAreaHeight(Node child, Insets margin) {
return computeChildPrefAreaHeight(child, margin, -1);
}
double computeChildPrefAreaHeight(Node child, Insets margin, double width) {
double top = margin != null? snapSpaceY(margin.getTop()) : 0;
double bottom = margin != null? snapSpaceY(margin.getBottom()) : 0;
double left = margin != null? snapSpaceX(margin.getLeft()) : 0;
double right = margin != null? snapSpaceX(margin.getRight()) : 0;
double alt = -1;
if (child.getContentBias() == Orientation.HORIZONTAL) { // height depends on width
alt = snapSizeX(boundedSize(
child.minWidth(-1), width != -1? width - left - right :
child.prefWidth(-1), child.maxWidth(-1)));
}
return top + snapSizeY(boundedSize(child.minHeight(alt), child.prefHeight(alt), child.maxHeight(alt))) + bottom;
}
/* end of copied code */
/** {@inheritDoc} */
@Override
public Object queryAccessibleAttribute(AccessibleAttribute attribute, Object... parameters) {
switch (attribute) {
case TEXT: {
String accText = getAccessibleText();
if (accText != null && !accText.isEmpty()) return accText;
StringBuilder title = new StringBuilder();
for (Node node: getChildren()) {
Object text = node.queryAccessibleAttribute(AccessibleAttribute.TEXT, parameters);
if (text != null) {
title.append(text.toString());
}
}
return title.toString();
}
default: return super.queryAccessibleAttribute(attribute, parameters);
}
}
}