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 }