(Please keep my CC'd, I'm not subscribed to the list) Hi folks,
while going over the Java keybinding code (trying to solve a different problem), I found some strangeness in how KeyboardManager and JComponent handle keystrokes. In particular, they try to match any registered keybindings to both the normal keycode, as well as the extended keycode from the KeyEvent. According to the documentation, the normal keycode relates to the physical position of the key on the keyboard and is not influenced by the keyboard layout. The extended keycode relates to the key pressed, according to the current keyboard layout. When a US QWERTY layout is used, both keycodes will be identical. Matching the extended keycode makes sense, if a keybinding is CTRL-Q, then you want it to work whenever you press the key labeled "Q" on the keyboard. Matching the normal keycode makes less sense to me - why would the "key-that-is-Q-on-a-QWERTY-keyboard" trigger a Ctrl-Q binding? I presume this is a sort of backward compatibility feature, but I wonder if the application should be in control of this? In any case, in the current implementation, this approach causes a problem, which I will describe below. Handling of keybindings happens by letting each JComponent track a number of "input maps", mapping keystrokes to actions. There is a map for when the component is focused, when it is the ancestor of a focused component, and when the component is in a focused window. The first two mappings are resolved by traversing the component hierarchy and letting each component check its maps, the latter map is resolved by KeyboardManager, which keeps one big map of bindings for each window. In summary, resolving keypress works like this: 1. SwingUtilities.processKeyBindings() / JComponent.processKeyBindings(): Starting with the focused component, going upwards, each component checks its keybindings for the current keyEvent. Each component first checks against the extended keycode, then the normal keycode. 2. KeyboardManager.fireKeyboardAction(), via JComponent.processKeyBindingsForAllComponents(): In the map of WHEN_IN_FOCUSED_WINDOW bindings kept by KeyboardManager for the current window, the key event is looked up and, if found, forwarded to the right JComponent. Again, this checks the extended keycode first, then the normal keycode. 3. KeyboardManager.fireKeyboardAction() / JMenuBar.processKeyBinding(): For each JMenuBar in the current window (tracked in a separate map by KeyboardManager), JMenuBar.processKeyBinding() is called, which recurses through the menu hierarchy to check the WHEN_IN_FOCUSED_WINDOW keybindings of each menu item. I assume this is needed because children of JMenus are not added to the hierarchy directly, but through a (detached) JPopupMenu instance. Note that in step 1, it actually seems like the component hierarchy is traversed in both SwingUtilities.processKeyBindings() as well as in JComponent.processKeyBindings(), which seems like it is doing double work (and applies the WHEN_FOCUSED maps even to ancestors of the focused component). However, this doesn't seem relevant to the subject of this mail, so I'll not investigate this further here. In my examples below, I will be using the Belgian AZERTY layout. For these examples, the only relevant changes are that the Q is swapped with the A, and the W is swapped with the Z. This resolution process has, AFAICS, two problems: 1. A given shortcut can be triggered by two keys. For example, when using the Belgian AZERTY layout, a Ctrl-Q shortcut will be triggered by both CTRL-Q (matching the extended keycode) as well as by CTRL-A (the key in the same place as the Q on a QWERTY keyboard, by matching the normal keycode). I guess this is really intended as a feature, but it might be confusing. One unintended side effect seems to be that, on OSX, some keyboard shortcuts (such as Ctrl-, to open preferences) are handled by the windowing system and passed to the application using a seperate notification (e.g. "Open your preferences"), in *addition* to passing the original keystroke to the application. This was reported in https://bugs.openjdk.java.net/browse/JDK-8019498 where a keybinding for Ctrl-M was triggered by pressing Ctrl-, in *addition* to opening the preferences. Note that the preferences event handling uses some OSX specific code, e.g. http://hg.openjdk.java.net/jdk9/jdk9/jdk/file/c021b855f51e/src/java.desktop/macosx/classes/com/apple/eawt/_AppEventHandler.java#l192 2. The second problem is that a component that is checked earlier in the resolution order can "hijack" keystrokes from later components. Normally, this is intentional - WHEN_FOCUSED and WHEN_ANCESTOR_OF_FOCUSED_COMPONENTS favor the focused component, or components close to them, and for WHEN_IN_FOCUSED_WINDOW bindings, no duplicate bindings should exist. However, because each step in the resolution matches both the extended and normal keycode, a component can hijack the even using the "backward compatible" normal keycode matching, even when a later component would have succesfully matched against the extended keycode. For example, when using a Belgian AZERTY layout, a button in the window binds Ctrl-Z, and a menu item binds Ctrl-W, you would expect both keypresses to trigger the corresponding action. However, in practice the button will match the Ctrl-W keypress too (by matching the normal keycode), preventing the menu bar (which comes later in the resolution procedure) from matching the keypress, so both Ctrl-W and Ctrl-Z trigger the button. The latter problem is of course the bigger one. The obvious solution is to run through the entire resolution twice, once matching the extended keycode, then once more matching the normal keycode. To fix the first problem too, the last step could perhaps be made optional, or perhaps KeyStroke can be modified to match the normal keycode, extended keycode, or both. Not sure if this can be done in a compatible way, though, since some of the methods involved are public or protected. To confirm my analisis, I wrote a small testcase (inline below, and at https://gist.github.com/matthijskooijman/4d016e7a9e3fb07d0699). It shows both of the problems outlined above. It contains a menu item bound to Ctrl-Q, which triggers on both Ctrl-Q and Ctrl-A when using the Belgian AZERTY layout. It also contains a button binding to Ctrl-Z, which prevents a menu item bound to Ctrl-W from working when using the Belgian AZERTY layout. I have tested this on Linux, by selecting a different keyboard layout ("Input source") in the Gnome keyboard settings (without actually switching the keyboard). There seem to be a few related bug reports already. I suspect that most or all of these are caused by this problem, though not all of them have enough details to be sure. https://bugs.openjdk.java.net/browse/JDK-8087915 https://bugs.openjdk.java.net/browse/JDK-8022079 https://bugs.openjdk.java.net/browse/JDK-8019498 https://bugs.openjdk.java.net/browse/JDK-8141096 Interestingly, all of these reports are about OSX, but I'm pretty sure the problem I'm describing will occur on all platforms, being caused by platform-independent code. There might be additional OSX-specific problems, of course, but I do not have access to OSX to test. Gr. Matthijs import javax.swing.*; import java.awt.event.*; import java.awt.*; class Item extends JMenuItem { public void addNotify() { System.out.println("Add\n"); } public Item(String s) { super(s); } } public class Test { public static void main(String[] args) { SwingUtilities.invokeLater(() -> { JFrame frame = new JFrame(); JMenuBar bar = new JMenuBar(); JButton button = new JButton("Button Ctrl-Z"); JTextArea text = new JTextArea(""); JMenu menu = new JMenu("Menu"); JMenuItem item1 = new Item( "Item 1" ); JMenuItem item2 = new JMenuItem( "Item 2" ); item1.addActionListener((ActionEvent e) -> text.append("Menuitem 1\n")); item2.addActionListener((ActionEvent e) -> text.append("Menuitem 2\n")); button.addActionListener((ActionEvent e) -> text.append("Button click\n")); item1.setAccelerator(KeyStroke.getKeyStroke('Q', KeyEvent.CTRL_MASK)); item2.setAccelerator(KeyStroke.getKeyStroke('W', KeyEvent.CTRL_MASK)); KeyStroke keyStroke = KeyStroke.getKeyStroke('Z', KeyEvent.CTRL_MASK); ActionListener action = (ActionEvent) -> text.append("Button key\n"); button.registerKeyboardAction(action, "action", keyStroke, JComponent.WHEN_IN_FOCUSED_WINDOW); menu.add(item1); menu.add(item2); bar.add(menu); text.setEnabled(false); frame.setLayout(new BorderLayout()); frame.add(button, BorderLayout.SOUTH); frame.add(text, BorderLayout.CENTER); frame.setJMenuBar(bar); frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); frame.setSize(200,200); frame.setVisible(true); }); } }
signature.asc
Description: Digital signature