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