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