javafxevent-handlingkeyboard-shortcutskeyeventactionevent

Check if "shortcut" key is pressed on action event


I'm trying to detect when a button's action is triggered (which could be through clicking or something else like the keyboard) while the "shortcut" key is pressed. I couldn't find a way to get the keys pressed from an ActionEvent, so I put event filters for key press and release on the scene which keep track of which keys are pressed. Then in the button's action I check if the shortcut key is pressed.

    import javafx.application.Application;
    import javafx.scene.Scene;
    import javafx.scene.control.Button;
    import javafx.scene.input.KeyCode;
    import javafx.scene.input.KeyEvent;
    import javafx.scene.layout.VBox;
    import javafx.stage.Stage;
    import java.util.HashSet;
    import java.util.Set;
    
    public class App extends Application {
        private final Set<KeyCode> pressedKeys = new HashSet<>();
        
        public static void main(String[] args) {
            launch(args);
        }
        
        @Override
        public void start(Stage stage) {
            Button button = new Button("Click with shortcut key pressed");
            button.setOnAction(e -> {
                if (pressedKeys.contains(KeyCode.SHORTCUT)) {
                    System.out.println("Success!");
                } else {
                    System.out.println("Failure!");
                }
            });
            VBox vBox = new VBox(button);
            vBox.setStyle("-fx-background-color:red;");
            Scene scene = new Scene(vBox);
            scene.addEventFilter(KeyEvent.KEY_PRESSED, e -> {
                System.out.println("Key pressed: " + e.getCode());
                pressedKeys.add(e.getCode());
            });
            scene.addEventFilter(KeyEvent.KEY_RELEASED, e -> {
                System.out.println("Key released: " + e.getCode());
                pressedKeys.remove(e.getCode());
            });
            stage.setScene(scene);
            stage.show();
        }
        
        public static class AppRunner {
            public static void main(String[] args) {
                App.main(args);
            }
        }
    }

However I find there are 2 problems:

  1. When I'm holding down the shortcut key and clicking on the button, the action isn't fired at all (neither success or failure are printed)
  2. If I change the button's action to a click event filter I always get failure. Using IntelliJ's debugger I see the KeyCode.CONTROL key is known to be pressed, but not the SHORTCUT key, even though they are the same key in this case. How do I check if any of the pressed keys are the shortcut key in a platform independent way?
button.addEventFilter(MouseEvent.MOUSE_CLICKED, e -> {
    if (pressedKeys.contains(KeyCode.SHORTCUT)) {
        System.out.println("Success!");
    } else {
        System.out.println("Failure!");
    }
});

I also need this to work with actions on other controls, such as ComboBoxes and TextFields.

Edit: I found another bug with this approach. If the user holds down a key and switches to another scene or application then releases the key, the scene won't detect the key release and will still think the key is pressed. So I could do with a way to detect what keys are down without tracking every key press/release. How are MouseEvents informed of which keys are down so they can use MouseEvent.isShortcutDown()? Can the same method be used here?

Edit: To answer jewelsea's question for more background, it's a little complicated but imagine I have a form with multiple types of controls that can be used to set values in the form, and the form needs to be filled out many times. Therefore I want a way not only to set a value in the form but also fix it or make it the default so that for the next time it needs filling out it will be pre-populated with that value. The shortcut key is a convenient way to differentiate between setting the value without fixing it and setting it while fixing it. This can apply to shortcut-clicking a button or dropdown item from a combobox or holding the shortcut key while pressing enter in a text field. I also think answers to this question will be more useful to others if they work for action events on any control, but even getting it to work for a button would be good.


Solution

  • I managed to solve all 3 problems without re-implementing the action triggering logic. The solution can be applied to any new control just by calling makeShortcutActionable(control). It's platform-independent and works well for other modifier keys and non-modifier keys. However, the solution is somewhat hacky and for controls that use the same modifier key already (e.g., Ctrl-A/Cmd-A to select all text in a TextField or editable ComboBox) it disables the original use of the modifier key.

    Solving problem 1: The way we get the action to fire when the shortcut (or any modifier key) is pressed is to fool the control into thinking it isn't pressed. For that we consume the event if event.isShortcutDown() is true and then fire a copy of the event which thinks the shortcut key isn't pressed. The way the action event handler knows the shortcut key is pressed is by asking the scene which keeps track of which keys are pressed. Unfortunately the default event handlers of the control don't know the shortcut key is pressed so Ctrl-A/Cmd-A in a TextField won't do anything (but luckily it doesn't type an A either). Therefore for TextFields it's probably better to add an event handler that looks for shortcut-enter instead (re-implementing the action logic is easy for TextField).

    Solving problem 2: The scene not only records the KeyCodes that were pressed/released but also checks event.isShortcutDown(). When modifying events for re-firing we have to know which modifier key is the shortcut key so we use Toolkit.getToolkit().getPlatformShortcutKey(), which is what isShortcutDown() uses in the source code.

    Solving problem 3: Tracking key states through key presses and releases is insufficient when switching between windows so we use isShortcutDown() instead. The scene tracks all key and mouse events so basically once there is any interaction with the scene again it checks if the shortcut key is pressed (which happens before any action event can be triggered). Non-modifier keys can't be checked in the same way, but when they are held down they continue to fire KeyEvents. Therefore when the scene loses focus we can assume all non-modifier keys were released and when the scene gets focus again we will quickly be informed which keys are still pressed. Finally because we are firing fake events which claim the shortcut key isn't pressed we have to tell the key tracker which events are fake so it can ignore them.

    Here's an example with a Button, a ComboBox and a TextField. Each has an action which checks whether the shortcut or S key is pressed. For the TextField I opted to use an event handler for the shortcut-enter combo instead of breaking the shortcut-based keyboard commands.

    import com.sun.javafx.tk.Toolkit;
    import javafx.application.Application;
    import javafx.collections.FXCollections;
    import javafx.event.Event;
    import javafx.scene.Node;
    import javafx.scene.Scene;
    import javafx.scene.control.Button;
    import javafx.scene.control.ComboBox;
    import javafx.scene.control.TextField;
    import javafx.scene.input.*;
    import javafx.scene.layout.VBox;
    import javafx.stage.Stage;
    
    import java.util.*;
    
    public class App extends Application {
        private final PressedKeys pressedKeys = new PressedKeys();
        private final VBox root = new VBox();
        private final Scene scene = new Scene(root);
        
        public static void main(String[] args) {
            launch(args);
        }
        
        @Override
        public void start(Stage stage) {
            Button button = new Button("Click with shortcut key pressed");
            button.setOnAction(e -> doAction());
            makeShortcutActionable(button);
            ComboBox<String> comboBox = new ComboBox<>(FXCollections.observableArrayList("A", "B", "C"));
            comboBox.setOnAction(e -> doAction());
            makeShortcutActionable(comboBox);
            TextField textField = new TextField();
            textField.setOnAction(e -> doAction());
    //      makeShortcutActionable(textField);
            textField.addEventFilter(KeyEvent.KEY_PRESSED, e -> {
                if (new KeyCodeCombination(KeyCode.ENTER, KeyCombination.SHORTCUT_DOWN).match(e))
                    doAction();
            });
            root.getChildren().addAll(button, comboBox, textField);
            root.setStyle("-fx-background-color:red;");
            scene.addEventFilter(KeyEvent.ANY, e -> {
                pressedKeys.update(e);
            });
            scene.addEventFilter(MouseEvent.ANY, e -> {
                pressedKeys.update(e);
            });
            stage.focusedProperty().addListener(((observable, oldValue, newValue) -> {
                if (newValue == false) pressedKeys.clear(); // Necessary for non-modifier keys to get released
            }));
            stage.setScene(scene);
            stage.show();
        }
        
        private void makeShortcutActionable(Node node) {
            node.addEventFilter(KeyEvent.ANY, e -> removeShortcutKey(node, e));
            node.addEventFilter(MouseEvent.ANY, e -> removeShortcutKey(node, e));
        }
        
        private void doAction() {
            if (pressedKeys.contains(KeyCode.SHORTCUT)) {
                System.out.println("Success!");
            } else if (pressedKeys.contains(KeyCode.S)) {
                System.out.println("Special function!");
            } else {
                System.out.println("Failure!");
            }
        }
        
        private void removeShortcutKey(Node node, KeyEvent e) {
            if (e.isShortcutDown()) {
                e.consume();
                KeyEvent newEvent = new KeyEvent(e.getSource(), e.getTarget(), e.getEventType(), e.getCharacter(), e.getText(), e.getCode(),
                        e.isShiftDown() && !isShortcut(KeyCode.SHIFT),
                        e.isControlDown() && !isShortcut(KeyCode.CONTROL),
                        e.isAltDown() && !isShortcut(KeyCode.ALT),
                        e.isMetaDown() && !isShortcut(KeyCode.META));
                pressedKeys.fireEvent(newEvent, node);
            }
        }
        
        private void removeShortcutKey(Node node, MouseEvent e) {
            if (e.isShortcutDown()) {
                e.consume();
                MouseEvent newEvent = new MouseEvent(e.getSource(), e.getTarget(), e.getEventType(), e.getX(), e.getY(), e.getScreenX(), e.getScreenY(), e.getButton(), e.getClickCount(),
                        e.isShiftDown() && !isShortcut(KeyCode.SHIFT),
                        e.isControlDown() && !isShortcut(KeyCode.CONTROL),
                        e.isAltDown() && !isShortcut(KeyCode.ALT),
                        e.isMetaDown() && !isShortcut(KeyCode.META),
                        e.isPrimaryButtonDown(), e.isMiddleButtonDown(), e.isSecondaryButtonDown(), e.isBackButtonDown(), e.isForwardButtonDown(), e.isSynthesized(), e.isPopupTrigger(), e.isStillSincePress(), e.getPickResult());
                pressedKeys.fireEvent(newEvent, node);
            }
        }
        
        private boolean isShortcut(KeyCode keyCode) {
            return keyCode == Toolkit.getToolkit().getPlatformShortcutKey();
        }
        
        private class PressedKeys {
            private final Set<KeyCode> keys = new HashSet<>();
            private final List<Event> eventsToIgnore = new ArrayList<>();
            
            private synchronized boolean contains(KeyCode keyCode) {
                return keys.contains(keyCode);
            }
            
            private synchronized void update(KeyEvent e) {
                if (!shouldIgnore(e)) {
                    if (e.isShortcutDown()) {
                        boolean justPressed = keys.add(KeyCode.SHORTCUT);
                        if (justPressed) System.out.println("Shortcut pressed");
                    } else {
                        boolean justReleased = keys.remove(KeyCode.SHORTCUT);
                        if (justReleased) System.out.println("Shortcut released");
                    }
                    if (e.getEventType() == KeyEvent.KEY_PRESSED) {
                        keys.add(e.getCode());
                    } else if (e.getEventType() == KeyEvent.KEY_RELEASED) {
                        keys.remove(e.getCode());
                    }
                }
            }
            
            private synchronized void update(MouseEvent e) {
                if (!shouldIgnore(e)) {
                    if (e.isShortcutDown()) {
                        boolean justPressed = keys.add(KeyCode.SHORTCUT);
                        if (justPressed) System.out.println("Shortcut pressed");
                    } else {
                        boolean justReleased = keys.remove(KeyCode.SHORTCUT);
                        if (justReleased) System.out.println("Shortcut released");
                    }
                }
            }
            
            private synchronized void clear() {
                keys.clear();
            }
            
            private synchronized void fireEvent(Event e, Node node) {
                Event modE = e.copyFor(scene, node); // Events use reference equality but event filters receive different objects for what is really the same event. KeyEvent is final so we can't add an ID to the event either. But the same event object will get passed to the scene's event filter if the source and target are already correct.
                eventsToIgnore.add(modE);
                if (eventsToIgnore.size() > 100) { // Prevent a memory leak by keeping the list small
                    eventsToIgnore.remove(0);
                }
                node.fireEvent(modE);
            }
            
            private boolean shouldIgnore(Event e) {
                return eventsToIgnore.contains(e);
            }
        }
        
        public static class AppRunner {
            public static void main(String[] args) {
                App.main(args);
            }
        }
    }