Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to consume KeyPressed event before DefaultButton action?

Tags:

java

javafx

I am having a hard time consuming an onKeyPressed event. I have a TextField in my app that allows the user to press the [ENTER] key for a certain function; however, I also have a default button specified for the scene.

While I can successfully trigger the needed actions for the key pressed while in the TextField, the default button's action is always executed first. I need to consume the event entirely for the keypress when the user is in the TextField.

See the following MCVE:

import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.TextField;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

public class Main extends Application {

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

    @Override
    public void start(Stage primaryStage) {

        // Simple UI
        VBox root = new VBox(10);
        root.setPadding(new Insets(10));
        root.setAlignment(Pos.CENTER);

        // TextField
        TextField textField = new TextField();

        // Capture the [ENTER] key
        textField.setOnKeyPressed(event -> {
            if (event.getCode() == KeyCode.ENTER) {
                System.out.println("-> Enter");
                event.consume();
            }
        });

        // Buttons
        Button btnCancel = new Button("Cancel");
        btnCancel.setCancelButton(true);
        btnCancel.setOnAction(e -> {
            System.out.println("-> Cancel");
            primaryStage.close();
        });

        Button btnSave = new Button("Save");
        btnSave.setDefaultButton(true);
        btnSave.setOnAction(e -> {
            System.out.println("-> Save");
            primaryStage.close();
        });

        ButtonBar buttonBar = new ButtonBar();
        buttonBar.getButtons().addAll(btnCancel, btnSave);

        root.getChildren().addAll(textField, buttonBar);

        primaryStage.setScene(new Scene(root));
        primaryStage.setTitle("Consume Event");
        primaryStage.show();
    }
}

The desired behavior is to be able to type in the textField and press enter. The output should show only -> Enter and the stage should remain.

However, what is currently happening is the stage closes with the following output:

-> Save
-> Enter

Do I have the event.consume() call in the wrong place? I would like to keep the default button as is.

EDIT:

This only appears to be an issue in JDK 10. I tried again using JDK 1.8.161 and it behaves as desired. Possible bug in Java 10?

Bug report submitted: View Bug Report

like image 260
Zephyr Avatar asked Jul 17 '18 18:07

Zephyr


2 Answers

As the documentation states :

Windows / Linux: A default Button receives ENTER key presses when it has focus. When the default button does not have focus, and focus is on another Button control, the ENTER key press will be received by the other, non-default Button. When focus is elsewhere in the user interface, and not on any Button, the ENTER key press will be received by the default button, if one is specified, and if no other node in the scene consumes it first.

So I believe it is a bug. As I say in the comments a workaround would be to check if the TextField has the focus inside the setOnAction of your befault button and consume the event there, until they fix it.

like image 109
JKostikiadis Avatar answered Oct 13 '22 03:10

JKostikiadis


The question is answered (it's a bug which is reported by the OP, the fix is approved and will make it into openjfx14):

  • consuming the event in the "special" (in that it is guaranteed to be the last in handlers registered for the same type/phase/event) event handler must work, that is stop dispatching the events to other interested parties
  • at that point in time we are at the start of the bubbling phase of event dispatch
  • accelerators are processed by the scene/stage, that is at the end of the bubbling phase: if all went correctly, they shouldn't be reached when consumed at its start. (Note: could not find a formal specification of when accelerators are handled, just a code comment in the scene's internal EventDispatcher of type KeyboardShortCutsHandler, so take it with a grain of salt).

But why does that happen, exactly?

Below is an example to play with: for keys like F5 all is well, the dispatch happens exactly as specified: down the scenegraph until the textField, then back up until the accelerator. The output is:

-> filter on parent:  source: VBox target: TextField
-> filter on field  source: TextField target: TextField
-> handler on field  source: TextField target: TextField
-> onKeyPressed on field  source: TextField target: TextField
-> handler on parent:  source: VBox target: TextField
-> onKeyPressed on parent  source: VBox target: TextField
in accelerator

Plus any of the handlers in the chain can consume and stop further dispatch.

Now switch to ENTER, and see how the dispatch chain gets severely confused, such that the special pressed handler gets its turn as the very last, after the accelerator. The output:

-> filter on parent:  source: VBox target: TextField
-> filter on field  source: TextField target: TextField
-> handler on field  source: TextField target: TextField
action added: javafx.event.ActionEvent[source=TextField@53c9244[styleClass=text-input text-field]]
-> filter on parent:  source: VBox target: VBox
-> handler on parent:  source: VBox target: VBox
-> onKeyPressed on parent  source: VBox target: VBox
in accelerator
-> onKeyPressed on field  source: TextField target: TextField

Consuming can be done (and works) in all handlers, except the special one on the field.

The source of the problem seems to be the manual forwarding of the keyEvent if no actionHandler had consumed it (I suspect that the forwarding code is from before the InputMap was introduced but ... didn't dig into that direction)

The example goes a bit (*cough - internal api, private fields ..) dirty and patches the textField's inputMap. The idea is to get rid off the manual forwarding and let the normal event dispatch do its job. The hook to control the normal dispatch is the event's consumed state. The patch code

  • replaces the ENTER keyMapping with a custom implementation
  • disables the autoConsume flag of the mapping, this moves the control entirely into the custom handler
  • creates and fires a ActionEvent (with both source and target set to the field, this is fixing JDK-8207774) via the field
  • sets the consumed state of the ENTER event if the action was handled, let it bubble up otherwise

Seems to work, as seen on the output of dispatch logging which now is the same as for normal keys like F5 - but beware: no formal testing done!

At last the example code:

public class TextFieldActionHandler extends Application {

    private TextField textField;

    private KeyCode actor = KeyCode.ENTER;
//    private KeyCode actor = KeyCode.F5;
    private Parent createContent() {
        textField = new TextField("just some text");
        textField.skinProperty().addListener((src, ov, nv) -> {
            replaceEnter(textField);

        });
        // only this here is in the bug report, with consume
        // https://bugs.openjdk.java.net/browse/JDK-8207774
        textField.addEventHandler(ActionEvent.ACTION, e -> {
            System.out.println("action added: " + e);
//            e.consume();
        });

        //everything else is digging around
        textField.setOnKeyPressed(event -> {
            logEvent("-> onKeyPressed on field ",  event);
        });

        textField.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
            logEvent("-> filter on field ", event);
        });

        textField.addEventHandler(KeyEvent.KEY_PRESSED, event -> {
            logEvent("-> handler on field ", event);
        });

        VBox pane = new VBox(10, textField);

        pane.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
            logEvent("-> handler on parent: ", e);
        });

        pane.addEventFilter(KeyEvent.KEY_PRESSED, e -> {
            logEvent("-> filter on parent: ", e);
        });

        //everything else is digging around
        pane.setOnKeyPressed(event -> {
            logEvent("-> onKeyPressed on parent ",  event);
        });

        return pane;
    }

    private void logEvent(String message, KeyEvent event) {
        logEvent(message, event, false);
    }

    private void logEvent(String message, KeyEvent event, boolean consume) {
        if (event.getCode() == actor) {
            System.out.println(message + " source: " + event.getSource().getClass().getSimpleName() 
                    + " target: " + event.getTarget().getClass().getSimpleName());
            if (consume)
                event.consume();    
        }

    }
    @Override
    public void start(Stage stage) throws Exception {
        Scene scene = new Scene(createContent());
        scene.getAccelerators().put(KeyCombination.keyCombination(actor.getName()),
                () -> System.out.println("in accelerator"));
        stage.setScene(scene);
        stage.setTitle(FXUtils.version());
        stage.show();
    }

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

    /** 
     * fishy code snippet from TextFieldBehaviour: 
     * 
     * https://bugs.openjdk.java.net/browse/JDK-8207774
     * during fire, the actionEvent without target is copied - such that
     * the check for being consumed of the original has no effect
     */
//    @Override protected void fire(KeyEvent event) {
//        TextField textField = getNode();
//        EventHandler<ActionEvent> onAction = textField.getOnAction();
//        ActionEvent actionEvent = new ActionEvent(textField, null);
//
//        textField.commitValue();
//        textField.fireEvent(actionEvent);
//
//        if (onAction == null && !actionEvent.isConsumed()) {
//            forwardToParent(event);
//        }
//    }


    // dirty patching
    protected void replaceEnter(TextField field) {
        TextFieldBehavior behavior = (TextFieldBehavior) FXUtils.invokeGetFieldValue(
                TextFieldSkin.class, field.getSkin(), "behavior");
        InputMap<TextField> inputMap = behavior.getInputMap();
        KeyBinding binding = new KeyBinding(KeyCode.ENTER);

        KeyMapping keyMapping = new KeyMapping(binding, this::fire);
        keyMapping.setAutoConsume(false);
        // note: this fails prior to 9-ea-108
        // due to https://bugs.openjdk.java.net/browse/JDK-8150636
        inputMap.getMappings().remove(keyMapping); 
        inputMap.getMappings().add(keyMapping);
    }

    /**
     * Copy from TextFieldBehaviour, changed to set the field as
     * both source and target of the created ActionEvent.
     * 
     * @param event
     */
    protected void fire(KeyEvent event) {
        EventHandler<ActionEvent> onAction = textField.getOnAction();
        ActionEvent actionEvent = new ActionEvent(textField, textField);

        textField.commitValue();
        textField.fireEvent(actionEvent);
        // remove the manual forwarding, instead consume the keyEvent if
        // the action handler has consumed the actionEvent
        // this way, the normal event dispatch can jump in with the normal
        // sequence
        if (onAction != null || actionEvent.isConsumed()) {
            event.consume();
        }
        // original code
//        if (onAction == null && !actionEvent.isConsumed()) {
////            forwardToParent(event);
//        }
        logEvent("in fire: " + event.isConsumed(), event);
    }

    protected void forwardToParent(KeyEvent event) {
        if (textField.getParent() !=  null) {
            textField.getParent().fireEvent(event);
        }
    }

    @SuppressWarnings("unused")
    private static final Logger LOG = Logger
            .getLogger(TextFieldActionHandler.class.getName());

}
like image 20
kleopatra Avatar answered Oct 13 '22 03:10

kleopatra