Search code examples
javafxtextareacaret

JavaFX TextArea - Get caret bounds


I am trying to show a popup beneath the caret of a text area. This means I need both X and Y coordinates.

I am currently trying to achieve it this way:

        final var textFieldBounds = input.getBoundsInParent();
        final var bounds = ((TextAreaSkin) input.getSkin()).getCaretBounds()
        completionList.setTranslateX(textFieldBounds.getMinX() + bounds.getMinX());
        completionList.setTranslateY(textFieldBounds.getMinY() + bounds.getMaxY());

While this works well in general, the "getCaretBounds" function always seems to be lagging behind one caret event:

animated picture of the issue

I've already tried using Platform.runLater, but it won't solve the issue.

I am reacting to the change in a caretPosition listener. Is there any better point in time / better way to achieve this?

I've also tried using the caretPosition value to manually retrieve the character bounds via the skin, but it doesn't solve the issue either.

Dumbed down version of the code:

package link.biosmarcel.baka.view;

import javafx.application.Application;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.ListView;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextInputControl;
import javafx.scene.control.skin.TextAreaSkin;
import javafx.scene.layout.Background;
import javafx.scene.layout.Pane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.stage.Stage;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.function.Function;

public class MRE extends Application {
    public static void main(String[] args) {
        Application.launch(MRE.class, args);
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        primaryStage.setTitle("AutocompleteField Demo");

        final var autocompleteField = new MREAutocompleteTextArea((string2) -> {
            List<String> available = new ArrayList<>();
            if ("reference".startsWith(string2)) {
                available.add("reference");
            }
            if ("name".startsWith(string2)) {
                available.add("name");
            }
            if ("booking_date".startsWith(string2)) {
                available.add("booking_date");
            }
            if ("effective_date".startsWith(string2)) {
                available.add("effective_date");
            }
            return available;
        });
        final var scene = new Scene(new VBox(autocompleteField.getNode()), 400, 300);
        primaryStage.setScene(scene);

        primaryStage.show();
    }

    private static class MREAutocompleteTextArea {

        private final Pane pane;
        public final TextInputControl input;
        protected final ListView<String> completionList;

        public MREAutocompleteTextArea(Function<String, List<String>> autocompleteGenerator) {
            pane = new Pane();
            input = createInput();
            completionList = new ListView<>();

            // This is what is the Z-Index on the web. It allows us to render our popup above everything else.
            pane.getChildren().add(input);
            pane.getChildren().add(completionList);
            pane.maxHeightProperty().bind(input.heightProperty());
            pane.maxWidthProperty().bind(input.widthProperty());

            completionList.setFixedCellSize(35.0);
            completionList.setMaxHeight(8 * completionList.getFixedCellSize());
            completionList.setBackground(Background.fill(Color.WHITE));
            completionList.setFocusTraversable(false);
            completionList.setVisible(false);

            input.focusedProperty().addListener((_, _, _) -> {
                if (!canShowPopup()) {
                    hidePopup();
                    return;
                }

                updatePopupItems(autocompleteGenerator.apply(input.getText().substring(0, input.getCaretPosition())));
                refreshPopup();
            });

            input.caretPositionProperty().addListener((_, _, newValue) -> {
                if (!canShowPopup()) {
                    return;
                }

                System.out.println(newValue.intValue());
                updatePopupItems(autocompleteGenerator.apply(input.getText().substring(0, newValue.intValue())));
                refreshPopup();
            });
        }

        private boolean canShowPopup() {
            return input.isFocused() && !input.isDisabled();
        }

        private void complete() {
            final String selectedItem = completionList.getSelectionModel().getSelectedItem();
            // selection is always nullable
            //noinspection ConstantValue
            if (selectedItem != null) {
                final var textBeforeCaret = input.getText().substring(0, input.getCaretPosition());
                final var lastSpace = textBeforeCaret.lastIndexOf(' ');
                final var preCompletionText = textBeforeCaret.substring(0, lastSpace + 1);

                // FIXME Use insert?
                input.setText(preCompletionText + selectedItem + " " + input.getText().substring(input.getCaretPosition()));
                // We add a space at the end, so we can start writing / completing the next token type right away.
                input.positionCaret(preCompletionText.length() + selectedItem.length() + 1);
            }
        }

        private void updatePopupItems(final Collection<String> newItems) {
            completionList.getItems().removeIf(item -> !newItems.contains(item));
            for (final String newItem : newItems) {
                if (!completionList.getItems().contains(newItem)) {
                    completionList.getItems().add(newItem);
                }
            }
            completionList.getItems().sort(String::compareTo);
        }

        public void hidePopup() {
            completionList.setVisible(false);
        }

        public void refreshPopup() {
            if (completionList.getItems().isEmpty()) {
                hidePopup();
                return;
            }

            // +2 to prevent an unnecessary scrollbar
            if (completionList.getSelectionModel().getSelectedIndex() == -1) {
                completionList.getSelectionModel().select(0);
            }

            completionList.setPrefHeight(completionList.getItems().size() * completionList.getFixedCellSize() + 2);
            positionPopup();
            toFront(completionList);

            completionList.setVisible(true);
        }

        private void toFront(Node node) {
            node.setViewOrder(-1);
            if (node.getParent() != null) {
                toFront(node.getParent());
            }
        }

        public Node getNode() {
            return pane;
        }

        void positionPopup() {
            final var textFieldBounds = input.getBoundsInParent();
            final var bounds = ((TextAreaSkin) input.getSkin()).getCaretBounds();
            completionList.setTranslateX(textFieldBounds.getMinX() + bounds.getMinX());
            completionList.setTranslateY(textFieldBounds.getMinY() + bounds.getMaxY());
        }

        TextInputControl createInput() {
            return new TextArea();
        }
    }
}

To reproduce, type boo, hit Enter and hit Backspace.

UPDATE

I did find a workaround now. While calling reuqestLayout, it did not work, calling layout has the desired effect. I am not sure if this can have any downsides though, as I assume this means I am potentially relayouting in some type of dirty state?


Solution

  • If layout() works for you, then that is likely the easiest solution. It's arguably the best solution anyway since it doesn't seem to rely on any implementation details. I also suspect any performance degradation is going to be minimal, if even noticeable by the end user (particularly if you create a native image with GraalVM).

    Alternative Approach

    That said, another approach is to get a reference to the Path representing the caret. That way you can directly observe the caret's bounds and react accordingly. Unfortunately, the caret node does not have a CSS ID or style-class. At least not in JavaFX 22. This means using Node::lookup is not an option. But there are at least two other approaches you can use, though they both rely on implementation details (and thus are brittle solutions).

    Walk the Scene Graph

    You can study how TextAreaSkin creates the scene graph for a TextArea. Then you can access the appropriate children lists and such, casting as you go. One way to do this somewhat reliably is to encapsulate this in your own custom skin.

    package com.example;
    
    import javafx.scene.Parent;
    import javafx.scene.control.ScrollPane;
    import javafx.scene.control.TextArea;
    import javafx.scene.control.skin.TextAreaSkin;
    import javafx.scene.shape.Path;
    
    public class CustomTextAreaSkin extends TextAreaSkin {
    
      private final Path caretPath;
    
      public CustomTextAreaSkin(TextArea control) {
        super(control);
        var scroll = (ScrollPane) getChildren().getFirst();
        var content = (Parent) scroll.getContent();
        caretPath = (Path) content.getChildrenUnmodifiable().getLast();
      }
    
      public Path getCaretPath() {
        return caretPath;
      }
    }
    

    Note: The above was tested with Java 22.0.2 and JavaFX 22.0.2. Modifications may be necessary if using a different version of Java and/or JavaFX.

    Then you can use it like so:

    var area = new TextArea();
    var skin = new CustomTextAreaSkin(area);
    area.setSkin(skin);
    
    var caret = skin.getCaretPath();
    // use 'caret'...
    

    If you want, you can set the skin via CSS.

    /* use different selector(s) as desired */
    .text-area {
        -fx-skin: "com.example.CustomTextAreaSkin";
    }
    

    But you'll have to wait until the CSS is applied to the control before you can get the skin. Typically, this means waiting until the control has been displayed in a showing window at least once.

    Reflection

    Another option is to use reflection to get the caret.

    package com.example;
    
    import javafx.scene.control.TextInputControl;
    import javafx.scene.control.skin.TextInputControlSkin;
    import javafx.scene.shape.Path;
    
    public class CaretHelper {
    
      public static Path getCaretPath(TextInputControl control) {
        var skin = control.getSkin();
        if (skin == null)
          throw new IllegalStateException("control has no skin");
        if (!(skin instanceof TextInputControlSkin<?>))
          throw new IllegalStateException("control's skin wrong type");
        
        try {
          var field = TextInputControlSkin.class.getDeclaredField("caretPath");
          field.setAccessible(true);
          return (Path) field.get(skin);
        } catch (ReflectiveOperationException ex) {
          throw new RuntimeException(ex);
        }
      }
    }
    

    Note: The above was tested with Java 22.0.2 and JavaFX 22.0.2. Modifications may be necessary if using a different version of Java and/or JavaFX.

    If JavaFX is resolved as named modules, then you'll need to pass:

    --add-opens javafx.controls/javafx.scene.control.skin=<target>
    

    When running your application or creating a GraalVM native image. Though fortunately, since the above code uses a class literal and "caretPath" is a compile-time constant, the reflection should "just work" in the native image without any extra configuration.

    Also, make sure the text area's skin has been set before calling getCaretPath. You can set it manually if you want. Otherwise, you typically have to wait for the text area to be in a window while it was shown at least once. More specifically, the default skin won't be set until CSS is applied on the control.


    Example

    Here's an example using a modified version of the code from the "Walk the Scene Graph" section. It was tested using Java 22.0.2 and JavaFX 22.0.2 on Windows 10.

    Main.java

    package com.example;
    
    import javafx.application.Application;
    import javafx.beans.value.ObservableValue;
    import javafx.geometry.Bounds;
    import javafx.geometry.Insets;
    import javafx.scene.Node;
    import javafx.scene.Scene;
    import javafx.scene.control.Label;
    import javafx.scene.control.TextArea;
    import javafx.scene.layout.StackPane;
    import javafx.stage.Popup;
    import javafx.stage.Stage;
    
    public class Main extends Application {
    
      @Override
      public void start(Stage primaryStage) {
        var area = new TextArea();
        var skin = new CustomTextAreaSkin(area);
        area.setSkin(skin);
        installCoordinatePopup(skin.getCaretPath(), skin.caretScreenBounds());
    
        var root = new StackPane(area);
        root.setPadding(new Insets(5));
    
        primaryStage.setScene(new Scene(root, 600, 400));
        primaryStage.show();
      }
    
      private void installCoordinatePopup(Node caret, ObservableValue<Bounds> caretScreenBounds) {
        var label = new Label();
        label.setStyle(
            """
            -fx-background-color: black, white;
            -fx-background-insets: 0, 1;
            -fx-background-radius: 3, 2;
            -fx-padding: 3;
            """);
    
        var popup = new Popup();
        popup.getContent().add(label);
    
        caretScreenBounds.subscribe(bounds -> {
          if (bounds == null) {
            popup.hide();
            return;
          }
    
          double minX = bounds.getMinX();
          double minY = bounds.getMinY();
          label.setText("X: %.2f, Y: %.2f".formatted(minX, minY));
    
          double anchorX = minX;
          double anchorY = bounds.getMaxY() + 3;
          if (popup.isShowing()) {
            popup.setAnchorX(anchorX);
            popup.setAnchorY(anchorY);
          } else {
            popup.show(caret, anchorX, anchorY);
          }
        });
      }
    }
    

    CustomTextAreaSkin.java

    import javafx.application.Platform;
    import javafx.beans.binding.Bindings;
    import javafx.beans.binding.ObjectBinding;
    import javafx.beans.binding.ObjectExpression;
    import javafx.geometry.Bounds;
    import javafx.scene.Parent;
    import javafx.scene.control.ScrollPane;
    import javafx.scene.control.TextArea;
    import javafx.scene.control.skin.TextAreaSkin;
    import javafx.scene.shape.Path;
    
    public class CustomTextAreaSkin extends TextAreaSkin {
    
      private final Path caretPath;
    
      private ObjectBinding<Bounds> caretScreenBounds;
      private boolean invalidate = true;
    
      public CustomTextAreaSkin(TextArea control) {
        super(control);
        var scroll = (ScrollPane) getChildren().getFirst();
        var content = (Parent) scroll.getContent();
        caretPath = (Path) content.getChildrenUnmodifiable().getLast();
      }
    
      public final Path getCaretPath() {
        return caretPath;
      }
    
      public final ObjectExpression<Bounds> caretScreenBounds() {
        if (caretScreenBounds == null) {
          caretScreenBounds =
              Bindings.createObjectBinding(() -> caretPath.localToScreen(caretPath.getBoundsInLocal()));
          // this listener will be automatically disposed when appropriate
          registerInvalidationListener(caretPath.boundsInLocalProperty(), _ -> {
            // try to minimize calls to runLater as much as possible
            if (caretScreenBounds.isValid() && invalidate) {
              invalidate = false;
              Platform.runLater(() -> {
                invalidate = true;
                caretScreenBounds.invalidate();
              });
            }
          });
        }
        return caretScreenBounds;
      }
    }
    

    This version of the code adds the caretScreenBounds() method to the skin class and makes use of Platform::runLater. Why? Because for some reason, scrolling seems to mess up the local bounds to screen bounds computation. The runLater call is the important part. Without it, you'll see the following issues:

    • When typing individual characters causes the text area to scroll, the bounds are not quite right. This causes the popup to "wiggle" as you type.

    • When scrolling a large amount, such as going to the next line after typing a really long line, the bounds are significantly off. This often causes the popup to go somewhere off the window, typically in the direction the scroll occurred. The popup will remain in the wrong location until the caret's bounds update (e.g., by typing something).

    There may be a better solution than runLater, but I couldn't find it. I even tried creating a binding dependent on the layout, in-local, and in-parent bounds of every node from the caret up to and including the text area itself and it still didn't solve the problem.