javafx

JavaFX events sent to a button control: why two MOUSE_ENTERED / MOUSE_EXITED events?


I have experimented a bit with the events sent to a JavaFX button control depending on what happens with the mouse pointer, and generated a hierarchical state machine diagram (approximate as my editor does not have any notion of the semantics) from this:

This is all pretty straightforward, just trace the behavior from the start state and see what happens. There is a subtlety here as the label inside the button control also has a role to play, and it generates MOUSE_ENTERED_TARGET and MOUSE_EXITED_TARGET events (I asked ChatGPT about this and it printed a beautiful answer that was quite wrong 😂)

Button control hierarchical state machine

The only point I'm wondering about is - why do I consistently get two MOUSE_ENTERED or two MOUSE_EXITED events, one with consumed = false, and one with consumed = true?

Example code

Here is the (near minimal) example. It just creates a button that we can manually prod to see what events are generated:

File com.example.stack_overflow.Main.java

package com.example.stack_overflow;

import javafx.application.Application;
import javafx.event.ActionEvent;
import javafx.event.Event;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

import java.util.Objects;

class PaneBuilder {

    private final StackPane stackPane;

    private static void log(String text) {
        System.out.println(text);
    }

    public PaneBuilder() {
        stackPane = buildCenteringStackPaneAroundButton(buildButton());
    }

    private static Button buildButton() {
        final var button = new Button("Click Me!");
        button.setMinWidth(120); // I think these are "120pt", why is there no "unit"?
        final var desc = "Button";
        button.addEventHandler(ActionEvent.ACTION, event -> {
            handleActionEvent(desc, event);
        });
        button.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
            handleMouseEvent(desc, event);
        });
        button.addEventHandler(MouseEvent.MOUSE_RELEASED, event -> {
            handleMouseEvent(desc, event);
        });
        button.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
            handleMouseEvent(desc, event);
        });
        button.addEventHandler(MouseEvent.MOUSE_ENTERED, event -> {
            handleMouseEvent(desc, event);
        });
        button.addEventHandler(MouseEvent.MOUSE_EXITED, event -> {
            handleMouseEvent(desc, event);
        });
        button.addEventHandler(MouseEvent.MOUSE_ENTERED_TARGET, event -> {
            handleMouseEvent(desc, event);
        });
        button.addEventHandler(MouseEvent.MOUSE_EXITED_TARGET, event -> {
            handleMouseEvent(desc, event);
        });
        button.armedProperty().addListener((obs, oldVal, newVal) -> log(desc + ": armed status changes: " + oldVal + " -> " + newVal));
        return button;
    }

    public Pane getPane() {
        return stackPane;
    }

    private static StackPane buildCenteringStackPaneAroundButton(Button button) {
        final var stackPane = new StackPane();
        stackPane.setAlignment(Pos.CENTER);
        final var hbox = buildHBoxAroundVBoxAroundButton(button);
        final var insets = new Insets(20, 20, 20, 20); // I guess those are "20points"
        StackPane.setMargin(hbox, insets);
        stackPane.getChildren().add(hbox);
        return stackPane;
    }

    private static HBox buildHBoxAroundVBoxAroundButton(Button button) {
        final var hbox = new HBox();
        hbox.setAlignment(Pos.CENTER);
        hbox.getChildren().add(buildVBoxAroundButton(button));
        return hbox;
    }

    private static VBox buildVBoxAroundButton(Button button) {
        final var vbox = new VBox();
        vbox.setAlignment(Pos.CENTER);
        vbox.getChildren().add(button);
        return vbox;
    }

    private static void appendEventDesc(StringBuilder buf, Event event) {
        buf.append("\n   Type          : " + event.getEventType());
        buf.append("\n   Class         : " + event.getClass().getName());
        buf.append("\n   Consumed      : " + event.isConsumed());
        buf.append("\n   Source class  : " + event.getSource().getClass().getName());
        buf.append("\n   Event         : " + Objects.toIdentityString(event));
    }

    public static void handleMouseEvent(String desc, MouseEvent event) {
        final var buf = new StringBuilder("Mouse event in " + desc);
        appendEventDesc(buf, event);
        log(buf.toString());
    }

    public static void handleActionEvent(String desc, ActionEvent event) {
        final var buf = new StringBuilder("Action event in " + desc);
        appendEventDesc(buf, event);
        log(buf.toString());
    }
}

public class Main extends Application {

    @Override
    public void start(Stage stage) {
        stage.setTitle("Button Example");
        stage.setScene(new Scene(new PaneBuilder().getPane()));
        stage.sizeToScene();
        stage.show();
    }

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

}

And the POM to build and run the above. The program must be run with the Maven goal javafx:run of JavaFX Maven plugin

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>stack_overflow</artifactId>
    <version>1.0-SNAPSHOT</version>

    <!-- RUNNING VIA MAVEN + JAVAFX PLUGIN IN IDE: -->
    <!--   'Main Menu' > 'Run' > 'Run Maven Goal' > 'Plugins' > 'JavaFx Maven Plugin' > 'javafx:run' -->
    <!--   This will invoke the goal "javafx:run" of the "javafx-maven-plugin". -->
    <!--   With the 'Run New Maven Goal' menu entry, you can define that goal, and it will appear in the -->
    <!--   context menu as 'right-mouse-button menu' > 'run maven' > 'javafx:run' -->

    <!-- RUNNING VIA MAVEN IN COMMAND LINE w/o leaving the IDE (no need for OpenJDX SDK): -->
    <!-- Right-click on the project window and select "Open Terminal at the current Maven module path". -->
    <!-- Enter the command "mvn javafx:run" or for debugging output "mvn -X javafx:run". -->
    <!-- This will invoke the goal "javafx:run" of the "javafx-maven-plugin". -->

    <properties>

        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

        <!-- This is the latest OpenJavaFX version on 2024-09-26 (version of 2024-09-16) -->
        <!-- https://mvnrepository.com/artifact/org.openjfx/javafx-controls -->

        <javafx.version>23</javafx.version>

        <javafx.plugin.version>0.0.8</javafx.plugin.version>
        <compiler.plugin.version>3.13.0</compiler.plugin.version>
        <exec.plugin.version>3.4.1</exec.plugin.version>
        <dependency.plugin.version>3.8.0</dependency.plugin.version>
        <main.class>com.example.stack_overflow.Main</main.class>

        <java.compiler.source.version>21</java.compiler.source.version>
        <java.compiler.target.version>21</java.compiler.target.version>

    </properties>

    <dependencies>
        <!-- https://mvnrepository.com/artifact/org.openjfx/javafx-controls -->
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>${javafx.version}</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.openjfx/javafx-fxml -->
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-fxml</artifactId>
            <version>${javafx.version}</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>

            <!-- Standard Java Compiler Plugin -->
            <!-- https://maven.apache.org/plugins/maven-compiler-plugin/ -->
            <!-- https://mvnrepository.com/artifact/org.apache.maven.plugins/maven-compiler-plugin -->

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>${compiler.plugin.version}</version>
                <configuration>
                    <source>${java.compiler.source.version}</source>
                    <target>${java.compiler.target.version}</target>
                </configuration>
            </plugin>

            <!-- Special Plugin to run JavaFX programs -->
            <!-- https://github.com/openjfx/javafx-maven-plugin -->
            <!-- https://mvnrepository.com/artifact/org.openjfx/javafx-maven-plugin -->
            <!-- If you run the plugin's goal on the command line with the '-X' option like so: -->
            <!-- mvn -X javafx:run -->
            <!-- you will see the command line the plugin builds, which looks as follows, with ++ replaced by double dash -->

            <plugin>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-maven-plugin</artifactId>
                <version>${javafx.plugin.version}</version>
                <configuration>
                    <mainClass>${main.class}</mainClass>
                    <options>
                        <option>-ea</option>
                    </options>
                </configuration>
            </plugin>

        </plugins>
    </build>
</project>

Running this program brings up this window:

enter image description here

If we now follow the indicated red path with the mouse pointer, then click the mouse and move the mouse pointer out of the button again:

enter image description here

we get the following changes in the value of the armed attribute and events emitted by the button. Note the double MOUSE_ENTERED with consumed = false, then true.

Entering the button control:

Mouse event in Button
   Type          : MOUSE_ENTERED
   Class         : javafx.scene.input.MouseEvent
   Consumed      : false
   Source class  : javafx.scene.control.Button
   Event         : javafx.scene.input.MouseEvent@2ebee20a
Mouse event in Button
   Type          : MOUSE_ENTERED
   Class         : javafx.scene.input.MouseEvent
   Consumed      : true
   Source class  : javafx.scene.control.Button
   Event         : javafx.scene.input.MouseEvent@2ebee20a

Entering the button control's label:

Mouse event in Button
   Type          : MOUSE_ENTERED_TARGET
   Class         : javafx.scene.input.MouseEvent
   Consumed      : false
   Source class  : javafx.scene.control.Button
   Event         : javafx.scene.input.MouseEvent@5d41df9f

Clicking, note the change in the status of armed.

Mouse event in Button
   Type          : MOUSE_PRESSED
   Class         : javafx.scene.input.MouseEvent
   Consumed      : false
   Source class  : javafx.scene.control.Button
   Event         : javafx.scene.input.MouseEvent@3df998ed
Button: armed status changes: false -> true
Mouse event in Button
   Type          : MOUSE_RELEASED
   Class         : javafx.scene.input.MouseEvent
   Consumed      : false
   Source class  : javafx.scene.control.Button
   Event         : javafx.scene.input.MouseEvent@241a11d5
Action event in Button
   Type          : ACTION
   Class         : javafx.event.ActionEvent
   Consumed      : false
   Source class  : javafx.scene.control.Button
   Event         : javafx.event.ActionEvent@3d8fce01
Button: armed status changes: true -> false
Mouse event in Button
   Type          : MOUSE_CLICKED
   Class         : javafx.scene.input.MouseEvent
   Consumed      : false
   Source class  : javafx.scene.control.Button
   Event         : javafx.scene.input.MouseEvent@505036ef

Exiting the button control's label:

Mouse event in Button
   Type          : MOUSE_EXITED_TARGET
   Class         : javafx.scene.input.MouseEvent
   Consumed      : false
   Source class  : javafx.scene.control.Button
   Event         : javafx.scene.input.MouseEvent@2d496725

Exiting the button control:

Mouse event in Button
   Type          : MOUSE_EXITED
   Class         : javafx.scene.input.MouseEvent
   Consumed      : false
   Source class  : javafx.scene.control.Button
   Event         : javafx.scene.input.MouseEvent@59a94fe0
Mouse event in Button
   Type          : MOUSE_EXITED
   Class         : javafx.scene.input.MouseEvent
   Consumed      : true
   Source class  : javafx.scene.control.Button
   Event         : javafx.scene.input.MouseEvent@59a94fe0

Solution

  • According to the documentation, both MOUSE_EXITED and MOUSED_EXITED_TARGET event handlers will be fired with a MOUSE_EXITED event when the mouse exits the button.

    You are handling both types of events. A MOUSED_EXITED_TARGET event is also an event of type MOUSE_EXITED, so when you have a handler for both, both get fired when the mouse exits the target.

    You write:

    button.addEventHandler(MouseEvent.MOUSE_EXITED, event -> {
        handleMouseEvent(desc, event);
    });
    button.addEventHandler(MouseEvent.MOUSE_EXITED_TARGET, event -> {
        handleMouseEvent(desc, event);
    });
    

    You can see from your output though that there is only a single event generated (just handled multiple times) because the output of both event handlers reference the same event object:

    Event         : javafx.scene.input.MouseEvent@59a94fe0
    

    Explanation

    This is explained in the MouseEvent javadoc.

    Sorry for copying such a large chunk, but it is a bit complicated and I couldn't explain it better in a shorter way myself.

    Mouse enter/exit handling

    When the mouse enters a node, the node gets MOUSE_ENTERED event, when it leaves, it gets MOUSE_EXITED event. These events are delivered only to the entered/exited node and seemingly don't go through the capturing/bubbling phases. This is the most common use-case.

    When the capturing or bubbling is desired, there are MOUSE_ENTERED_TARGET/MOUSE_EXITED_TARGET events. These events go through capturing/bubbling phases normally. This means that parent may receive the MOUSE_ENTERED_TARGET event when the mouse entered either the parent itself or some of its children. To distinguish between these two cases, the event target can be tested on equality with the node.

    These two types are closely connected: MOUSE_ENTERED/MOUSE_EXITED are subtypes of MOUSE_ENTERED_TARGET/MOUSE_EXITED_TARGET. During capturing phase, MOUSE_ENTERED_TARGET is delivered to the parents. When the event is delivered to the event target (the node that has actually been entered), its type is switched to MOUSE_ENTERED. Then the type is switched back to MOUSE_ENTERED_TARGET for the bubbling phase. It's still one event just switching types, so if it's filtered or consumed, it affects both event variants. Thanks to the subtype-relationship, a MOUSE_ENTERED_TARGET event handler will receive the MOUSE_ENTERED event on target.

    I placed the last sentence in bold for emphasis.