1 /*
2 * Copyright (c) 2010, 2018, 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 javafx.scene.control.skin;
27
28 import com.sun.javafx.scene.ParentHelper;
29 import com.sun.javafx.scene.control.FakeFocusTextField;
30 import com.sun.javafx.scene.control.Properties;
31 import com.sun.javafx.scene.control.behavior.TextInputControlBehavior;
32 import com.sun.javafx.scene.input.ExtendedInputMethodRequests;
33 import com.sun.javafx.scene.traversal.Algorithm;
34 import com.sun.javafx.scene.traversal.Direction;
35 import com.sun.javafx.scene.traversal.ParentTraversalEngine;
36 import com.sun.javafx.scene.traversal.TraversalContext;
37 import javafx.beans.InvalidationListener;
38 import javafx.beans.value.ObservableValue;
39 import javafx.css.Styleable;
40 import javafx.event.EventHandler;
41 import javafx.geometry.Bounds;
42 import javafx.geometry.HPos;
43 import javafx.geometry.Point2D;
44 import javafx.geometry.VPos;
45 import javafx.scene.AccessibleAttribute;
46 import javafx.scene.Node;
47 import javafx.scene.control.ComboBoxBase;
48 import javafx.scene.control.PopupControl;
49 import javafx.scene.control.Skin;
50 import javafx.scene.control.Skinnable;
51 import javafx.scene.control.TextField;
52 import javafx.scene.input.DragEvent;
53 import javafx.scene.input.KeyCode;
54 import javafx.scene.input.KeyEvent;
55 import javafx.scene.input.MouseEvent;
56 import javafx.scene.layout.Region;
57 import javafx.stage.WindowEvent;
58 import javafx.util.StringConverter;
59
60 /**
61 * An abstract class that extends the functionality of {@link ComboBoxBaseSkin}
62 * to include API related to showing ComboBox-like controls as popups.
63 *
64 * @param <T> The type of the ComboBox-like control.
65 * @since 9
66 */
67 public abstract class ComboBoxPopupControl<T> extends ComboBoxBaseSkin<T> {
68
69 /***************************************************************************
70 * *
71 * Private fields *
72 * *
73 **************************************************************************/
74
75 PopupControl popup;
76
77 private boolean popupNeedsReconfiguring = true;
78
79 private final ComboBoxBase<T> comboBoxBase;
80 private TextField textField;
81
82 private String initialTextFieldValue = null;
83
84
85
86 /***************************************************************************
87 * *
88 * TextField Listeners *
89 * *
90 **************************************************************************/
91
92 private EventHandler<MouseEvent> textFieldMouseEventHandler = event -> {
93 ComboBoxBase<T> comboBoxBase = getSkinnable();
94 if (!event.getTarget().equals(comboBoxBase)) {
95 comboBoxBase.fireEvent(event.copyFor(comboBoxBase, comboBoxBase));
96 event.consume();
97 }
98 };
99 private EventHandler<DragEvent> textFieldDragEventHandler = event -> {
100 ComboBoxBase<T> comboBoxBase = getSkinnable();
101 if (!event.getTarget().equals(comboBoxBase)) {
102 comboBoxBase.fireEvent(event.copyFor(comboBoxBase, comboBoxBase));
103 event.consume();
104 }
105 };
106
107
108
109 /***************************************************************************
110 * *
111 * Constructors *
112 * *
113 **************************************************************************/
114
115 /**
116 * Creates a new instance of ComboBoxPopupControl, although note that this
117 * instance does not handle any behavior / input mappings - this needs to be
118 * handled appropriately by subclasses.
119 *
120 * @param control The control that this skin should be installed onto.
121 */
122 public ComboBoxPopupControl(ComboBoxBase<T> control) {
123 super(control);
124 this.comboBoxBase = control;
125
126 // editable input node
127 this.textField = getEditor() != null ? getEditableInputNode() : null;
128
129 // Fix for RT-29565. Without this the textField does not have a correct
130 // pref width at startup, as it is not part of the scenegraph (and therefore
131 // has no pref width until after the first measurements have been taken).
132 if (this.textField != null) {
133 getChildren().add(textField);
134 }
135
136 // move fake focus in to the textfield if the comboBox is editable
137 comboBoxBase.focusedProperty().addListener((ov, t, hasFocus) -> {
138 if (getEditor() != null) {
139 // Fix for the regression noted in a comment in RT-29885.
140 ((FakeFocusTextField)textField).setFakeFocus(hasFocus);
141 }
142 });
143
144 comboBoxBase.addEventFilter(KeyEvent.ANY, ke -> {
145 if (textField == null || getEditor() == null) {
146 handleKeyEvent(ke, false);
147 } else {
148 // This prevents a stack overflow from our rebroadcasting of the
149 // event to the textfield that occurs in the final else statement
150 // of the conditions below.
151 if (ke.getTarget().equals(textField)) return;
152
153 switch (ke.getCode()) {
154 case ESCAPE:
155 case F10:
156 // Allow to bubble up.
157 break;
158
159 case ENTER:
160 handleKeyEvent(ke, true);
161 break;
162
163 default:
164 // Fix for the regression noted in a comment in RT-29885.
165 // This forwards the event down into the TextField when
166 // the key event is actually received by the ComboBox.
167 textField.fireEvent(ke.copyFor(textField, textField));
168 ke.consume();
169 }
170 }
171 });
172
173 // RT-38978: Forward input method events to TextField if editable.
174 if (comboBoxBase.getOnInputMethodTextChanged() == null) {
175 comboBoxBase.setOnInputMethodTextChanged(event -> {
176 if (textField != null && getEditor() != null && comboBoxBase.getScene().getFocusOwner() == comboBoxBase) {
177 if (textField.getOnInputMethodTextChanged() != null) {
178 textField.getOnInputMethodTextChanged().handle(event);
179 }
180 }
181 });
182 }
183
184 // Fix for RT-36902, where focus traversal was getting stuck inside the ComboBox
185 ParentHelper.setTraversalEngine(comboBoxBase,
186 new ParentTraversalEngine(comboBoxBase, new Algorithm() {
187
188 @Override public Node select(Node owner, Direction dir, TraversalContext context) {
189 return null;
190 }
191
192 @Override public Node selectFirst(TraversalContext context) {
193 return null;
194 }
195
196 @Override public Node selectLast(TraversalContext context) {
197 return null;
198 }
199 }));
200
201 updateEditable();
202 }
203
204
205
206 /***************************************************************************
207 * *
208 * Public API *
209 * *
210 **************************************************************************/
211
212 /**
213 * This method should return the Node that will be displayed when the user
214 * clicks on the ComboBox 'button' area.
215 * @return the Node that will be displayed when the user clicks on the
216 * ComboBox 'button' area
217 */
218 protected abstract Node getPopupContent();
219
220 /**
221 * Subclasses are responsible for getting the editor. This will be removed
222 * in FX 9 when the editor property is moved up to ComboBoxBase with
223 * JDK-8130354
224 *
225 * Note: ComboBoxListViewSkin should return null if editable is false, even
226 * if the ComboBox does have an editor set.
227 * @return the editor
228 */
229 protected abstract TextField getEditor();
230
231 /**
232 * Subclasses are responsible for getting the converter. This will be
233 * removed in FX 9 when the converter property is moved up to ComboBoxBase
234 * with JDK-8130354.
235 * @return the string converter
236 */
237 protected abstract StringConverter<T> getConverter();
238
239 /** {@inheritDoc} */
240 @Override public void show() {
241 if (getSkinnable() == null) {
242 throw new IllegalStateException("ComboBox is null");
243 }
244
245 Node content = getPopupContent();
246 if (content == null) {
247 throw new IllegalStateException("Popup node is null");
248 }
249
250 if (getPopup().isShowing()) return;
251
252 positionAndShowPopup();
253 }
254
255 /** {@inheritDoc} */
256 @Override public void hide() {
257 if (popup != null && popup.isShowing()) {
258 popup.hide();
259 }
260 }
261
262
263
264 /***************************************************************************
265 * *
266 * Private implementation *
267 * *
268 **************************************************************************/
269
270 PopupControl getPopup() {
271 if (popup == null) {
272 createPopup();
273 }
274 return popup;
275 }
276
277 TextField getEditableInputNode() {
278 if (textField == null && getEditor() != null) {
279 textField = getEditor();
280 textField.setFocusTraversable(false);
281 textField.promptTextProperty().bind(comboBoxBase.promptTextProperty());
282 textField.tooltipProperty().bind(comboBoxBase.tooltipProperty());
283
284 // Fix for JDK-8145515 - in short the ComboBox was firing the event down to
285 // the TextField, and then the TextField was firing it back up to the
286 // ComboBox, resulting in stack overflows.
287 textField.getProperties().put(TextInputControlBehavior.DISABLE_FORWARD_TO_PARENT, true);
288
289 // Fix for RT-21406: ComboBox do not show initial text value
290 initialTextFieldValue = textField.getText();
291 // End of fix (see updateDisplayNode below for the related code)
292 }
293
294 return textField;
295 }
296
297 void setTextFromTextFieldIntoComboBoxValue() {
298 if (getEditor() != null) {
299 StringConverter<T> c = getConverter();
300 if (c != null) {
301 T oldValue = comboBoxBase.getValue();
302 T value = oldValue;
303 String text = textField.getText();
304
305 // conditional check here added due to RT-28245
306 if (oldValue == null && (text == null || text.isEmpty())) {
307 value = null;
308 } else {
309 try {
310 value = c.fromString(text);
311 } catch (Exception ex) {
312 // Most likely a parsing error, such as DateTimeParseException
313 }
314 }
315
316 if ((value != null || oldValue != null) && (value == null || !value.equals(oldValue))) {
317 // no point updating values needlessly if they are the same
318 comboBoxBase.setValue(value);
319 }
320
321 updateDisplayNode();
322 }
323 }
324 }
325
326 void updateDisplayNode() {
327 if (textField != null && getEditor() != null) {
328 T value = comboBoxBase.getValue();
329 StringConverter<T> c = getConverter();
330
331 if (initialTextFieldValue != null && ! initialTextFieldValue.isEmpty()) {
332 // Remainder of fix for RT-21406: ComboBox do not show initial text value
333 textField.setText(initialTextFieldValue);
334 initialTextFieldValue = null;
335 // end of fix
336 } else {
337 String stringValue = c.toString(value);
338 if (value == null || stringValue == null) {
339 textField.setText("");
340 } else if (! stringValue.equals(textField.getText())) {
341 textField.setText(stringValue);
342 }
343 }
344 }
345 }
346
347 void updateEditable() {
348 TextField newTextField = getEditor();
349
350 if (getEditor() == null) {
351 // remove event filters
352 if (textField != null) {
353 textField.removeEventFilter(MouseEvent.DRAG_DETECTED, textFieldMouseEventHandler);
354 textField.removeEventFilter(DragEvent.ANY, textFieldDragEventHandler);
355
356 comboBoxBase.setInputMethodRequests(null);
357 }
358 } else if (newTextField != null) {
359 // add event filters
360
361 // Fix for RT-31093 - drag events from the textfield were not surfacing
362 // properly for the ComboBox.
363 newTextField.addEventFilter(MouseEvent.DRAG_DETECTED, textFieldMouseEventHandler);
364 newTextField.addEventFilter(DragEvent.ANY, textFieldDragEventHandler);
365
366 // RT-38978: Forward input method requests to TextField.
367 comboBoxBase.setInputMethodRequests(new ExtendedInputMethodRequests() {
368 @Override public Point2D getTextLocation(int offset) {
369 return newTextField.getInputMethodRequests().getTextLocation(offset);
370 }
371
372 @Override public int getLocationOffset(int x, int y) {
373 return newTextField.getInputMethodRequests().getLocationOffset(x, y);
374 }
375
376 @Override public void cancelLatestCommittedText() {
377 newTextField.getInputMethodRequests().cancelLatestCommittedText();
378 }
379
380 @Override public String getSelectedText() {
381 return newTextField.getInputMethodRequests().getSelectedText();
382 }
383
384 @Override public int getInsertPositionOffset() {
385 return ((ExtendedInputMethodRequests)newTextField.getInputMethodRequests()).getInsertPositionOffset();
386 }
387
388 @Override public String getCommittedText(int begin, int end) {
389 return ((ExtendedInputMethodRequests)newTextField.getInputMethodRequests()).getCommittedText(begin, end);
390 }
391
392 @Override public int getCommittedTextLength() {
393 return ((ExtendedInputMethodRequests)newTextField.getInputMethodRequests()).getCommittedTextLength();
394 }
395 });
396 }
397
398 textField = newTextField;
399 }
400
401 private Point2D getPrefPopupPosition() {
402 return com.sun.javafx.util.Utils.pointRelativeTo(getSkinnable(), getPopupContent(), HPos.CENTER, VPos.BOTTOM, 0, 0, true);
403 }
404
405 private void positionAndShowPopup() {
406 final ComboBoxBase<T> comboBoxBase = getSkinnable();
407 if (comboBoxBase.getScene() == null) {
408 return;
409 }
410
411 final PopupControl _popup = getPopup();
412 _popup.getScene().setNodeOrientation(getSkinnable().getEffectiveNodeOrientation());
413
414
415 final Node popupContent = getPopupContent();
416 sizePopup();
417
418 Point2D p = getPrefPopupPosition();
419
420 popupNeedsReconfiguring = true;
421 reconfigurePopup();
422
423 _popup.show(comboBoxBase.getScene().getWindow(),
424 snapPositionX(p.getX()),
425 snapPositionY(p.getY()));
426
427 popupContent.requestFocus();
428
429 // second call to sizePopup here to enable proper sizing _after_ the popup
430 // has been displayed. See RT-37622 for more detail.
431 sizePopup();
432 }
433
434 private void sizePopup() {
435 final Node popupContent = getPopupContent();
436
437 if (popupContent instanceof Region) {
438 // snap to pixel
439 final Region r = (Region) popupContent;
440
441 // 0 is used here for the width due to RT-46097
442 double prefHeight = snapSizeY(r.prefHeight(0));
443 double minHeight = snapSizeY(r.minHeight(0));
444 double maxHeight = snapSizeY(r.maxHeight(0));
445 double h = snapSizeY(Math.min(Math.max(prefHeight, minHeight), Math.max(minHeight, maxHeight)));
446
447 double prefWidth = snapSizeX(r.prefWidth(h));
448 double minWidth = snapSizeX(r.minWidth(h));
449 double maxWidth = snapSizeX(r.maxWidth(h));
450 double w = snapSizeX(Math.min(Math.max(prefWidth, minWidth), Math.max(minWidth, maxWidth)));
451
452 popupContent.resize(w, h);
453 } else {
454 popupContent.autosize();
455 }
456 }
457
458 private void createPopup() {
459 popup = new PopupControl() {
460 @Override public Styleable getStyleableParent() {
461 return ComboBoxPopupControl.this.getSkinnable();
462 }
463 {
464 setSkin(new Skin<Skinnable>() {
465 @Override public Skinnable getSkinnable() { return ComboBoxPopupControl.this.getSkinnable(); }
466 @Override public Node getNode() { return getPopupContent(); }
467 @Override public void dispose() { }
468 });
469 }
470 };
471 popup.getStyleClass().add(Properties.COMBO_BOX_STYLE_CLASS);
472 popup.setConsumeAutoHidingEvents(false);
473 popup.setAutoHide(true);
474 popup.setAutoFix(true);
475 popup.setHideOnEscape(true);
476 popup.setOnAutoHide(e -> getBehavior().onAutoHide(popup));
477 popup.addEventHandler(MouseEvent.MOUSE_CLICKED, t -> {
478 // RT-18529: We listen to mouse input that is received by the popup
479 // but that is not consumed, and assume that this is due to the mouse
480 // clicking outside of the node, but in areas such as the
481 // dropshadow.
482 getBehavior().onAutoHide(popup);
483 });
484 popup.addEventHandler(WindowEvent.WINDOW_HIDDEN, t -> {
485 // Make sure the accessibility focus returns to the combo box
486 // after the window closes.
487 getSkinnable().notifyAccessibleAttributeChanged(AccessibleAttribute.FOCUS_NODE);
488 });
489
490 // Fix for RT-21207
491 InvalidationListener layoutPosListener = o -> {
492 popupNeedsReconfiguring = true;
493 reconfigurePopup();
494 };
495 getSkinnable().layoutXProperty().addListener(layoutPosListener);
496 getSkinnable().layoutYProperty().addListener(layoutPosListener);
497 getSkinnable().widthProperty().addListener(layoutPosListener);
498 getSkinnable().heightProperty().addListener(layoutPosListener);
499
500 // RT-36966 - if skinnable's scene becomes null, ensure popup is closed
501 getSkinnable().sceneProperty().addListener(o -> {
502 if (((ObservableValue)o).getValue() == null) {
503 hide();
504 } else if (getSkinnable().isShowing()) {
505 show();
506 }
507 });
508
509 }
510
511 void reconfigurePopup() {
512 // RT-26861. Don't call getPopup() here because it may cause the popup
513 // to be created too early, which leads to memory leaks like those noted
514 // in RT-32827.
515 if (popup == null) return;
516
517 final boolean isShowing = popup.isShowing();
518 if (! isShowing) return;
519
520 if (! popupNeedsReconfiguring) return;
521 popupNeedsReconfiguring = false;
522
523 final Point2D p = getPrefPopupPosition();
524
525 final Node popupContent = getPopupContent();
526 final double minWidth = popupContent.prefWidth(Region.USE_COMPUTED_SIZE);
527 final double minHeight = popupContent.prefHeight(Region.USE_COMPUTED_SIZE);
528
529 if (p.getX() > -1) popup.setAnchorX(p.getX());
530 if (p.getY() > -1) popup.setAnchorY(p.getY());
531 if (minWidth > -1) popup.setMinWidth(minWidth);
532 if (minHeight > -1) popup.setMinHeight(minHeight);
533
534 final Bounds b = popupContent.getLayoutBounds();
535 final double currentWidth = b.getWidth();
536 final double currentHeight = b.getHeight();
537 final double newWidth = currentWidth < minWidth ? minWidth : currentWidth;
538 final double newHeight = currentHeight < minHeight ? minHeight : currentHeight;
539
540 if (newWidth != currentWidth || newHeight != currentHeight) {
541 // Resizing content to resolve issues such as RT-32582 and RT-33700
542 // (where RT-33700 was introduced due to a previous fix for RT-32582)
543 popupContent.resize(newWidth, newHeight);
544 if (popupContent instanceof Region) {
545 ((Region)popupContent).setMinSize(newWidth, newHeight);
546 ((Region)popupContent).setPrefSize(newWidth, newHeight);
547 }
548 }
549 }
550
551 private void handleKeyEvent(KeyEvent ke, boolean doConsume) {
552 // When the user hits the enter key, we respond before
553 // ever giving the event to the TextField.
554 if (ke.getCode() == KeyCode.ENTER) {
555 if (ke.isConsumed() || ke.getEventType() != KeyEvent.KEY_RELEASED) {
556 return;
557 }
558 setTextFromTextFieldIntoComboBoxValue();
559
560 if (doConsume && comboBoxBase.getOnAction() != null) {
561 ke.consume();
562 } else if (textField != null) {
563 textField.fireEvent(ke);
564 }
565 } else if (ke.getCode() == KeyCode.F10 || ke.getCode() == KeyCode.ESCAPE) {
566 // RT-23275: The TextField fires F10 and ESCAPE key events
567 // up to the parent, which are then fired back at the
568 // TextField, and this ends up in an infinite loop until
569 // the stack overflows. So, here we consume these two
570 // events and stop them from going any further.
571 if (doConsume) ke.consume();
572 }
573 }
574
575
576
577 /***************************************************************************
578 * *
579 * Support classes *
580 * *
581 **************************************************************************/
582
583
584
585
586
587 /***************************************************************************
588 * *
589 * Stylesheet Handling *
590 * *
591 **************************************************************************/
592
593 }