Richtextfx: CodeArea selection does not work when changing style before text change

Created on 5 Sep 2020  路  9Comments  路  Source: FXMisc/RichTextFX

I am trying to write a matching bracket highlighter for CodeArea. I have managed to get it working in the following manner:

I have extended CodeArea into CustomCodeArea. There I have overridden the following method:

@Override
public void replaceText(int start, int end, String text) {
    for (TextInsertionListener listener : insertionListeners) {
        listener.codeInserted(start, end, text);
    }
    super.replaceText(start, end, text);
}

Here the listener is used to listen for text change just before the text is actually changed.
This is needed to clear old matching bracket highlights in the following manner:
this.tabData.getCodeArea().addTextInsertionListener((start, end, text) -> clearBracket());

Inside the clear bracket method, I am removing the style in the following manner:

this.tabData.getCodeArea().setStyle(pair.start, pair.start + 1, styleList);
this.tabData.getCodeArea().setStyle(pair.end, pair.end + 1, styleList);

I must perform the operation of clearing the bracket in the main UX thread because it must be done before the text has changed.

When this is done, however, selection fails to work (selection highlight is not started, and nothing is selected) on the first try.
I have to click at the same position once more to start the selection highlight.

If there is some way to fix this (maybe fire event manually, or something else), or there is a better approach to matching bracket highlighting for CodeArea, could you please help me with that.

Thanks a lot!

Most helpful comment

You're welcome, thanks for PR.

All 9 comments

Okay, so I'm assuming that if you comment out the listener.codeInserted(start, end, text); line in your overridden replaceText method then selection works as normal, without the need to click twice.

I guess the reason you want to clear the bracket _here_ is because the text modification is going to mess up pair.start and pair.end, if that is the case why not just modify their values if needed based on the replaceText parameters ?

I tried the approach you mentioned and it works when typing slowly.

The issue is, if the rate of text change (say someone presses down the bracket key) is greater than the speed of processing then multiple old brackets' highlighting is not cleared.

Also, at times, this can lead to concurrent modification exceptions. Although concurrent modification can be taken care by synchronization, which I tried, this does not guarantee which thread will execute first, leading to wilder complications.

This is compounded by the fact that I am also implementing bracket auto-completion.

If you don't mind posting a simple but complete program with just your bracket highlighting and auto complete sections then I'll have a look.

I have prepared a short demo as requested here: https://github.com/prat-man/CodeAreaBracketsDemo

I have stripped everything else (including syntax highlighting, etc.) and only one type of brackets [ ] are supported to keep things simple.

It is a maven based IntelliJ project. Please have a look. Thanks!

Here are the two main classes posted here directly for a quicker access:

Controller.java

package in.pratanumandal.demo;

import javafx.fxml.FXML;
import javafx.scene.layout.BorderPane;

public class Controller {

    @FXML private BorderPane mainPane;

    @FXML
    public void initialize() {

        // create a new code area
        CustomCodeArea codeArea = new CustomCodeArea();

        // add code area to main pane
        mainPane.setCenter(codeArea);

        // highlight brackets
        BracketHighlighter bracketHighlighter = new BracketHighlighter(codeArea);

        // auto complete loops
        codeArea.setOnKeyTyped(keyEvent -> {
            // clear bracket highlighting
            bracketHighlighter.clearBracket();

            // get typed character
            String character = keyEvent.getCharacter();

            // add a ] if [ is typed
            if (character.equals("[")) {
                int position = codeArea.getCaretPosition();
                codeArea.insert(position, "]", "loop");
                codeArea.moveTo(position);
            }
            // remove next ] if ] is typed
            else if (character.equals("]")) {
                int position = codeArea.getCaretPosition();
                if (position != codeArea.getLength()) {
                    String nextChar = codeArea.getText(position, position + 1);
                    if (nextChar.equals("]")) codeArea.deleteText(position, position + 1);
                }
            }

            // refresh bracket highlighting
            bracketHighlighter.highlightBracket();
        });

    }

}


BracketHighlighter.java

package in.pratanumandal.demo;

import java.util.*;

public class BracketHighlighter {

    // the code area
    private final CustomCodeArea codeArea;

    // the map of bracket pairs existing in code
    private Map<Integer, Integer> brackets;

    // the list of highlighted bracket pairs
    private List<BracketPair> bracketPairs;

    /**
     * Parameterized constructor
     * @param codeArea the code area
     */
    public BracketHighlighter(CustomCodeArea codeArea) {
        this.codeArea = codeArea;

        this.brackets = new HashMap<>();
        this.bracketPairs = new ArrayList<>();

        this.codeArea.addTextInsertionListener((start, end, text) -> clearBracket());
        this.codeArea.textProperty().addListener((obs, oldVal, newVal) -> initializeBrackets(newVal));
        this.codeArea.caretPositionProperty().addListener((obs, oldVal, newVal) -> highlightBracket(newVal));
    }

    /**
     * Method to initialize the bracket pairs (do only when text is changed)
     *
     * @param code the next text
     */
    private synchronized void initializeBrackets(String code) {

        // clear bracket map
        this.brackets.clear();

        // compute matching brackets and add to map
        Stack<Integer> stack = new Stack<>();
        int index = 0;

        while (index < code.length()) {
            int i = code.indexOf("[", index);
            int j = code.indexOf("]", index);

            if (i == -1 && j == -1) {
                break;
            } else if (i != -1 && (i < j || j == -1)) {
                stack.push(i);
                index = i + 1;
            } else if (j != -1) {
                if (!stack.isEmpty()) {
                    int k = stack.pop();
                    brackets.put(k, j);
                    brackets.put(j, k);
                }
                index = j + 1;
            }
        }

    }

    /**
     * Highlight the matching bracket at new caret position
     *
     * @param newVal the new caret position
     */
    private synchronized void highlightBracket(int newVal) {

        // first clear existing bracket highlights
        this.clearBracket();

        // detect caret position both before and after bracket
        String prevChar = (newVal > 0) ? codeArea.getText(newVal - 1, newVal) : "";
        if (prevChar.equals("[") || prevChar.equals("]")) newVal--;

        // get other half of matching bracket
        Integer other = this.brackets.get(newVal);

        if (other != null) {
            // other half exists
            BracketPair pair = new BracketPair(newVal, other);

            // highlight start
            if (pair.start < codeArea.getLength()) {
                String text = codeArea.getText(pair.start, pair.start + 1);
                if (text.equals("[") || text.equals("]")) {
                    List<String> styleList = new ArrayList<>();
                    styleList.add("loop");
                    styleList.add("match");

                    // if we run the following line with Platform.runLater, selection works
                    // but bracket highlighting breaks down
                    codeArea.setStyle(pair.start, pair.start + 1, styleList);
                }
            }

            // highlight end
            if (pair.end < codeArea.getLength()) {
                String text = codeArea.getText(pair.end, pair.end + 1);
                if (text.equals("[") || text.equals("]")) {
                    List<String> styleList = new ArrayList<>();
                    styleList.add("loop");
                    styleList.add("match");

                    // if we run the following line with Platform.runLater, selection works
                    // but bracket highlighting breaks down
                    codeArea.setStyle(pair.end, pair.end + 1, styleList);
                }
            }

            // add bracket pair to list
            this.bracketPairs.add(pair);
        }

    }

    /**
     * Highlight the matching bracket at current caret position
     */
    public synchronized void highlightBracket() {
        this.highlightBracket(codeArea.getCaretPosition());
    }

    /**
     * Clear the existing highlighted bracket styles
     */
    public synchronized void clearBracket() {

        Iterator<BracketPair> iterator = this.bracketPairs.iterator();
        while (iterator.hasNext()) {
            // get next bracket pair
            BracketPair pair = iterator.next();

            // clear start
            if (pair.start < codeArea.getLength()) {
                String text = codeArea.getText(pair.start, pair.start + 1);
                if (text.equals("[") || text.equals("]")) {
                    List<String> styleList = new ArrayList<>();
                    styleList.add("loop");

                    // if we run the following line with Platform.runLater, selection works
                    // but bracket highlighting breaks down
                    codeArea.setStyle(pair.start, pair.start + 1, styleList);
                }
            }

            // clear end
            if (pair.end < codeArea.getLength()) {
                String text = codeArea.getText(pair.end, pair.end + 1);
                if (text.equals("[") || text.equals("]")) {
                    List<String> styleList = new ArrayList<>();
                    styleList.add("loop");

                    // if we run the following line with Platform.runLater, selection works
                    // but bracket highlighting breaks down
                    codeArea.setStyle(pair.end, pair.end + 1, styleList);
                }
            }

            // remove bracket pair from list
            iterator.remove();
        }

    }

    /**
     * Class representing a pair of matching bracket indices
     */
    static class BracketPair {

        private int start;
        private int end;

        public BracketPair(int start, int end) {
            this.start = start;
            this.end = end;
        }

        public int getStart() {
            return start;
        }

        public int getEnd() {
            return end;
        }

        @Override
        public String toString() {
            return "BracketPair{" +
                    "start=" + start +
                    ", end=" + end +
                    '}';
        }

    }

}

Great, not sure if I'll get to it today though so probably tomorrow ....

First of all nice code and thanks for the code comments.

It turns out that the _caretPositionProperty()_ listener, in the BracketHighlighter constructor, which calls _highlightBracket( newVal )_ somehow sabotages the area's selection state. The simple solution is to just wrap that call in Platform.runLater like so:

codeArea.caretPositionProperty().addListener((obs,old,newVal) -> Platform.runLater(() -> highlightBracket(newVal)));

I took some time playing around with your code and also found that you don't need to invoke bracketHighlighter.highlightBracket() in the Controller's codeArea.setOnKeyTyped event handler because it will be invoked in any case via BracketHighlighter's _caretPositionProperty()_ listener.

Lastly I suggest an alternative to _initializeBrackets_ which processes ALL the code area's text every time an edit occurs. I think a better idea is to just search forward/backward from the current bracket to find it's matching partner when needed. Here is my version of BracketHighlighter with some additional changes for you to assess and use if you like:

public class BracketHighlighter {

    private final CustomCodeArea codeArea;

    // the list of highlighted bracket pairs
    private List<BracketPair> bracketPairs = new ArrayList<>();

    // constants that don't need to be created every time
    private final List<String> LOOP_STYLE = Collections.singletonList( "loop" );
    private final List<String> MATCH_STYLE = Arrays.asList( "match", "loop" );
    private final String BRACKET_PAIRS = "(){}[]<>";

    /**
     * Parameterized constructor
     * @param codeArea the code area
     */
    public BracketHighlighter(CustomCodeArea codeArea) {
        this.codeArea = codeArea;
        this.codeArea.addTextInsertionListener((start, end, text) -> clearBracket());
        //this.codeArea.textProperty().addListener((obs, oldVal, newVal) -> initializeBrackets(newVal)); // Let's not process all the text every time ;-)
        this.codeArea.caretPositionProperty().addListener((obs, oldVal, newVal) -> Platform.runLater( () -> highlightBracket(newVal) ));
    }

    /**
     * Highlight the matching bracket at new caret position
     * @param newVal the new caret position
     */
    private void highlightBracket(int newVal) {

        // first clear existing bracket highlights
        this.clearBracket();

        // detect caret position both before and after bracket
        String prevChar = (newVal > 0) ? codeArea.getText(newVal - 1, newVal) : "";
        if (prevChar.equals("[") || prevChar.equals("]")) --newVal;

        // get other half of matching bracket
        Integer other = getMatchingBracket( newVal );

        if (other != null) {
            // other half exists
            BracketPair pair = new BracketPair(newVal, other);

            // highlight start and end
            styleBrackets( pair, MATCH_STYLE );

            // add bracket pair to list
            this.bracketPairs.add(pair);
        }
    }

    /**
     * Find the matching bracket location.
     * @param index to start searching from
     * @return null or position of matching bracket
     */
    private Integer getMatchingBracket( int index )
    {
        if ( index == codeArea.getLength() ) return null;

        char initialBracket = codeArea.getText( index, index+1 ).charAt(0);
        int bracketTypePosition = BRACKET_PAIRS.indexOf( initialBracket ); // "(){}[]<>"
        if ( bracketTypePosition < 0 ) return null;

        // even numbered bracketTypePositions are opening brackets, and odd positions are closing
        // if even (opening bracket) then step forwards, otherwise step backwards
        int stepDirection = ( bracketTypePosition % 2 == 0 ) ? +1 : -1;

        // the matching bracket to look for, the opposite of initialBracket
        char match = BRACKET_PAIRS.charAt( bracketTypePosition + stepDirection );

        index += stepDirection;
        int bracketCount = 1;

        while ( index > -1 && index < codeArea.getLength() ) {
            char code = codeArea.getText( index, index+1 ).charAt(0);
            if ( code == initialBracket ) bracketCount++;
            else if ( code == match ) bracketCount--;
            if ( bracketCount == 0 ) return index;
            else index += stepDirection;
        }

        return null;
    }

    /**
     * Highlight the matching bracket at current caret position
     */
    public void highlightBracket() {
        this.highlightBracket(codeArea.getCaretPosition());
    }

    /**
     * Clear the existing highlighted bracket styles
     */
    public void clearBracket() {

        Iterator<BracketPair> iterator = this.bracketPairs.iterator();

        while ( iterator.hasNext() )
        {
            // clear next bracket pair
            styleBrackets( iterator.next(), LOOP_STYLE );

            // remove bracket pair from list
            iterator.remove();
        }

    }

    private void styleBrackets( BracketPair pair, List<String> styles )
    {
        styleBracket( pair.start, styles );
        styleBracket( pair.end, styles );
    }

    private void styleBracket( int pos, List<String> styles )
    {
        if ( pos < codeArea.getLength() ) {
            String text = codeArea.getText( pos, pos + 1 );
            if ( text.equals("[") || text.equals("]") ) {
                codeArea.setStyle( pos, pos + 1, styles );
            }
        }
    }

    /**
     * Class representing a pair of matching bracket indices
     */
    static class BracketPair {

        private int start;
        private int end;

        public BracketPair(int start, int end) {
            this.start = start;
            this.end = end;
        }

        public int getStart() {
            return start;
        }

        public int getEnd() {
            return end;
        }

        @Override
        public String toString() {
            return "BracketPair{" +
                    "start=" + start +
                    ", end=" + end +
                    '}';
        }

    }

}

Thanks a lot for helping me out!

Changing it to codeArea.caretPositionProperty().addListener((obs,old,newVal) -> Platform.runLater(() -> highlightBracket(newVal))); fixes the problem and even after testing with multiple scenarios, I still haven't found any issues.

Also thanks for the improved code, I have adopted some parts of it like the styleBrackets(BracketPair pair, List<String> styles) and styleBracket(int pos, List<String> styles) methods. They have really cleaned up my code.

However, I am staying with the approach of computing the brackets all at once, since it is more efficient for my use case.

Also, I have created a pull request with an improved demo supporting multiple bracket types at https://github.com/FXMisc/RichTextFX/pull/959.
I think that would be a really useful addition for anyone trying to do bracket highlighting using RichTextFX.

You're welcome, thanks for PR.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

Adrodoc picture Adrodoc  路  3Comments

miho picture miho  路  10Comments

DaveJarvis picture DaveJarvis  路  6Comments

RobertBColton picture RobertBColton  路  5Comments

xulihang picture xulihang  路  4Comments