4

In JavaFX, how can I get the index of the character (in a javafx.scene.text.Text object) under the mouse pointer from a mouse clicked event? Or more generally, how do I get the index of the character located at a (x, y) coordinate in a javafx.scene.text.Text node?

I have managed to find the index using Node.queryAccessibleAttribute(), but this feels somewhat like a hack and not the proper use of the JavaFX APIs:

import javafx.application.Application;
import javafx.geometry.Point2D;
import javafx.scene.Scene;
import javafx.scene.layout.HBox;
import javafx.scene.text.Text;
import javafx.stage.Stage;

import static javafx.scene.AccessibleAttribute.OFFSET_AT_POINT;

public class TextClicked extends Application {

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        final Text textNode = new Text("Hello, world!");
        textNode.setOnMouseClicked(event -> {
            final int idx = (int) textNode.queryAccessibleAttribute(OFFSET_AT_POINT, new Point2D(event.getScreenX(), event.getScreenY()));
            System.out.println("Character clicked: " + textNode.getText().charAt(idx));
        });

        primaryStage.setScene(new Scene(new HBox(textNode)));
        primaryStage.show();
    }
}
oddbjorn
  • 359
  • 2
  • 11
  • Looks quite correct, so why do you not feel comfortable with it? – aw-think Sep 23 '15 at 11:36
  • 1
    While this isn't the intended use for the API you're using, as hacks go it's a pretty clean one (the behavior you're relying on is actually documented). AFAIK there's no other API for this functionality. So your other options would be to find a third-party library ([RichTextFX](https://github.com/TomasMikula/RichTextFX) has some functionality similar to what you're looking for), or to use a `TextFlow` with each individual character added as a text node, registering mouse handlers on each. – James_D Sep 23 '15 at 12:11
  • 1
    Right. There is Text.impl_hitTestChar() but this is internal and deprecated. I find it strange that they would deprecate it without providing a replacement? – oddbjorn Sep 23 '15 at 12:13
  • 1
    It's deprecated because they intended it to be private, but couldn't do that for (I think) unit testing reasons. If you look at the [source code](http://hg.openjdk.java.net/openjfx/8u60/rt/file/996511a322b7/modules/graphics/src/main/java/javafx/scene/text/Text.java) you'll see `@treatAsPrivate implementation detail` and `@deprecated this is an internal API that is not intended for use`. Here they are (ab?)using deprecation as a marker that this method was never intended to be used, rather than something that was intended for use and was removed or replaced. – James_D Sep 23 '15 at 13:18
  • 1
    In general, you should consider `impl_*` methods in JavaFX as private methods. – James_D Sep 23 '15 at 13:19

1 Answers1

2

JavaFX doesn't really encourage a functionality where you register an event handler with a control, and then inspect low-level details of the event (such as the mouse coordinates) in order to deduce semantic information about the event. The preferred approach is to register listeners with individual contained nodes in the scene graph. For example, whereas in Swing you might register a listener with a JList and then locationToIndex(...) method to get the index of the item in the JList, in JavaFX you are encouraged to register listeners with the individual ListCells instead of the ListView itself.

So the "idiomatic" way to do this is probably to create a TextFlow and add individual Texts to it. To determine which Text is clicked, you would register listeners with each of the individual texts.

You could create a reusable class to encapsulate this functionality, of course, exposing as much API of the enclosed TextFlow as you need.

Here's a fairly basic example:

import java.util.ArrayList;
import java.util.List;

import javafx.application.Application;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.event.Event;
import javafx.event.EventType;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.input.InputEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.stage.Stage;

public class ClickableTextExample extends Application {

    @Override
    public void start(Stage primaryStage) {
        ClickableText text = new ClickableText("The quick brown fox jumped over the lazy dog.");
        text.asNode().addEventHandler(TextClickEvent.CLICKED, e -> {
            System.out.println("Click on "+e.getCharacter()+" at "+e.getIndex());
        });

        StackPane root = new StackPane(text.asNode());
        Scene scene = new Scene(root, 350, 120);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static class ClickableText {
        private final StringProperty text = new SimpleStringProperty();
        public StringProperty textProperty() {
            return text ;
        }
        public String getText() {
            return textProperty().get();
        }
        public void setText(String text) {
            textProperty().set(text);
        }

        private final TextFlow textFlow ;

        public ClickableText(String text) {

            textFlow = new TextFlow();

            textProperty().addListener((obs, oldText, newText) -> 
                rebuildText(newText));

            setText(text);
        }

        public Node asNode() {
            return textFlow ;
        }

        private void rebuildText(String text) {
            List<Text> textNodes = new ArrayList<>();
            for (int i = 0; i < text.toCharArray().length; i++) {
                char c = text.charAt(i);
                Text textNode = new Text(Character.toString(c));
                textNodes.add(textNode);
                registerListener(textNode, i);
            }
            textFlow.getChildren().setAll(textNodes);
        }

        private void registerListener(Text text, int index) {
            text.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
                TextClickEvent event = new TextClickEvent(text.getText().charAt(0), index, textFlow, textFlow);
                Event.fireEvent(textFlow, event);
            });
        }


    }

    public static class TextClickEvent extends InputEvent {

        private final char c ;
        private final int index ;


        public static final EventType<TextClickEvent> CLICKED = new EventType<TextClickEvent>(InputEvent.ANY);

        public TextClickEvent(char c, int index, Node source, Node target) {

            super(source, target, CLICKED);

            this.c = c ;
            this.index = index ;
        }

        public char getCharacter() {
            return c ;
        }

        public int getIndex() {
            return index ;
        }
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Another solution, which you may or may not regard as a hack, would be to use a non-editable text field and style it to look like a Text (or Label). Then you can check the caretPosition after the user clicks.

This code is based on Copiable Label/TextField/LabeledText in JavaFX

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.layout.HBox;
import javafx.scene.control.TextField;
import javafx.stage.Stage;


public class TextClicked extends Application {

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        final TextField textNode = new TextField("Hello, world!");
        textNode.setEditable(false);
        textNode.getStyleClass().add("clickable-text");
        textNode.setOnMouseClicked(event -> {
            final int idx = Math.max(0, textNode.getCaretPosition() - 1);
            System.out.println("Character clicked: " + textNode.getText().charAt(idx));
        });

        Scene scene = new Scene(new HBox(textNode));
        scene.getStylesheets().add("clickable-text.css");
        primaryStage.setScene(scene);
        primaryStage.show();
    }
}

and then

clickable-text.css:

.clickable-text, .clickable-text:focused {
    -fx-background-color: transparent ;
    -fx-background-insets: 0px ;
}
Community
  • 1
  • 1
James_D
  • 201,275
  • 16
  • 291
  • 322