diff --git a/.github/assets/EmojiDemo.png b/.github/assets/EmojiDemo.png index db720e4..9b015f7 100644 Binary files a/.github/assets/EmojiDemo.png and b/.github/assets/EmojiDemo.png differ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d0dc3ad..c5254a7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,6 +24,8 @@ jobs: - name: Build project run: | + export DISPLAY=:90 + Xvfb -ac :90 -screen 0 1280x1024x24 > /dev/null 2>&1 & mvn -B -ntp verify -f rta - name: Publish Snapshots diff --git a/rta/pom.xml b/rta/pom.xml index 9f6f6ad..7d6c644 100644 --- a/rta/pom.xml +++ b/rta/pom.xml @@ -37,6 +37,11 @@ 5.9.2 test + + org.testfx + testfx-junit5 + 4.0.18 + @@ -135,7 +140,11 @@ org.apache.maven.plugins maven-surefire-plugin - 3.0.0-M5 + 3.0.0-M7 + + false + --add-opens javafx.graphics/com.sun.javafx.application=ALL-UNNAMED + org.junit.jupiter diff --git a/rta/src/test/java/com/gluonhq/richtextarea/ui/RTATest.java b/rta/src/test/java/com/gluonhq/richtextarea/ui/RTATest.java new file mode 100644 index 0000000..911f68f --- /dev/null +++ b/rta/src/test/java/com/gluonhq/richtextarea/ui/RTATest.java @@ -0,0 +1,383 @@ +/* + * Copyright (c) 2024, Gluon + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL GLUON BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.gluonhq.richtextarea.ui; + +import com.gluonhq.emoji.Emoji; +import com.gluonhq.emoji.EmojiData; +import com.gluonhq.emoji.EmojiSkinTone; +import com.gluonhq.richtextarea.RichTextArea; +import com.gluonhq.richtextarea.Selection; +import com.gluonhq.richtextarea.action.TextDecorateAction; +import com.gluonhq.richtextarea.model.DecorationModel; +import com.gluonhq.richtextarea.model.Document; +import com.gluonhq.richtextarea.model.ParagraphDecoration; +import com.gluonhq.richtextarea.model.PieceTable; +import com.gluonhq.richtextarea.model.TextDecoration; +import javafx.application.Platform; +import javafx.event.ActionEvent; +import javafx.scene.Scene; +import javafx.scene.control.ToggleButton; +import javafx.scene.image.ImageView; +import javafx.scene.input.KeyCodeCombination; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.text.Text; +import javafx.stage.Stage; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testfx.api.FxRobot; +import org.testfx.framework.junit5.ApplicationExtension; +import org.testfx.framework.junit5.Init; +import org.testfx.framework.junit5.Start; +import org.testfx.matcher.base.NodeMatchers; + +import java.nio.CharBuffer; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import static javafx.scene.input.KeyCode.A; +import static javafx.scene.input.KeyCombination.SHORTCUT_DOWN; +import static javafx.scene.text.FontPosture.ITALIC; +import static javafx.scene.text.FontPosture.REGULAR; +import static javafx.scene.text.FontWeight.BOLD; +import static javafx.scene.text.FontWeight.NORMAL; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.testfx.api.FxAssert.verifyThat; +import static org.testfx.util.WaitForAsyncUtils.sleep; +import static org.testfx.util.WaitForAsyncUtils.waitForFxEvents; + +@ExtendWith(ApplicationExtension.class) +public class RTATest { + + private static boolean fxStarted; + private BorderPane root; + private RichTextArea richTextArea; + + @BeforeEach + public void setup() { + if (!fxStarted) { + try { + Platform.startup(() -> fxStarted = true); + } catch (IllegalStateException e) { + // Platform already initialized + Platform.runLater(() -> fxStarted = true); + } + } + } + + @Init + public void init() { + richTextArea = new RichTextArea(); + root = new BorderPane(richTextArea); + } + + @Start + public void start(Stage stage) { + Scene scene = new Scene(root, 640, 480); + stage.setScene(scene); + stage.setTitle("RichTextArea"); + stage.show(); + } + + @Test + public void basicTest(FxRobot robot) { + verifyThat(".rich-text-area", node -> node instanceof RichTextArea); + verifyThat(".rich-text-area", NodeMatchers.isFocused()); + RichTextArea rta = robot.lookup(".rich-text-area").query(); + assertEquals(0, rta.getTextLength()); + assertEquals(0, rta.getCaretPosition()); + assertNotNull(rta.getDocument()); + assertEquals(0, rta.getDocument().getCaretPosition()); + assertEquals("", rta.getDocument().getText()); + } + + @Test + public void basicPromptDemoTest(FxRobot robot) { + run(() -> richTextArea.setPromptText("Type something!")); + waitForFxEvents(); + verifyThat(".rich-text-area", node -> node instanceof RichTextArea); + verifyThat(".rich-text-area", NodeMatchers.isFocused()); + RichTextArea rta = robot.lookup(".rich-text-area").query(); + assertEquals(0, rta.getTextLength()); + assertEquals(0, rta.getCaretPosition()); + assertNotNull(rta.getDocument()); + assertEquals(0, rta.getDocument().getCaretPosition()); + assertEquals("", rta.getDocument().getText()); + verifyThat(".prompt", node -> node instanceof Text && + "Type something!".equals(((Text) node).getText())); + } + + @Test + public void basicDocumentDemoTest(FxRobot robot) { + run(() -> { + String text = "Hello RTA"; + TextDecoration textDecoration = TextDecoration.builder().presets() + .fontFamily("Arial") + .fontSize(20) + .foreground("red") + .build(); + ParagraphDecoration paragraphDecoration = ParagraphDecoration.builder().presets().build(); + DecorationModel decorationModel = new DecorationModel(0, text.length(), textDecoration, paragraphDecoration); + Document document = new Document(text, List.of(decorationModel), text.length()); + richTextArea.getActionFactory().open(document).execute(new ActionEvent()); + }); + waitForFxEvents(); + + verifyThat(".rich-text-area", node -> node instanceof RichTextArea); + verifyThat(".rich-text-area", NodeMatchers.isFocused()); + RichTextArea rta = robot.lookup(".rich-text-area").query(); + assertEquals(9, rta.getTextLength()); + assertEquals(9, rta.getCaretPosition()); + assertNotNull(rta.getDocument()); + assertEquals(9, rta.getDocument().getCaretPosition()); + assertEquals("Hello RTA", rta.getDocument().getText()); + assertNotNull(rta.getDocument().getDecorations()); + assertEquals(1, rta.getDocument().getDecorations().size()); + assertInstanceOf(TextDecoration.class, rta.getDocument().getDecorations().get(0).getDecoration()); + TextDecoration td = (TextDecoration) rta.getDocument().getDecorations().get(0).getDecoration(); + assertEquals("red", td.getForeground()); + assertEquals("transparent", td.getBackground()); + assertEquals("Arial", td.getFontFamily()); + assertEquals(20, td.getFontSize()); + assertEquals(NORMAL, td.getFontWeight()); + assertEquals(REGULAR, td.getFontPosture()); + } + + @Test + public void actionsDemoTest(FxRobot robot) { + run(() -> { + String text = "Document is the basic model that contains all the information required"; + TextDecoration textDecoration = TextDecoration.builder().presets() + .fontFamily("Arial") + .fontSize(14) + .build(); + ParagraphDecoration paragraphDecoration = ParagraphDecoration.builder().presets().build(); + DecorationModel decorationModel = new DecorationModel(0, text.length(), textDecoration, paragraphDecoration); + Document document = new Document(text, List.of(decorationModel), text.length()); + richTextArea.getActionFactory().open(document).execute(new ActionEvent()); + richTextArea.setAutoSave(true); + + ToggleButton fontBoldToggle = new ToggleButton("Bold"); + fontBoldToggle.getStyleClass().add("bold-toggle-button"); + new TextDecorateAction<>(richTextArea, fontBoldToggle.selectedProperty().asObject(), + d -> d.getFontWeight() == BOLD, + (builder, a) -> builder.fontWeight(a ? BOLD : NORMAL).build()); + ToggleButton fontItalicToggle = new ToggleButton("Italic"); + fontItalicToggle.getStyleClass().add("italic-toggle-button"); + new TextDecorateAction<>(richTextArea, fontItalicToggle.selectedProperty().asObject(), + d -> d.getFontPosture() == ITALIC, + (builder, a) -> builder.fontPosture(a ? ITALIC : REGULAR).build()); + ToggleButton fontUnderlinedToggle = new ToggleButton("Underline"); + fontUnderlinedToggle.getStyleClass().add("underline-toggle-button"); + new TextDecorateAction<>(richTextArea, fontUnderlinedToggle.selectedProperty().asObject(), + TextDecoration::isUnderline, (builder, a) -> builder.underline(a).build()); + HBox actionsBox = new HBox(fontBoldToggle, fontItalicToggle, fontUnderlinedToggle); + root.setTop(actionsBox); + }); + waitForFxEvents(); + RichTextArea rta = robot.lookup(".rich-text-area").query(); + + robot.push(new KeyCodeCombination(A, SHORTCUT_DOWN)); + waitForFxEvents(); + Selection selection = rta.getSelection(); + assertNotNull(selection); + assertEquals(0, selection.getStart()); + assertEquals(70, selection.getEnd()); + + assertNotNull(rta.getDocument().getDecorations()); + assertEquals(1, rta.getDocument().getDecorations().size()); + assertInstanceOf(TextDecoration.class, rta.getDocument().getDecorations().get(0).getDecoration()); + TextDecoration td = (TextDecoration) rta.getDocument().getDecorations().get(0).getDecoration(); + assertEquals("Arial", td.getFontFamily()); + assertEquals(14, td.getFontSize()); + assertEquals(NORMAL, td.getFontWeight()); + assertEquals(REGULAR, td.getFontPosture()); + + robot.clickOn(".bold-toggle-button"); + waitForFxEvents(); + td = (TextDecoration) rta.getDocument().getDecorations().get(0).getDecoration(); + assertEquals(BOLD, td.getFontWeight()); + assertEquals(REGULAR, td.getFontPosture()); + assertEquals(false, td.isUnderline()); + + robot.clickOn(".italic-toggle-button"); + waitForFxEvents(); + td = (TextDecoration) rta.getDocument().getDecorations().get(0).getDecoration(); + assertEquals(BOLD, td.getFontWeight()); + assertEquals(ITALIC, td.getFontPosture()); + assertEquals(false, td.isUnderline()); + + robot.clickOn(".underline-toggle-button"); + waitForFxEvents(); + td = (TextDecoration) rta.getDocument().getDecorations().get(0).getDecoration(); + assertEquals(BOLD, td.getFontWeight()); + assertEquals(ITALIC, td.getFontPosture()); + assertEquals(true, td.isUnderline()); + + robot.clickOn(".bold-toggle-button") + .clickOn(".italic-toggle-button") + .clickOn(".underline-toggle-button"); + waitForFxEvents(); + td = (TextDecoration) rta.getDocument().getDecorations().get(0).getDecoration(); + assertEquals(NORMAL, td.getFontWeight()); + assertEquals(REGULAR, td.getFontPosture()); + assertEquals(false, td.isUnderline()); + + run(() -> { + // select some text and change its decoration; + richTextArea.getActionFactory().selectAndDecorate(new Selection(12, 27), + TextDecoration.builder().presets().fontFamily("Arial") + .fontWeight(BOLD).underline(true) + .build()).execute(new ActionEvent()); + }); + waitForFxEvents(); + selection = rta.getSelection(); + assertNotNull(selection); + assertEquals(Selection.UNDEFINED, selection); + assertEquals(3, rta.getDocument().getDecorations().size()); + td = (TextDecoration) rta.getDocument().getDecorations().get(0).getDecoration(); + assertEquals(NORMAL, td.getFontWeight()); + assertEquals(REGULAR, td.getFontPosture()); + assertEquals(false, td.isUnderline()); + td = (TextDecoration) rta.getDocument().getDecorations().get(1).getDecoration(); + assertEquals(BOLD, td.getFontWeight()); + assertEquals(REGULAR, td.getFontPosture()); + assertEquals(true, td.isUnderline()); + td = (TextDecoration) rta.getDocument().getDecorations().get(2).getDecoration(); + assertEquals(NORMAL, td.getFontWeight()); + assertEquals(REGULAR, td.getFontPosture()); + assertEquals(false, td.isUnderline()); + } + + @Test + public void emojiDemoTest(FxRobot robot) { + run(() -> { + String title = "Document with emojis \ud83d\ude03!\n"; + String contentText = "\uD83D\uDC4B\uD83C\uDFFC, this is some random text with some emojis " + + "like \uD83E\uDDD1\uD83C\uDFFC\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1\uD83C\uDFFD or " + + "\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC73\uDB40\uDC63\uDB40\uDC74\uDB40\uDC7F.\n" + + "These are emojis with skin tone or hair style, like:\n"; + String personText = EmojiData.search("person").stream() + .limit(10) + .map(Emoji::character) + .collect(Collectors.joining(", ")); + String endText = ".\nAnd this is another emoji with skin tone: "; + String text = title + contentText + personText + endText; + ParagraphDecoration paragraphDecoration = ParagraphDecoration.builder().presets().build(); + List decorationModels = List.of( + new DecorationModel(0, title.length(), TextDecoration.builder().presets().fontFamily("Arial").fontWeight(BOLD).fontSize(16).build(), paragraphDecoration), + new DecorationModel(title.length(), contentText.length(), TextDecoration.builder().presets().fontFamily("Arial").fontSize(14).build(), paragraphDecoration), + new DecorationModel(title.length() + contentText.length(), personText.length() + endText.length(), TextDecoration.builder().presets().fontFamily("Arial").fontPosture(ITALIC).fontSize(14).build(), paragraphDecoration)); + Document document = new Document(text, decorationModels, text.length()); + richTextArea.getActionFactory().open(document).execute(new ActionEvent()); + richTextArea.setAutoSave(true); + }); + waitForFxEvents(); + + run(() -> EmojiData.emojiFromShortName("runner").ifPresent(emoji -> { + Emoji emojiWithTone = emoji.getSkinVariationMap().get(EmojiSkinTone.MEDIUM_SKIN_TONE.getUnicode()); + richTextArea.getActionFactory().insertEmoji(emojiWithTone).execute(new ActionEvent()); + })); + waitForFxEvents(); + + RichTextArea rta = robot.lookup(".rich-text-area").query(); + + assertEquals(208, rta.getTextLength()); + assertEquals(267, rta.getDocument().getText().length()); + assertEquals(267, rta.getCaretPosition()); + assertEquals(267, rta.getDocument().getCaretPosition()); + + String serialText = CharBuffer.wrap(rta.getDocument().getText().toCharArray()).chars() + .mapToObj(i -> i > 255 ? String.format("\\u%x", i) : String.valueOf((char) i)) + .collect(Collectors.joining()); + String text = "Document with emojis \ud83d\ude03!\n" + + "\ud83d\udc4b\ud83c\udffc, this is some random text with some emojis like \ud83e\uddd1\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffd or \ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f.\n" + + "These are emojis with skin tone or hair style, like:\n" + + "\ud83d\udc71, \ud83d\udc71\ud83c\udffb, \ud83d\udc71\ud83c\udffd, \ud83d\udc71\ud83c\udffc, \ud83d\udc71\ud83c\udfff, \ud83d\udc71\ud83c\udffe, \ud83e\uddd4\ud83c\udffd, \ud83e\uddd4\ud83c\udffc, \ud83e\uddd4\ud83c\udffb, \ud83e\uddd4\ud83c\udfff.\n" + + "And this is another emoji with skin tone: \ud83c\udfc3\ud83c\udffd"; + assertEquals(text, rta.getDocument().getText()); + assertEquals(267, text.length()); + + String internalText = "Document with emojis \u2063!\n" + + "\u2063, this is some random text with some emojis like \u2063 or \u2063.\n" + + "These are emojis with skin tone or hair style, like:\n" + + "\u2063, \u2063, \u2063, \u2063, \u2063, \u2063, \u2063, \u2063, \u2063, \u2063.\n" + + "And this is another emoji with skin tone: \u2063"; + assertEquals(208, internalText.length()); + PieceTable pt = new PieceTable(rta.getDocument()); + StringBuilder internalSb = new StringBuilder(); + pt.walkFragments((u, d) -> internalSb.append(u.getInternalText()), 0, 208); + assertEquals(internalText, internalSb.toString()); + + assertEquals(15, robot.lookup(node -> node instanceof ImageView).queryAll().size()); + AtomicInteger counter = new AtomicInteger(); + for (int i = 0; i < 15; i++) { + ImageView imageView = robot.lookup(node -> node instanceof ImageView).nth(i).query(); + Object emojiUnified = imageView.getProperties().get("emoji_unified"); + assertNotNull(emojiUnified); + EmojiData.emojiFromCodepoints(emojiUnified.toString()).ifPresent(emoji -> { + counter.addAndGet(emoji.character().length()); + assertEquals(emojiUnified, emoji.getUnified()); + }); + } + assertEquals(267 - 208, counter.get() - 15); + + run(() -> richTextArea.getActionFactory().selectAll().execute(new ActionEvent())); + waitForFxEvents(); + + Selection selection = rta.getSelection(); + assertNotNull(selection); + assertEquals(0, selection.getStart()); + assertEquals(208, selection.getEnd()); + + run(() -> richTextArea.getActionFactory().selectNone().execute(new ActionEvent())); + waitForFxEvents(); + + sleep(10, TimeUnit.SECONDS); + } + + private void run(Runnable runnable) { + CountDownLatch countDownLatch = new CountDownLatch(1); + Platform.runLater(() -> { + runnable.run(); + countDownLatch.countDown(); + }); + try { + Assertions.assertTrue(countDownLatch.await(3, TimeUnit.SECONDS), "Timeout"); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +}