diff --git a/modules/javafx.graphics/src/main/java/com/sun/javafx/text/PrismTextLayout.java b/modules/javafx.graphics/src/main/java/com/sun/javafx/text/PrismTextLayout.java index 71d76a21234..3cbbd1ce207 100644 --- a/modules/javafx.graphics/src/main/java/com/sun/javafx/text/PrismTextLayout.java +++ b/modules/javafx.graphics/src/main/java/com/sun/javafx/text/PrismTextLayout.java @@ -422,12 +422,14 @@ public PathElement[] getCaretShape(int offset, boolean isLeading, @Override public Hit getHitInfo(float x, float y) { int charIndex = -1; + int insertionIndex = -1; boolean leading = false; ensureLayout(); int lineIndex = getLineIndex(y); if (lineIndex >= getLineCount()) { charIndex = getCharCount(); + insertionIndex = charIndex + 1; } else { if (isMirrored()) { x = getMirroringWidth() - x; @@ -450,13 +452,30 @@ public Hit getHitInfo(float x, float y) { int[] trailing = new int[1]; charIndex = run.getStart() + run.getOffsetAtX(x, trailing); leading = (trailing[0] == 0); + + insertionIndex = charIndex; + if (getText() != null && insertionIndex < getText().length) { + if (!leading) { + BreakIterator charIterator = BreakIterator.getCharacterInstance(); + charIterator.setText(new String(getText())); + int next = charIterator.following(insertionIndex); + if (next == BreakIterator.DONE) { + insertionIndex += 1; + } else { + insertionIndex = next; + } + } + } else if (!leading) { + insertionIndex += 1; + } } else { //empty line, set to line break leading charIndex = line.getStart(); leading = true; + insertionIndex = charIndex; } } - return new Hit(charIndex, -1, leading); + return new Hit(charIndex, insertionIndex, leading); } @Override diff --git a/tests/system/src/test/java/test/robot/javafx/scene/TextFlowSurrogatePairInsertionIndexTest.java b/tests/system/src/test/java/test/robot/javafx/scene/TextFlowSurrogatePairInsertionIndexTest.java new file mode 100644 index 00000000000..d2b71c96f20 --- /dev/null +++ b/tests/system/src/test/java/test/robot/javafx/scene/TextFlowSurrogatePairInsertionIndexTest.java @@ -0,0 +1,355 @@ +/* + * Copyright (c) 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 test.robot.javafx.scene; + +import java.util.concurrent.CountDownLatch; + +import javafx.application.Application; +import javafx.application.Platform; +import javafx.geometry.Point2D; +import javafx.scene.Scene; +import javafx.scene.input.MouseEvent; +import javafx.scene.input.MouseButton; +import javafx.scene.robot.Robot; +import javafx.scene.text.Font; +import javafx.scene.text.HitInfo; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; +import javafx.stage.Stage; +import javafx.stage.StageStyle; + +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import com.sun.javafx.PlatformUtil; +import com.sun.javafx.tk.Toolkit; + +import test.util.Util; + +/* + * Test for verifying insertion index in TextFlow surrogate pairs + * + * There are 4 tests in this file. + * Steps for testTextFlowInsertionIndexUsingTwoEmojis() + * 1. Create a TextFlow. Add Text node with surrogate pair to it. + * 2. Move the cursor and click on the leading side of a surrogate pair text. + * 3. Insertion index should be same as character index. + * 4. Move the cursor and click on the trailing side of a surrogate pair text. + * 5. Insertion index should 2 more than the character index. + * + * Steps for testTextFlowInsertionIndexUsingMultipleEmojis() + * 1. Create a TextFlow. Add Text node with multiple emojis (surrogate pairs). + * 2. Move the cursor to the first character and click. + * 3. Insertion index should be same as character index. + * 4. Move the cursor continously till last character and check that + * character index and insertion index increase monitonically as expected. + * + * Steps for testTextFlowInsertionIndexUsingTextAndEmojis() + * 1. Create a TextFlow. Add Text node with multiple emojis (surrogate pairs). + * 2. Move the cursor to the first character and click. + * 3. Insertion index should be same as character index. + * 4. Move the cursor continously till last character and check that + * character index and insertion index increase monitonically as expected. + * + * Steps for testTextFlowInsertionIndexUsingEmbeddedTextNodes() + * 1. Create a TextFlow. Add a Text node with text and another with emojis. + * 2. Move the cursor to the first character and click. + * 3. Insertion index should be same as character index. + * 4. Move the cursor continously till last character and check that + * character index and insertion index increase monitonically as expected. + * + * Steps for testTextFlowInsertionIndexWhenMouseMovedOutsideText() + * 1. Create a TextFlow. Add a Text node with text and emojis. + * 2. Move the cursor to the first character and click. + * 3. Insertion index should be same as character index. + * 4. Move the cursor towards bottom of the application window check that + * chracter index and insertion index are as expected. + * This test is implemented to test insertion index initialization + * when text run is not used to calculate character index. + * + * Steps for testTextFlowInsertionIndexUsingWrappedText() + * 1. Create a TextFlow. Add a Text node with text and emojis whose length is + * more than the size of application window. + * 2. Move the cursor from first character to last character. + * 3. Character index should increase monotonically as expected. + * 4. Insertion index should also increase as expected along with character index. + */ + +public class TextFlowSurrogatePairInsertionIndexTest { + static CountDownLatch startupLatch = new CountDownLatch(1); + static Robot robot; + static TextFlow textFlow; + static Text text; + static Text emoji; + + static volatile Stage stage; + static volatile Scene scene; + + static final int WIDTH = 500; + static final int HEIGHT = 150; + + final int Y_OFFSET = 25; + final int X_LEADING_OFFSET = 10; + final int X_TRAILING_OFFSET = 40; + + boolean isLeading; + boolean isSurrogatePair; + int charIndex; + int insertionIndex; + + private void mouseClick(double x, double y) { + Util.runAndWait(() -> { + robot.mouseMove((int) (scene.getWindow().getX() + scene.getX() + x), + (int) (scene.getWindow().getY() + scene.getY() + y)); + robot.mouseClick(MouseButton.PRIMARY); + }); + } + + private void moveMouseOverTextFlow(int x, int y) throws Exception { + mouseClick(textFlow.getLayoutX() + x, + textFlow.getLayoutY() + y); + } + + private void addTwoEmojis() { + Util.runAndWait(() -> { + text = new Text("😊😇"); + text.setFont(new Font(48)); + textFlow.getChildren().clear(); + textFlow.getChildren().setAll(text); + }); + } + + private void addMultipleEmojis() { + Util.runAndWait(() -> { + text = new Text("😊😇💙🦋🏁🔥"); + text.setFont(new Font(48)); + textFlow.getChildren().clear(); + textFlow.getChildren().setAll(text); + }); + } + + private void addTextAndEmojis() { + Util.runAndWait(() -> { + text = new Text("Text 😊😇💙🦋🔥"); + text.setFont(new Font(48)); + textFlow.getChildren().clear(); + textFlow.getChildren().setAll(text); + }); + } + + private void addTwoTextNodes() { + Util.runAndWait(() -> { + text = new Text("Text"); + text.setFont(new Font(48)); + + emoji = new Text("😊😇"); + emoji.setFont(new Font(48)); + + textFlow.getChildren().clear(); + textFlow.getChildren().setAll(text, emoji); + }); + } + + private void addLongText() { + Util.runAndWait(() -> { + text = new Text("[This is text 😀😃😄😁😆 🙂🙃😉😊😇]"); + text.setFont(new Font(48)); + + textFlow.getChildren().clear(); + textFlow.getChildren().setAll(text); + }); + } + + @Test + public void testTextFlowInsertionIndexUsingTwoEmojis() throws Exception { + addTwoEmojis(); + Util.waitForIdle(scene); + + moveMouseOverTextFlow(X_LEADING_OFFSET, Y_OFFSET); + Assert.assertTrue(isLeading); + Assert.assertEquals(charIndex, insertionIndex); + + moveMouseOverTextFlow(X_TRAILING_OFFSET, Y_OFFSET); + Assert.assertFalse(isLeading); + Assert.assertEquals(charIndex, insertionIndex - 2); + } + + @Test + public void testTextFlowInsertionIndexUsingMultipleEmojis() throws Exception { + addMultipleEmojis(); + Util.waitForIdle(scene); + + int textLength = text.getText().length(); + int index = 0; + while (charIndex < textLength - 2) { + moveMouseOverTextFlow(index, Y_OFFSET); + if (isLeading) { + Assert.assertEquals(charIndex, insertionIndex); + } else { + Assert.assertEquals(charIndex, insertionIndex - 2); + } + index += 5; + } + } + + @Test + public void testTextFlowInsertionIndexUsingTextAndEmojis() throws Exception { + addTextAndEmojis(); + Util.waitForIdle(scene); + + int textLength = text.getText().length(); + int index = 0; + while (charIndex < textLength - 2) { + moveMouseOverTextFlow(index, Y_OFFSET); + if (isLeading) { + Assert.assertEquals(charIndex, insertionIndex); + } else if (!isLeading && charIndex < 5) { + Assert.assertEquals(charIndex, insertionIndex - 1); + } else { + Assert.assertEquals(charIndex, insertionIndex - 2); + } + index += 5; + } + } + + @Test + public void testTextFlowInsertionIndexUsingEmbeddedTextNodes() throws Exception { + addTwoTextNodes(); + Util.waitForIdle(scene); + + int textLength = text.getText().length(); + textLength += emoji.getText().length(); + int index = 0; + while (charIndex < textLength - 2) { + moveMouseOverTextFlow(index, Y_OFFSET); + if (isLeading) { + Assert.assertEquals(charIndex, insertionIndex); + } else if (isSurrogatePair) { + Assert.assertEquals(charIndex, insertionIndex - 2); + } else { + Assert.assertEquals(charIndex, insertionIndex - 1); + } + index += 5; + } + } + + @Test + public void testTextFlowInsertionIndexWhenMouseMovedOutsideText() throws Exception { + addTextAndEmojis(); + Util.waitForIdle(scene); + + int index = 0; + while (index < (HEIGHT - Y_OFFSET)) { + moveMouseOverTextFlow(X_LEADING_OFFSET, (Y_OFFSET + index)); + if (isLeading) { + Assert.assertEquals(charIndex, insertionIndex); + } else { + Assert.assertEquals(charIndex, insertionIndex - 1); + } + index += 5; + } + } + + @Test + public void testTextFlowInsertionIndexUsingWrappedText() throws Exception { + addLongText(); + Util.waitForIdle(scene); + + for (int y = 0; y < 2; y++) { + for (int x = 0; x < (WIDTH - X_LEADING_OFFSET); x += 5) { + moveMouseOverTextFlow(x, (Y_OFFSET + (Y_OFFSET * (y * 2)))); + if (isLeading) { + Assert.assertEquals(charIndex, insertionIndex); + } else if (isSurrogatePair) { + Assert.assertEquals(charIndex, insertionIndex - 2); + } else { + Assert.assertEquals(charIndex, insertionIndex - 1); + } + } + } + } + + private void handleMouseEvent(MouseEvent event) { + Point2D point = new Point2D(event.getX(), event.getY()); + HitInfo hitInfo = textFlow.hitTest(point); + isLeading = hitInfo.isLeading(); + charIndex = hitInfo.getCharIndex(); + insertionIndex = hitInfo.getInsertionIndex(); + + String testString = text.getText(); + if (charIndex >= testString.length() && emoji != null) { + testString += emoji.getText(); + } + if (charIndex < testString.length()) { + char c = testString.charAt(charIndex); + isSurrogatePair = Character.isSurrogate(c); + } + } + + @After + public void resetUI() { + Platform.runLater(() -> { + textFlow.removeEventHandler(MouseEvent.MOUSE_PRESSED, this::handleMouseEvent); + }); + } + + @Before + public void setupUI() { + Platform.runLater(() -> { + textFlow.addEventHandler(MouseEvent.MOUSE_PRESSED, this::handleMouseEvent); + }); + } + + @BeforeClass + public static void initFX() { + Util.launch(startupLatch, TestApp.class); + } + + @AfterClass + public static void exit() { + Util.shutdown(stage); + } + + public static class TestApp extends Application { + @Override + public void start(Stage primaryStage) { + robot = new Robot(); + stage = primaryStage; + + textFlow = new TextFlow(); + scene = new Scene(textFlow, WIDTH, HEIGHT); + stage.setScene(scene); + stage.initStyle(StageStyle.UNDECORATED); + stage.setOnShown(event -> Platform.runLater(startupLatch::countDown)); + stage.setAlwaysOnTop(true); + stage.show(); + } + } +} diff --git a/tests/system/src/test/java/test/util/Util.java b/tests/system/src/test/java/test/util/Util.java index 03e6e292fb7..6af57d800a3 100644 --- a/tests/system/src/test/java/test/util/Util.java +++ b/tests/system/src/test/java/test/util/Util.java @@ -43,6 +43,7 @@ import javafx.geometry.Rectangle2D; import javafx.scene.Node; import javafx.scene.robot.Robot; +import javafx.scene.Scene; import javafx.stage.Screen; import javafx.stage.Stage; @@ -424,6 +425,38 @@ public static void parkCursor(Robot robot) { } } + /** + * Triggers and waits for 10 pulses to complete in the specified scene. + */ + public static void waitForIdle(Scene scene) { + waitForIdle(scene, 10); + } + + /** + * Triggers and waits for specified number of pulses (pulseCount) + * to complete in the specified scene. + */ + public static void waitForIdle(Scene scene, int pulseCount) { + CountDownLatch latch = new CountDownLatch(pulseCount); + Runnable pulseListener = () -> { + latch.countDown(); + Platform.requestNextPulse(); + }; + + runAndWait(() -> { + scene.addPostLayoutPulseListener(pulseListener); + }); + + try { + Platform.requestNextPulse(); + waitForLatch(latch, TIMEOUT, "Timeout waiting for post layout pulse"); + } finally { + runAndWait(() -> { + scene.removePostLayoutPulseListener(pulseListener); + }); + } + } + /** returns true if scaleX of the specified Node is not integer */ public static boolean isFractionalScaleX(Node n) { double scale = n.getScene().getWindow().getRenderScaleX();