Skip to content

Commit 457d9cd

Browse files
author
Martin Fox
committed
8360886: Cmd + plus shortcut does not work reliably
Reviewed-by: angorya, mstrauss
1 parent 657d34f commit 457d9cd

File tree

2 files changed

+301
-27
lines changed

2 files changed

+301
-27
lines changed

modules/javafx.graphics/src/main/native-glass/mac/GlassView3D.m

Lines changed: 9 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -475,33 +475,15 @@ - (BOOL)performKeyEquivalent:(NSEvent *)theEvent
475475
// as it is passed through as two calls to performKeyEquivalent, which in turn
476476
// create extra KeyEvents.
477477
//
478-
NSString *chars = [theEvent charactersIgnoringModifiers];
479-
if ([theEvent type] == NSEventTypeKeyDown && [chars length] > 0)
480-
{
481-
unichar uch = [chars characterAtIndex:0];
482-
if ([theEvent modifierFlags] & NSEventModifierFlagCommand &&
483-
(uch == com_sun_glass_events_KeyEvent_VK_PERIOD ||
484-
uch == com_sun_glass_events_KeyEvent_VK_EQUALS))
485-
{
486-
GET_MAIN_JENV;
487-
488-
jcharArray jKeyChars = GetJavaKeyChars(env, theEvent);
489-
jint jModifiers = GetJavaModifiers(theEvent);
490-
491-
(*env)->CallBooleanMethod(env, self->_delegate->jView, jViewNotifyKeyAndReturnConsumed,
492-
com_sun_glass_events_KeyEvent_PRESS,
493-
uch, jKeyChars, jModifiers);
494-
(*env)->CallBooleanMethod(env, self->_delegate->jView, jViewNotifyKeyAndReturnConsumed,
495-
com_sun_glass_events_KeyEvent_TYPED,
496-
uch, jKeyChars, jModifiers);
497-
(*env)->CallBooleanMethod(env, self->_delegate->jView, jViewNotifyKeyAndReturnConsumed,
498-
com_sun_glass_events_KeyEvent_RELEASE,
499-
uch, jKeyChars, jModifiers);
500-
(*env)->DeleteLocalRef(env, jKeyChars);
501-
502-
GLASS_CHECK_EXCEPTION(env);
503-
return YES;
504-
}
478+
// If the user presses Command-"=" on a US keyboard the OS will send that
479+
// to performKeyEquivalent. If it isn't handled it will then send
480+
// Command-"+". This allows a user to invoke Command-"+" without using
481+
// the Shift key. The OS does this for any key where + is the shifted
482+
// character above =. It does something similar with the period key;
483+
// Command-"." leads to Escape for dismissing dialogs. Here we detect and
484+
// ignore the second key event.
485+
if (theEvent != NSApp.currentEvent && NSApp.currentEvent == lastKeyEvent) {
486+
return YES;
505487
}
506488

507489
BOOL result = [self handleKeyDown: theEvent];
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
/*
2+
* Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved.
3+
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4+
*
5+
* This code is free software; you can redistribute it and/or modify it
6+
* under the terms of the GNU General Public License version 2 only, as
7+
* published by the Free Software Foundation. Oracle designates this
8+
* particular file as subject to the "Classpath" exception as provided
9+
* by Oracle in the LICENSE file that accompanied this code.
10+
*
11+
* This code is distributed in the hope that it will be useful, but WITHOUT
12+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
14+
* version 2 for more details (a copy is included in the LICENSE file that
15+
* accompanied this code).
16+
*
17+
* You should have received a copy of the GNU General Public License version
18+
* 2 along with this work; if not, write to the Free Software Foundation,
19+
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
20+
*
21+
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
22+
* or visit www.oracle.com if you need additional information or have any
23+
* questions.
24+
*/
25+
26+
package test.robot.javafx.scene;
27+
28+
import java.util.Timer;
29+
import java.util.TimerTask;
30+
import java.util.concurrent.CountDownLatch;
31+
import java.util.concurrent.atomic.AtomicReference;
32+
33+
import javafx.application.Application;
34+
import javafx.application.Platform;
35+
import javafx.event.EventHandler;
36+
import javafx.scene.Parent;
37+
import javafx.scene.Scene;
38+
import javafx.scene.control.TextArea;
39+
import javafx.scene.input.KeyCode;
40+
import javafx.scene.input.KeyCombination;
41+
import javafx.scene.input.KeyCharacterCombination;
42+
import javafx.scene.input.KeyEvent;
43+
import javafx.scene.input.MouseButton;
44+
import javafx.scene.robot.Robot;
45+
import javafx.stage.Stage;
46+
47+
import org.junit.jupiter.api.AfterAll;
48+
import org.junit.jupiter.api.Assertions;
49+
import org.junit.jupiter.api.Assumptions;
50+
import org.junit.jupiter.api.BeforeAll;
51+
import org.junit.jupiter.api.Test;
52+
import org.junit.jupiter.api.Timeout;
53+
import org.junit.jupiter.params.ParameterizedTest;
54+
import org.junit.jupiter.params.provider.EnumSource;
55+
56+
import com.sun.javafx.PlatformUtil;
57+
58+
import test.util.Util;
59+
60+
// Test a series of KeyCodes verifying that they at least generate a
61+
// KEY_PRESSED event with the matching code. If the key generates a character
62+
// we can also verify that the KEY_PRESSED event for that character matches
63+
// the expected KeyCharacterCombination.
64+
public class ShortcutKeyboardTest {
65+
66+
static CountDownLatch startupLatch = new CountDownLatch(1);
67+
68+
static volatile TestApp testApp;
69+
static volatile Stage stage;
70+
static volatile boolean isLatin = false;
71+
72+
private enum KeyData {
73+
// These two keys are special-cased by macOS and can lead to multiple
74+
// calls to performKeyEquivalent. The platform code has logic to
75+
// prevent multiple KeyEvents from firing.
76+
EQUALS(KeyCode.EQUALS, "="),
77+
PERIOD(KeyCode.PERIOD, "."),
78+
79+
PLUS(KeyCode.PLUS, "+"),
80+
MINUS(KeyCode.MINUS, "-"),
81+
COMMA(KeyCode.COMMA, ","),
82+
83+
ADD(KeyCode.ADD, "+"),
84+
SUBTRACT(KeyCode.SUBTRACT, "-"),
85+
86+
A(KeyCode.A, "a"),
87+
Q(KeyCode.Q, "q"),
88+
Y(KeyCode.Y, "y"),
89+
Z(KeyCode.Z, "z");
90+
91+
final public KeyCode code;
92+
final public String combinationChar;
93+
94+
KeyData(KeyCode k, String c) {
95+
code = k;
96+
combinationChar = c;
97+
}
98+
};
99+
100+
@ParameterizedTest(name = "{0}")
101+
@EnumSource(KeyData.class)
102+
@Timeout(value = 3)
103+
void testKey(KeyData keyData) {
104+
Assumptions.assumeTrue(PlatformUtil.isMac(), "Mac-only test");
105+
Assumptions.assumeTrue(isLatin, "Non-Latin layout");
106+
Util.runAndWait(() -> testApp.testShortcutKey(keyData.code, keyData.combinationChar));
107+
String result = testApp.getTestResult();
108+
if (result != null) {
109+
Assertions.fail(result);
110+
}
111+
}
112+
113+
@BeforeAll
114+
@Timeout(value = 15)
115+
static void initFX() throws Exception {
116+
Util.launch(startupLatch, TestApp.class);
117+
118+
// When run from the command line Windows does not want to
119+
// activate the window.
120+
if (PlatformUtil.isWindows()) {
121+
Util.runAndWait(() -> {
122+
var robot = new Robot();
123+
var oldPosition = robot.getMousePosition();
124+
var root = stage.getScene().getRoot();
125+
var bounds = root.getBoundsInLocal();
126+
var mouseX = (bounds.getMinX() + bounds.getMaxX()) / 2.0;
127+
var mouseY = (bounds.getMinY() + bounds.getMaxY()) / 2.0;
128+
var clickPoint = root.localToScreen(mouseX, mouseY);
129+
robot.mouseMove(clickPoint);
130+
robot.mouseClick(MouseButton.PRIMARY);
131+
robot.mouseMove(oldPosition);
132+
});
133+
}
134+
135+
Util.runAndWait(() -> testApp.testLatin());
136+
}
137+
138+
@AfterAll
139+
static void exit() {
140+
Util.shutdown();
141+
}
142+
143+
public static class TestApp extends Application {
144+
// We throw key events at a TextArea to ensure that the input method
145+
// logic is active.
146+
private final TextArea focusNode = new TextArea();
147+
private final AtomicReference<String> testResult = new AtomicReference<String>(null);
148+
149+
@Override
150+
public void start(Stage primaryStage) {
151+
testApp = this;
152+
stage = primaryStage;
153+
154+
focusNode.setEditable(false);
155+
Scene scene = new Scene(focusNode, 200, 200);
156+
primaryStage.setScene(scene);
157+
primaryStage.setOnShown(event -> {
158+
Platform.runLater(startupLatch::countDown);
159+
});
160+
primaryStage.show();
161+
}
162+
163+
// At the end of the test getTestResult() will return null on success.
164+
// Otherwise it will return a string describing what failed.
165+
private void testShortcutKey(KeyCode characterKeyCode, String character) {
166+
focusNode.requestFocus();
167+
168+
final var modifierKeyCode = PlatformUtil.isMac() ? KeyCode.COMMAND : KeyCode.CONTROL;
169+
final var combination = new KeyCharacterCombination(character, KeyCombination.SHORTCUT_DOWN);
170+
171+
// We assume failure until we see the modifier key arrive.
172+
testResult.set("Did not see the initial modifer PRESSED event");
173+
174+
Object eventLoop = new Object();
175+
176+
// If we never see the modifier released something has gone wrong.
177+
var timeoutTask = new TimerTask() {
178+
@Override
179+
public void run() {
180+
Platform.runLater(() -> {
181+
testResult.set("Timeout waiting for modifier RELEASED event");
182+
Platform.exitNestedEventLoop(eventLoop, null);
183+
});
184+
}
185+
};
186+
187+
// First we should see the modifier pressed, then the accelerator character.
188+
final EventHandler<KeyEvent> pressedHandler = (e -> {
189+
if (e.getCode() == modifierKeyCode) {
190+
// So far so good. For a letter key we expect another
191+
// PRESSED event and assume failure until it arrives.
192+
// Other codes may not be present on this layout so it's
193+
// not an error if no events arrive.
194+
if (characterKeyCode.isLetterKey()) {
195+
testResult.set("Did not see character PRESSED event");
196+
}
197+
else {
198+
testResult.set(null);
199+
}
200+
}
201+
else if (e.getCode() == characterKeyCode) {
202+
testResult.set(null);
203+
if (!combination.match(e)) {
204+
testResult.set("Character key " + e.getCode() + " did not match " + combination);
205+
}
206+
}
207+
else {
208+
testResult.set("Unexpected character key " + e.getCode());
209+
}
210+
e.consume();
211+
});
212+
213+
// The test is over when the modifier is released.
214+
final EventHandler<KeyEvent> releasedHandler = (e -> {
215+
if (e.getCode() == modifierKeyCode) {
216+
timeoutTask.cancel();
217+
Platform.exitNestedEventLoop(eventLoop, null);
218+
}
219+
e.consume();
220+
});
221+
222+
focusNode.addEventFilter(KeyEvent.KEY_PRESSED, pressedHandler);
223+
focusNode.addEventFilter(KeyEvent.KEY_RELEASED, releasedHandler);
224+
final var timer = new Timer();
225+
timer.schedule(timeoutTask, 100);
226+
227+
final var robot = new Robot();
228+
robot.keyPress(modifierKeyCode);
229+
robot.keyPress(characterKeyCode);
230+
robot.keyRelease(characterKeyCode);
231+
robot.keyRelease(modifierKeyCode);
232+
233+
// Wait for the final event to arrive or the timout to fire
234+
Platform.enterNestedEventLoop(eventLoop);
235+
236+
focusNode.removeEventFilter(KeyEvent.KEY_PRESSED, pressedHandler);
237+
focusNode.removeEventFilter(KeyEvent.KEY_RELEASED, releasedHandler);
238+
timeoutTask.cancel();
239+
timer.cancel();
240+
}
241+
242+
// Send KeyCode.A and verify we get an "a" back.
243+
private void testLatin() {
244+
focusNode.requestFocus();
245+
246+
Object eventLoop = new Object();
247+
248+
// In case we don't see the release event
249+
var timeoutTask = new TimerTask() {
250+
@Override
251+
public void run() {
252+
Platform.runLater(() -> {
253+
isLatin = false;
254+
Platform.exitNestedEventLoop(eventLoop, null);
255+
});
256+
}
257+
};
258+
259+
final EventHandler<KeyEvent> typedHandler = (e -> {
260+
if (e.getCharacter().equals("a")) {
261+
isLatin = true;
262+
}
263+
e.consume();
264+
});
265+
266+
final EventHandler<KeyEvent> releasedHandler = (e -> {
267+
e.consume();
268+
Platform.exitNestedEventLoop(eventLoop, null);
269+
});
270+
271+
focusNode.addEventFilter(KeyEvent.KEY_TYPED, typedHandler);
272+
focusNode.addEventFilter(KeyEvent.KEY_RELEASED, releasedHandler);
273+
final var timer = new Timer();
274+
timer.schedule(timeoutTask, 100);
275+
276+
final Robot robot = new Robot();
277+
robot.keyPress(KeyCode.A);
278+
robot.keyRelease(KeyCode.A);
279+
280+
Platform.enterNestedEventLoop(eventLoop);
281+
282+
focusNode.removeEventFilter(KeyEvent.KEY_TYPED, typedHandler);
283+
focusNode.removeEventFilter(KeyEvent.KEY_RELEASED, releasedHandler);
284+
timeoutTask.cancel();
285+
timer.cancel();
286+
}
287+
288+
public String getTestResult() {
289+
return testResult.get();
290+
}
291+
}
292+
}

0 commit comments

Comments
 (0)