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