FXMisc / RichTextFX

Rich-text area for JavaFX
BSD 2-Clause "Simplified" License
1.2k stars 235 forks source link

Horizontal scrolling results in a "wobble" that is not present when using normal TextFlow #990

Open swpalmer opened 3 years ago

swpalmer commented 3 years ago

Expected Behavior

The entire line should scroll as a single unit. Like the top panel in the example case provided.

Actual Behavior

The rounding of positions of nodes within the CodeArea seems to cause adjacent nodes to occasionally be separated by an extra pixel. See the "wobble" of differently coloured nodes produced by the provided example.

Reproducible Demo

Run this program and notice the "wobbling" text in the lower panel:

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javafx.animation.Animation;
import javafx.animation.Interpolator;
import javafx.animation.Transition;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.fxmisc.richtext.CodeArea;
import org.fxmisc.richtext.model.StyleSpan;
import org.fxmisc.richtext.model.StyleSpans;
import org.fxmisc.richtext.model.StyleSpansBuilder;

public class Main extends Application {
    static String sample = """
        // Shows a 'wobble' between nodes in the CodeArea
        // on the bottom, compared to the 'normal'
        // TextFlow and Text nodes on the top.
        public static void main(String[] args) {
           System.println("Hello World!");
        }
        // This line is very long just so horizontal scrolling is possible in the ScrollPane on the top
        """;

    static String cssSyntax = """
        .keyword {
        -fx-fill: purple;
        -fx-font-weight: bold;
        }
        .semicolon {
        -fx-font-weight: bold;
        }
        .paren {
        -fx-fill: firebrick;
        -fx-font-weight: bold;
        }
        .bracket {
        -fx-fill: darkgreen;
        -fx-font-weight: bold;
        }
        .brace {
        -fx-fill: teal;
        -fx-font-weight: bold;
        }
        .string {
        -fx-fill: blue;
        }

        .comment {
        -fx-fill: cadetblue;
        }
        """;

    static Font mono = Font.font("monospaced");
    static File cssFile;
    VBox lineBox = new VBox();

    public static void main(String[] args) {
        System.out.println("java.version = "+System.getProperty("java.version"));
        System.out.println("javafx.version = "+System.getProperty("javafx.version"));
        try {
            cssFile = File.createTempFile("syntax", ".css");
            Files.writeString(cssFile.toPath(), cssSyntax, StandardOpenOption.CREATE,
                    StandardOpenOption.TRUNCATE_EXISTING);
            cssFile.deleteOnExit();
        } catch (IOException ex) {
            System.err.println(ex);
        }
        launch(args);
    }

    private void buildTextFlows(final String sample, StyleSpans<Collection<String>> styleStuff) {
        int p = 0;
        TextFlow tf = new TextFlow();
        for (Iterator<StyleSpan<Collection<String>>> sit = styleStuff.iterator(); sit.hasNext();) {
            StyleSpan<Collection<String>> ss = sit.next();
            int len = ss.getLength();
            String txt = sample.substring(p, p+len);
            p += len;
            if (txt.equals("\n")) {
                lineBox.getChildren().add(tf);
                tf = new TextFlow();
            } else {
                Text t = new Text(txt);
                t.setFont(mono);
                t.getStyleClass().addAll(ss.getStyle());
                tf.getChildren().add(t);
            }
        }
        if (!tf.getChildren().isEmpty()) {
            lineBox.getChildren().add(tf);
        }
    }

    @Override
    public void start(Stage stage) {
        StyleSpans<Collection<String>> styleStuff = new JavaRegExHighlighter().computeHighlighting(sample);
        buildTextFlows(sample, styleStuff);
        ScrollPane sp = new ScrollPane(lineBox);

        CodeArea code = new CodeArea(sample);
        code.setStyleSpans(0, styleStuff);

        // link scroll position
        sp.hvalueProperty().addListener((obs, ov, nv) -> {
            double vpw = sp.getViewportBounds().getWidth();
            double cw = sp.getContent().getLayoutBounds().getWidth();
            double xpos = Math.max(0, (cw-vpw) * nv.doubleValue());
            code.scrollXToPixel(xpos);
        });

        Transition anim = new Transition(60) {
            {
                setCycleDuration(Duration.seconds(20));
            }
            @Override
            protected void interpolate(double frac) {
                sp.hvalueProperty().set(frac/2);
            }
        };
        anim.setInterpolator(Interpolator.LINEAR);
        anim.setAutoReverse(true);
        anim.setDelay(Duration.seconds(2));
        anim.setCycleCount(Animation.INDEFINITE);

        BorderPane bp = new BorderPane(null,sp,null,code,null);

        Scene scene = new Scene(bp);
        scene.getStylesheets().add(cssFile.toURI().toString());
        stage.setScene(scene);
        stage.setWidth(400);
        stage.setTitle("CodeArea Horizontal Scrolling Issue");
        stage.show();
        anim.playFromStart();
    }
}

class JavaRegExHighlighter {

    private static final String[] KEYWORDS = new String[]{
        "abstract", "assert", "boolean", "break", "byte",
        "case", "catch", "char", "class", "const",
        "continue", "default", "do", "double", "else",
        "enum", "extends", "final", "finally", "float",
        "for", "goto", "if", "implements", "import",
        "instanceof", "int", "interface", "long", "native",
        "new", "package", "private", "protected", "public",
        "return", "short", "static", "strictfp", "super",
        "switch", "synchronized", "this", "throw", "throws",
        "transient", "try", "void", "volatile", "while"
    };
    private static final String KEYWORD_PATTERN = "\\b(" + String.join("|", KEYWORDS) + ")\\b";
    private static final String PAREN_PATTERN = "\\(|\\)";
    private static final String BRACE_PATTERN = "\\{|\\}";
    private static final String BRACKET_PATTERN = "\\[|\\]";
    private static final String SEMICOLON_PATTERN = "\\;";
    private static final String STRING_PATTERN = "\"([^\"\\\\]|\\\\.)*\"";
    private static final String COMMENT_PATTERN = "//[^\n]*" + "|" + "/\\*(.|\\R)*?\\*/";

    private static final Pattern PATTERN = Pattern.compile(
            "(?<KEYWORD>" + KEYWORD_PATTERN + ")"
            + "|(?<PAREN>" + PAREN_PATTERN + ")"
            + "|(?<BRACE>" + BRACE_PATTERN + ")"
            + "|(?<BRACKET>" + BRACKET_PATTERN + ")"
            + "|(?<SEMICOLON>" + SEMICOLON_PATTERN + ")"
            + "|(?<STRING>" + STRING_PATTERN + ")"
            + "|(?<COMMENT>" + COMMENT_PATTERN + ")"
    );

    public StyleSpans<Collection<String>> computeHighlighting(String text) {
        return compute(text);
    }

    static StyleSpans<Collection<String>> compute(String text) {
        Matcher matcher = PATTERN.matcher(text);
        int lastKwEnd = 0;
        StyleSpansBuilder<Collection<String>> spansBuilder = new StyleSpansBuilder<>();
        while (matcher.find()) {
            String styleClass
                    = matcher.group("KEYWORD") != null ? "keyword"
                    : matcher.group("PAREN") != null ? "paren"
                    : matcher.group("BRACE") != null ? "brace"
                    : matcher.group("BRACKET") != null ? "bracket"
                    : matcher.group("SEMICOLON") != null ? "semicolon"
                    : matcher.group("STRING") != null ? "string"
                    : matcher.group("COMMENT") != null ? "comment"
                    : null;
            /* never happens */ assert styleClass != null;
            spansBuilder.add(Collections.emptyList(), matcher.start() - lastKwEnd);
            spansBuilder.add(Collections.singleton(styleClass), matcher.end() - matcher.start());
            lastKwEnd = matcher.end();
        }
        spansBuilder.add(Collections.emptyList(), text.length() - lastKwEnd);
        return spansBuilder.create();
    }
}

Environment info:


Current Workarounds

Coding directly to use plain TextFlow and Text nodes avoids the issue, as seen in the top panel of the provided example.

Jugen commented 3 years ago

Thanks for submitting and the demo program :-). Unfortunately I haven't been able to trace the problem :-(

I tested this with Java 1.8, 9, 11, and 15 as well as with RichTextFX 0.6.10, 0.9.0, and 0.10.5 with all of them showing the same wobble.

The demo program uses scrollXToPixel but the wobble also occurs if you use setTranslateX instead, so it's not the scroll code and therefore not the Flowless library that's responsible for scrolling.

I initially thought that it may be layout code being called twice but as far as I could debug and trace no layout code is being called multiple times.

I replaced the TextExt nodes with normal Text ones in RichTextFX and vice versa in the demo program with no difference, so it's also not TextExt and I'm fairly sure also not TextFlowExt. (Note that both each subclass Text and TextFlow respectively only adding functionality and not overriding any methods.)

Besides the above I noticed that the wobble only seems to happen on lines where there are multiple Text nodes, irrespective of any styling on those nodes. On lines with either no styling or styling that covers the entire line (so there's only one text node) the wobble doesn't occur.

I'm sorry to say that at this stage I'm at a loss and have no further ideas to pursue.