1 /******************************************************************************* 2 * Copyright (c) 2005, 2019 IBM Corporation and others. 3 * 4 * This program and the accompanying materials 5 * are made available under the terms of the Eclipse Public License 2.0 6 * which accompanies this distribution, and is available at 7 * https://www.eclipse.org/legal/epl-2.0/ 8 * 9 * SPDX-License-Identifier: EPL-2.0 10 * 11 * Contributors: 12 * IBM Corporation - initial API and implementation 13 * Hannes Erven <hannes@erven.at> - Bug 293841 - [FieldAssist] NumLock keyDown event should not close the proposal popup [with patch] 14 *******************************************************************************/ 15 package org.eclipse.jface.fieldassist; 16 17 import java.util.ArrayList; 18 19 import org.eclipse.core.runtime.Assert; 20 import org.eclipse.core.runtime.ListenerList; 21 import org.eclipse.jface.bindings.keys.KeyStroke; 22 import org.eclipse.jface.dialogs.PopupDialog; 23 import org.eclipse.jface.preference.JFacePreferences; 24 import org.eclipse.jface.resource.JFaceResources; 25 import org.eclipse.jface.viewers.ILabelProvider; 26 import org.eclipse.swt.SWT; 27 import org.eclipse.swt.events.FocusAdapter; 28 import org.eclipse.swt.events.FocusEvent; 29 import org.eclipse.swt.events.SelectionEvent; 30 import org.eclipse.swt.events.SelectionListener; 31 import org.eclipse.swt.graphics.Color; 32 import org.eclipse.swt.graphics.Image; 33 import org.eclipse.swt.graphics.Point; 34 import org.eclipse.swt.graphics.Rectangle; 35 import org.eclipse.swt.layout.GridData; 36 import org.eclipse.swt.widgets.Combo; 37 import org.eclipse.swt.widgets.Composite; 38 import org.eclipse.swt.widgets.Control; 39 import org.eclipse.swt.widgets.Event; 40 import org.eclipse.swt.widgets.Listener; 41 import org.eclipse.swt.widgets.ScrollBar; 42 import org.eclipse.swt.widgets.Shell; 43 import org.eclipse.swt.widgets.Table; 44 import org.eclipse.swt.widgets.TableItem; 45 import org.eclipse.swt.widgets.Text; 46 47 /** 48 * ContentProposalAdapter can be used to attach content proposal behavior to a 49 * control. This behavior includes obtaining proposals, opening a popup dialog, 50 * managing the content of the control relative to the selections in the popup, 51 * and optionally opening up a secondary popup to further describe proposals. 52 * <p> 53 * A number of configurable options are provided to determine how the control 54 * content is altered when a proposal is chosen, how the content proposal popup 55 * is activated, and whether any filtering should be done on the proposals as 56 * the user types characters. 57 * <p> 58 * This class provides some overridable methods to allow clients to manually 59 * control the popup. However, most of the implementation remains private. 60 * 61 * @since 3.2 62 */ 63 public class ContentProposalAdapter { 64 65 /* 66 * The lightweight popup used to show content proposals for a text field. If 67 * additional information exists for a proposal, then selecting that 68 * proposal will result in the information being displayed in a secondary 69 * popup. 70 */ 71 class ContentProposalPopup extends PopupDialog { 72 /* 73 * The listener we install on the popup and related controls to 74 * determine when to close the popup. Some events (move, resize, close, 75 * deactivate) trigger closure as soon as they are received, simply 76 * because one of the registered listeners received them. Other events 77 * depend on additional circumstances. 78 */ 79 private final class PopupCloserListener implements Listener { 80 private boolean scrollbarClicked = false; 81 82 @Override handleEvent(final Event e)83 public void handleEvent(final Event e) { 84 85 // If focus is leaving an important widget or the field's 86 // shell is deactivating 87 if (e.type == SWT.FocusOut) { 88 scrollbarClicked = false; 89 /* 90 * Ignore this event if it's only happening because focus is 91 * moving between the popup shells, their controls, or a 92 * scrollbar. Do this in an async since the focus is not 93 * actually switched when this event is received. 94 */ 95 e.display.asyncExec(() -> { 96 if (isValid()) { 97 if (scrollbarClicked || hasFocus()) { 98 return; 99 } 100 // Workaround a problem on X and Mac, whereby at 101 // this point, the focus control is not known. 102 // This can happen, for example, when resizing 103 // the popup shell on the Mac. 104 // Check the active shell. 105 Shell activeShell = e.display.getActiveShell(); 106 if (activeShell == getShell() 107 || (infoPopup != null && infoPopup 108 .getShell() == activeShell)) { 109 return; 110 } 111 close(); 112 } 113 }); 114 return; 115 } 116 117 // Scroll bar has been clicked. Remember this for focus event 118 // processing. 119 if (e.type == SWT.Selection) { 120 scrollbarClicked = true; 121 return; 122 } 123 // For all other events, merely getting them dictates closure. 124 close(); 125 } 126 127 // Install the listeners for events that need to be monitored for 128 // popup closure. installListeners()129 void installListeners() { 130 // Listeners on this popup's table and scroll bar 131 proposalTable.addListener(SWT.FocusOut, this); 132 ScrollBar scrollbar = proposalTable.getVerticalBar(); 133 if (scrollbar != null) { 134 scrollbar.addListener(SWT.Selection, this); 135 } 136 137 // Listeners on this popup's shell 138 getShell().addListener(SWT.Deactivate, this); 139 getShell().addListener(SWT.Close, this); 140 141 // Listeners on the target control 142 control.addListener(SWT.MouseDoubleClick, this); 143 control.addListener(SWT.MouseDown, this); 144 control.addListener(SWT.Dispose, this); 145 control.addListener(SWT.FocusOut, this); 146 // Listeners on the target control's shell 147 Shell controlShell = control.getShell(); 148 controlShell.addListener(SWT.Move, this); 149 controlShell.addListener(SWT.Resize, this); 150 151 } 152 153 // Remove installed listeners removeListeners()154 void removeListeners() { 155 if (isValid()) { 156 proposalTable.removeListener(SWT.FocusOut, this); 157 ScrollBar scrollbar = proposalTable.getVerticalBar(); 158 if (scrollbar != null) { 159 scrollbar.removeListener(SWT.Selection, this); 160 } 161 162 getShell().removeListener(SWT.Deactivate, this); 163 getShell().removeListener(SWT.Close, this); 164 } 165 166 if (control != null && !control.isDisposed()) { 167 168 control.removeListener(SWT.MouseDoubleClick, this); 169 control.removeListener(SWT.MouseDown, this); 170 control.removeListener(SWT.Dispose, this); 171 control.removeListener(SWT.FocusOut, this); 172 173 Shell controlShell = control.getShell(); 174 controlShell.removeListener(SWT.Move, this); 175 controlShell.removeListener(SWT.Resize, this); 176 } 177 } 178 } 179 180 /* 181 * The listener we will install on the target control. 182 */ 183 private final class TargetControlListener implements Listener { 184 // Key events from the control 185 @Override handleEvent(Event e)186 public void handleEvent(Event e) { 187 if (!isValid()) { 188 return; 189 } 190 191 char key = e.character; 192 193 // Traverse events are handled depending on whether the 194 // event has a character. 195 if (e.type == SWT.Traverse) { 196 // If the traverse event contains a legitimate character, 197 // then we must set doit false so that the widget will 198 // receive the key event. We return immediately so that 199 // the character is handled only in the key event. 200 // See https://bugs.eclipse.org/bugs/show_bug.cgi?id=132101 201 if (key != 0) { 202 e.doit = false; 203 return; 204 } 205 // Traversal does not contain a character. Set doit true 206 // to indicate TRAVERSE_NONE will occur and that no key 207 // event will be triggered. We will check for navigation 208 // keys below. 209 e.detail = SWT.TRAVERSE_NONE; 210 e.doit = true; 211 } else { 212 // Default is to only propagate when configured that way. 213 // Some keys will always set doit to false anyway. 214 e.doit = propagateKeys; 215 } 216 217 // No character. Check for navigation keys. 218 219 if (key == 0) { 220 int newSelection = proposalTable.getSelectionIndex(); 221 int visibleRows = (proposalTable.getSize().y / proposalTable 222 .getItemHeight()) - 1; 223 switch (e.keyCode) { 224 case SWT.ARROW_UP: 225 newSelection -= 1; 226 if (newSelection < 0) { 227 newSelection = proposalTable.getItemCount() - 1; 228 } 229 // Not typical - usually we get this as a Traverse and 230 // therefore it never propagates. Added for consistency. 231 if (e.type == SWT.KeyDown) { 232 // don't propagate to control 233 e.doit = false; 234 } 235 236 break; 237 238 case SWT.ARROW_DOWN: 239 newSelection += 1; 240 if (newSelection > proposalTable.getItemCount() - 1) { 241 newSelection = 0; 242 } 243 // Not typical - usually we get this as a Traverse and 244 // therefore it never propagates. Added for consistency. 245 if (e.type == SWT.KeyDown) { 246 // don't propagate to control 247 e.doit = false; 248 } 249 250 break; 251 252 case SWT.PAGE_DOWN: 253 newSelection += visibleRows; 254 if (newSelection >= proposalTable.getItemCount()) { 255 newSelection = proposalTable.getItemCount() - 1; 256 } 257 if (e.type == SWT.KeyDown) { 258 // don't propagate to control 259 e.doit = false; 260 } 261 break; 262 263 case SWT.PAGE_UP: 264 newSelection -= visibleRows; 265 if (newSelection < 0) { 266 newSelection = 0; 267 } 268 if (e.type == SWT.KeyDown) { 269 // don't propagate to control 270 e.doit = false; 271 } 272 break; 273 274 case SWT.HOME: 275 newSelection = 0; 276 if (e.type == SWT.KeyDown) { 277 // don't propagate to control 278 e.doit = false; 279 } 280 break; 281 282 case SWT.END: 283 newSelection = proposalTable.getItemCount() - 1; 284 if (e.type == SWT.KeyDown) { 285 // don't propagate to control 286 e.doit = false; 287 } 288 break; 289 290 // If received as a Traverse, these should propagate 291 // to the control as keydown. If received as a keydown, 292 // proposals should be recomputed since the cursor 293 // position has changed. 294 case SWT.ARROW_LEFT: 295 case SWT.ARROW_RIGHT: 296 if (e.type == SWT.Traverse) { 297 e.doit = false; 298 } else { 299 e.doit = true; 300 String contents = getControlContentAdapter() 301 .getControlContents(getControl()); 302 // If there are no contents, changes in cursor 303 // position have no effect. Note also that we do 304 // not affect the filter text on ARROW_LEFT as 305 // we would with BS. 306 if (contents.length() > 0) { 307 asyncRecomputeProposals(filterText); 308 } 309 } 310 break; 311 312 // Any unknown keycodes will cause the popup to close. 313 // Modifier keys are explicitly checked and ignored because 314 // they are not complete yet (no character). 315 default: 316 if (e.keyCode != SWT.CAPS_LOCK && e.keyCode != SWT.NUM_LOCK 317 && e.keyCode != SWT.MOD1 318 && e.keyCode != SWT.MOD2 319 && e.keyCode != SWT.MOD3 320 && e.keyCode != SWT.MOD4) { 321 close(); 322 } 323 return; 324 } 325 326 // If any of these navigation events caused a new selection, 327 // then handle that now and return. 328 if (newSelection >= 0) { 329 selectProposal(newSelection); 330 } 331 return; 332 } 333 334 // key != 0 335 // Check for special keys involved in cancelling, accepting, or 336 // filtering the proposals. 337 switch (key) { 338 case SWT.ESC: 339 e.doit = false; 340 close(); 341 break; 342 343 case SWT.LF: 344 case SWT.CR: 345 e.doit = false; 346 Object p = getSelectedProposal(); 347 if (p != null) { 348 acceptCurrentProposal(); 349 } else { 350 close(); 351 } 352 break; 353 354 case SWT.TAB: 355 e.doit = false; 356 getShell().setFocus(); 357 return; 358 359 case SWT.BS: 360 // Backspace should back out of any stored filter text 361 if (filterStyle != FILTER_NONE) { 362 // We have no filter to back out of, so do nothing 363 if (filterText.length() == 0) { 364 return; 365 } 366 // There is filter to back out of 367 filterText = filterText.substring(0, filterText 368 .length() - 1); 369 asyncRecomputeProposals(filterText); 370 return; 371 } 372 // There is no filtering provided by us, but some 373 // clients provide their own filtering based on content. 374 // Recompute the proposals if the cursor position 375 // will change (is not at 0). 376 int pos = getControlContentAdapter().getCursorPosition( 377 getControl()); 378 // We rely on the fact that the contents and pos do not yet 379 // reflect the result of the BS. If the contents were 380 // already empty, then BS should not cause 381 // a recompute. 382 if (pos > 0) { 383 asyncRecomputeProposals(filterText); 384 } 385 break; 386 387 default: 388 // If the key is a defined unicode character, and not one of 389 // the special cases processed above, update the filter text 390 // and filter the proposals. 391 if (Character.isDefined(key)) { 392 if (filterStyle == FILTER_CUMULATIVE) { 393 filterText = filterText + key; 394 } else if (filterStyle == FILTER_CHARACTER) { 395 filterText = String.valueOf(key); 396 } 397 // Recompute proposals after processing this event. 398 asyncRecomputeProposals(filterText); 399 } 400 break; 401 } 402 } 403 } 404 405 /* 406 * Internal class used to implement the secondary popup. 407 */ 408 private class InfoPopupDialog extends PopupDialog { 409 410 /* 411 * The text control that displays the text. 412 */ 413 private Text text; 414 415 /* 416 * The String shown in the popup. 417 */ 418 private String contents = EMPTY; 419 420 /* 421 * Construct an info-popup with the specified parent. 422 */ InfoPopupDialog(Shell parent)423 InfoPopupDialog(Shell parent) { 424 super(parent, PopupDialog.HOVER_SHELLSTYLE, false, false, false, 425 false, false, null, null); 426 } 427 428 /* 429 * Create a text control for showing the info about a proposal. 430 */ 431 @Override createDialogArea(Composite parent)432 protected Control createDialogArea(Composite parent) { 433 text = new Text(parent, SWT.MULTI | SWT.READ_ONLY | SWT.WRAP | SWT.NO_FOCUS); 434 435 // Use the compact margins employed by PopupDialog. 436 GridData gd = new GridData(GridData.BEGINNING | GridData.FILL_BOTH); 437 gd.horizontalIndent = PopupDialog.POPUP_HORIZONTALSPACING; 438 gd.verticalIndent = PopupDialog.POPUP_VERTICALSPACING; 439 text.setLayoutData(gd); 440 text.setText(contents); 441 442 // since SWT.NO_FOCUS is only a hint... 443 text.addFocusListener(new FocusAdapter() { 444 @Override 445 public void focusGained(FocusEvent event) { 446 ContentProposalPopup.this.close(); 447 } 448 }); 449 return text; 450 } 451 452 /* 453 * Adjust the bounds so that we appear adjacent to our parent shell 454 */ 455 @Override adjustBounds()456 protected void adjustBounds() { 457 Rectangle parentBounds = getParentShell().getBounds(); 458 Rectangle proposedBounds; 459 // Try placing the info popup to the right 460 Rectangle rightProposedBounds = new Rectangle(parentBounds.x 461 + parentBounds.width 462 + PopupDialog.POPUP_HORIZONTALSPACING, parentBounds.y 463 + PopupDialog.POPUP_VERTICALSPACING, 464 parentBounds.width, parentBounds.height); 465 rightProposedBounds = getConstrainedShellBounds(rightProposedBounds); 466 // If it won't fit on the right, try the left 467 if (rightProposedBounds.intersects(parentBounds)) { 468 Rectangle leftProposedBounds = new Rectangle(parentBounds.x 469 - parentBounds.width - POPUP_HORIZONTALSPACING - 1, 470 parentBounds.y, parentBounds.width, 471 parentBounds.height); 472 leftProposedBounds = getConstrainedShellBounds(leftProposedBounds); 473 // If it won't fit on the left, choose the proposed bounds 474 // that fits the best 475 if (leftProposedBounds.intersects(parentBounds)) { 476 if (rightProposedBounds.x - parentBounds.x >= parentBounds.x 477 - leftProposedBounds.x) { 478 rightProposedBounds.x = parentBounds.x 479 + parentBounds.width 480 + PopupDialog.POPUP_HORIZONTALSPACING; 481 proposedBounds = rightProposedBounds; 482 } else { 483 leftProposedBounds.width = parentBounds.x 484 - POPUP_HORIZONTALSPACING 485 - leftProposedBounds.x; 486 proposedBounds = leftProposedBounds; 487 } 488 } else { 489 // use the proposed bounds on the left 490 proposedBounds = leftProposedBounds; 491 } 492 } else { 493 // use the proposed bounds on the right 494 proposedBounds = rightProposedBounds; 495 } 496 getShell().setBounds(proposedBounds); 497 } 498 499 @Override getForeground()500 protected Color getForeground() { 501 return control.getDisplay(). 502 getSystemColor(SWT.COLOR_INFO_FOREGROUND); 503 } 504 505 @Override getBackground()506 protected Color getBackground() { 507 return control.getDisplay(). 508 getSystemColor(SWT.COLOR_INFO_BACKGROUND); 509 } 510 511 /* 512 * Set the text contents of the popup. 513 */ setContents(String newContents)514 void setContents(String newContents) { 515 if (newContents == null) { 516 newContents = EMPTY; 517 } 518 this.contents = newContents; 519 if (text != null && !text.isDisposed()) { 520 text.setText(contents); 521 } 522 } 523 524 /* 525 * Return whether the popup has focus. 526 */ hasFocus()527 boolean hasFocus() { 528 if (text == null || text.isDisposed()) { 529 return false; 530 } 531 return text.getShell().isFocusControl() 532 || text.isFocusControl(); 533 } 534 } 535 536 /* 537 * The listener installed on the target control. 538 */ 539 private Listener targetControlListener; 540 541 /* 542 * The listener installed in order to close the popup. 543 */ 544 private PopupCloserListener popupCloser; 545 546 /* 547 * The table used to show the list of proposals. 548 */ 549 private Table proposalTable; 550 551 /* 552 * The proposals to be shown (cached to avoid repeated requests). 553 */ 554 private IContentProposal[] proposals; 555 556 /* 557 * Secondary popup used to show detailed information about the selected 558 * proposal.. 559 */ 560 private InfoPopupDialog infoPopup; 561 562 /* 563 * Flag indicating whether there is a pending secondary popup update. 564 */ 565 private boolean pendingDescriptionUpdate = false; 566 567 /* 568 * Filter text - tracked while popup is open, only if we are told to 569 * filter 570 */ 571 private String filterText = EMPTY; 572 573 /** 574 * Constructs a new instance of this popup, specifying the control for 575 * which this popup is showing content, and how the proposals should be 576 * obtained and displayed. 577 * 578 * @param infoText 579 * Text to be shown in a lower info area, or 580 * <code>null</code> if there is no info area. 581 */ ContentProposalPopup(String infoText, IContentProposal[] proposals)582 ContentProposalPopup(String infoText, IContentProposal[] proposals) { 583 // IMPORTANT: Use of SWT.ON_TOP is critical here for ensuring 584 // that the target control retains focus on Mac and Linux. Without 585 // it, the focus will disappear, keystrokes will not go to the 586 // popup, and the popup closer will wrongly close the popup. 587 // On platforms where SWT.ON_TOP overrides SWT.RESIZE, we will live 588 // with this. 589 // See https://bugs.eclipse.org/bugs/show_bug.cgi?id=126138 590 super(control.getShell(), SWT.RESIZE | SWT.ON_TOP, false, false, false, 591 false, false, null, infoText); 592 this.proposals = proposals; 593 } 594 595 @Override getForeground()596 protected Color getForeground() { 597 return JFaceResources.getColorRegistry().get( 598 JFacePreferences.CONTENT_ASSIST_FOREGROUND_COLOR); 599 } 600 601 @Override getBackground()602 protected Color getBackground() { 603 return JFaceResources.getColorRegistry().get( 604 JFacePreferences.CONTENT_ASSIST_BACKGROUND_COLOR); 605 } 606 607 /* 608 * Creates the content area for the proposal popup. This creates a table 609 * and places it inside the composite. The table will contain a list of 610 * all the proposals. 611 * 612 * @param parent The parent composite to contain the dialog area; must 613 * not be <code>null</code>. 614 */ 615 @Override createDialogArea(final Composite parent)616 protected final Control createDialogArea(final Composite parent) { 617 proposalTable = new Table(parent, SWT.H_SCROLL | SWT.V_SCROLL | SWT.VIRTUAL); 618 619 Listener listener = event -> handleSetData(event); 620 proposalTable.addListener(SWT.SetData, listener); 621 // set the proposals to force population of the table. 622 setProposals(filterProposals(proposals, filterText)); 623 proposalTable.setTextDirection(SWT.AUTO_TEXT_DIRECTION); 624 625 proposalTable.setHeaderVisible(false); 626 proposalTable.addSelectionListener(new SelectionListener() { 627 628 @Override 629 public void widgetSelected(SelectionEvent e) { 630 // If a proposal has been selected, show it in the secondary 631 // popup. Otherwise close the popup. 632 if (e.item == null) { 633 if (infoPopup != null) { 634 infoPopup.close(); 635 } 636 } else { 637 showProposalDescription(); 638 } 639 } 640 641 // Default selection was made. Accept the current proposal. 642 @Override 643 public void widgetDefaultSelected(SelectionEvent e) { 644 acceptCurrentProposal(); 645 } 646 }); 647 return proposalTable; 648 } 649 650 @Override adjustBounds()651 protected void adjustBounds() { 652 // Get our control's location in display coordinates. 653 Point location = control.getDisplay().map(control.getParent(), null, control.getLocation()); 654 int initialX = location.x + POPUP_OFFSET; 655 int initialY = location.y + control.getSize().y + POPUP_OFFSET; 656 // If we are inserting content, use the cursor position to 657 // position the control. 658 if (getProposalAcceptanceStyle() == PROPOSAL_INSERT) { 659 Rectangle insertionBounds = controlContentAdapter 660 .getInsertionBounds(control); 661 initialX = initialX + insertionBounds.x; 662 initialY = location.y + insertionBounds.y 663 + insertionBounds.height; 664 } 665 666 // If there is no specified size, force it by setting 667 // up a layout on the table. 668 if (popupSize == null) { 669 GridData data = new GridData(GridData.FILL_BOTH); 670 data.heightHint = proposalTable.getItemHeight() 671 * POPUP_CHAR_HEIGHT; 672 data.widthHint = Math.max(control.getSize().x, 673 POPUP_MINIMUM_WIDTH); 674 proposalTable.setLayoutData(data); 675 getShell().pack(); 676 popupSize = getShell().getSize(); 677 } 678 679 int dir = proposalTable.getTextDirection(); 680 if (dir == SWT.RIGHT_TO_LEFT) { 681 initialX = initialX - popupSize.x; 682 } 683 684 // Constrain to the display 685 Rectangle constrainedBounds = getConstrainedShellBounds(new Rectangle(initialX, initialY, popupSize.x, popupSize.y)); 686 687 // If there has been an adjustment causing the popup to overlap 688 // with the control, then put the popup above the control. 689 if (constrainedBounds.y < initialY) 690 getShell().setBounds(initialX, location.y - popupSize.y, popupSize.x, popupSize.y); 691 else 692 getShell().setBounds(initialX, initialY, popupSize.x, popupSize.y); 693 694 // Now set up a listener to monitor any changes in size. 695 getShell().addListener(SWT.Resize, e -> { 696 popupSize = getShell().getSize(); 697 if (infoPopup != null) { 698 infoPopup.adjustBounds(); 699 } 700 }); 701 } 702 703 /* 704 * Handle the set data event. Set the item data of the requested item to 705 * the corresponding proposal in the proposal cache. 706 */ handleSetData(Event event)707 private void handleSetData(Event event) { 708 TableItem item = (TableItem) event.item; 709 int index = proposalTable.indexOf(item); 710 711 if (0 <= index && index < proposals.length) { 712 IContentProposal current = proposals[index]; 713 item.setText(getString(current)); 714 item.setImage(getImage(current)); 715 item.setData(current); 716 } 717 } 718 719 /* 720 * Caches the specified proposals and repopulates the table if it has 721 * been created. 722 */ setProposals(IContentProposal[] newProposals)723 private void setProposals(IContentProposal[] newProposals) { 724 if (newProposals == null || newProposals.length == 0) { 725 newProposals = getEmptyProposalArray(); 726 } 727 this.proposals = newProposals; 728 729 // If there is a table 730 if (isValid()) { 731 final int newSize = newProposals.length; 732 // Set and clear the virtual table. Data will be 733 // provided in the SWT.SetData event handler. 734 proposalTable.setItemCount(newSize); 735 proposalTable.clearAll(); 736 // Default to the first selection if there is content. 737 if (newProposals.length > 0) { 738 selectProposal(0); 739 } else { 740 // No selection, close the secondary popup if it was open 741 if (infoPopup != null) { 742 infoPopup.close(); 743 } 744 745 } 746 } 747 } 748 749 /* 750 * Get the string for the specified proposal. Always return a String of 751 * some kind. 752 */ getString(IContentProposal proposal)753 private String getString(IContentProposal proposal) { 754 if (proposal == null) { 755 return EMPTY; 756 } 757 if (labelProvider == null) { 758 return proposal.getLabel() == null ? proposal.getContent() 759 : proposal.getLabel(); 760 } 761 return labelProvider.getText(proposal); 762 } 763 764 /* 765 * Get the image for the specified proposal. If there is no image 766 * available, return null. 767 */ getImage(IContentProposal proposal)768 private Image getImage(IContentProposal proposal) { 769 if (proposal == null || labelProvider == null) { 770 return null; 771 } 772 return labelProvider.getImage(proposal); 773 } 774 775 /* 776 * Return an empty array. Used so that something always shows in the 777 * proposal popup, even if no proposal provider was specified. 778 */ getEmptyProposalArray()779 private IContentProposal[] getEmptyProposalArray() { 780 return new IContentProposal[0]; 781 } 782 783 /* 784 * Answer true if the popup is valid, which means the table has been 785 * created and not disposed. 786 */ isValid()787 private boolean isValid() { 788 return proposalTable != null && !proposalTable.isDisposed(); 789 } 790 791 /* 792 * Return whether the receiver has focus. Since 3.4, this includes a 793 * check for whether the info popup has focus. 794 */ hasFocus()795 private boolean hasFocus() { 796 if (!isValid()) { 797 return false; 798 } 799 if (getShell().isFocusControl() || proposalTable.isFocusControl()) { 800 return true; 801 } 802 if (infoPopup != null && infoPopup.hasFocus()) { 803 return true; 804 } 805 return false; 806 } 807 808 /* 809 * Return the current selected proposal. 810 */ getSelectedProposal()811 private IContentProposal getSelectedProposal() { 812 if (isValid()) { 813 int i = proposalTable.getSelectionIndex(); 814 if (proposals == null || i < 0 || i >= proposals.length) { 815 return null; 816 } 817 return proposals[i]; 818 } 819 return null; 820 } 821 822 /* 823 * Select the proposal at the given index. 824 */ selectProposal(int index)825 private void selectProposal(int index) { 826 Assert 827 .isTrue(index >= 0, 828 "Proposal index should never be negative"); //$NON-NLS-1$ 829 if (!isValid() || proposals == null || index >= proposals.length) { 830 return; 831 } 832 proposalTable.setSelection(index); 833 proposalTable.showSelection(); 834 835 showProposalDescription(); 836 } 837 838 /** 839 * Opens this ContentProposalPopup. This method is extended in order to 840 * add the control listener when the popup is opened and to invoke the 841 * secondary popup if applicable. 842 * 843 * @return the return code 844 * 845 * @see org.eclipse.jface.window.Window#open() 846 */ 847 @Override open()848 public int open() { 849 int value = super.open(); 850 if (popupCloser == null) { 851 popupCloser = new PopupCloserListener(); 852 } 853 popupCloser.installListeners(); 854 IContentProposal p = getSelectedProposal(); 855 if (p != null) { 856 showProposalDescription(); 857 } 858 return value; 859 } 860 861 /** 862 * Closes this popup. This method is extended to remove the control 863 * listener. 864 * 865 * @return <code>true</code> if the window is (or was already) closed, 866 * and <code>false</code> if it is still open 867 */ 868 @Override close()869 public boolean close() { 870 popupCloser.removeListeners(); 871 if (infoPopup != null) { 872 infoPopup.close(); 873 } 874 boolean ret = super.close(); 875 notifyPopupClosed(); 876 return ret; 877 } 878 879 /** 880 * Asynchronously recompute proposals. 881 */ refresh()882 private void refresh() { 883 asyncRecomputeProposals(filterText); 884 } 885 886 /* 887 * Show the currently selected proposal's description in a secondary 888 * popup. 889 */ showProposalDescription()890 private void showProposalDescription() { 891 // If we do not already have a pending update, then 892 // create a thread now that will show the proposal description 893 if (!pendingDescriptionUpdate) { 894 // Create a thread that will sleep for the specified delay 895 // before creating the popup. We do not use Jobs since this 896 // code must be able to run independently of the Eclipse 897 // runtime. 898 Runnable runnable = () -> { 899 pendingDescriptionUpdate = true; 900 try { 901 Thread.sleep(POPUP_DELAY); 902 } catch (InterruptedException e) { 903 } 904 if (!isValid()) { 905 return; 906 } 907 getShell().getDisplay().syncExec(() -> { 908 // Query the current selection since we have 909 // been delayed 910 IContentProposal p = getSelectedProposal(); 911 if (p != null) { 912 String description = p.getDescription(); 913 if (description != null) { 914 if (infoPopup == null) { 915 infoPopup = new InfoPopupDialog(getShell()); 916 infoPopup.open(); 917 infoPopup.getShell() 918 .addDisposeListener(event -> infoPopup = null); 919 } 920 infoPopup.setContents(p.getDescription()); 921 } else if (infoPopup != null) { 922 infoPopup.close(); 923 } 924 pendingDescriptionUpdate = false; 925 926 } 927 }); 928 }; 929 Thread t = new Thread(runnable); 930 t.start(); 931 } 932 } 933 934 /* 935 * Accept the current proposal. 936 */ acceptCurrentProposal()937 private void acceptCurrentProposal() { 938 // Close before accepting the proposal. This is important 939 // so that the cursor position can be properly restored at 940 // acceptance, which does not work without focus on some controls. 941 // See https://bugs.eclipse.org/bugs/show_bug.cgi?id=127108 942 IContentProposal proposal = getSelectedProposal(); 943 close(); 944 proposalAccepted(proposal); 945 } 946 947 /* 948 * Request the proposals from the proposal provider, and recompute any 949 * caches. Repopulate the popup if it is open. 950 */ recomputeProposals(String filterText)951 private void recomputeProposals(String filterText) { 952 IContentProposal[] allProposals = getProposals(); 953 if (allProposals == null) 954 allProposals = getEmptyProposalArray(); 955 // If the non-filtered proposal list is empty, we should 956 // close the popup. 957 // See https://bugs.eclipse.org/bugs/show_bug.cgi?id=147377 958 if (allProposals.length == 0) { 959 proposals = allProposals; 960 close(); 961 } else { 962 // Keep the popup open, but filter by any provided filter text 963 setProposals(filterProposals(allProposals, filterText)); 964 } 965 } 966 967 /* 968 * In an async block, request the proposals. This is used when clients 969 * are in the middle of processing an event that affects the widget 970 * content. By using an async, we ensure that the widget content is up 971 * to date with the event. 972 */ asyncRecomputeProposals(final String filterText)973 private void asyncRecomputeProposals(final String filterText) { 974 if (isValid()) { 975 control.getDisplay().asyncExec(() -> { 976 recordCursorPosition(); 977 recomputeProposals(filterText); 978 }); 979 } else { 980 recomputeProposals(filterText); 981 } 982 } 983 984 /* 985 * Filter the provided list of content proposals according to the filter 986 * text. 987 */ filterProposals( IContentProposal[] proposals, String filterString)988 private IContentProposal[] filterProposals( 989 IContentProposal[] proposals, String filterString) { 990 if (filterString.length() == 0) { 991 return proposals; 992 } 993 994 // Check each string for a match. Use the string displayed to the 995 // user, not the proposal content. 996 ArrayList<IContentProposal> list = new ArrayList<>(); 997 for (IContentProposal proposal : proposals) { 998 String string = getString(proposal); 999 if (string.length() >= filterString.length() 1000 && string.substring(0, filterString.length()) 1001 .equalsIgnoreCase(filterString)) { 1002 list.add(proposal); 1003 } 1004 1005 } 1006 return list.toArray(new IContentProposal[list 1007 .size()]); 1008 } 1009 getTargetControlListener()1010 Listener getTargetControlListener() { 1011 if (targetControlListener == null) { 1012 targetControlListener = new TargetControlListener(); 1013 } 1014 return targetControlListener; 1015 } 1016 } 1017 1018 /** 1019 * Flag that controls the printing of debug info. 1020 */ 1021 public static final boolean DEBUG = false; 1022 1023 /** 1024 * Indicates that a chosen proposal should be inserted into the field. 1025 */ 1026 public static final int PROPOSAL_INSERT = 1; 1027 1028 /** 1029 * Indicates that a chosen proposal should replace the entire contents of 1030 * the field. 1031 */ 1032 public static final int PROPOSAL_REPLACE = 2; 1033 1034 /** 1035 * Indicates that the contents of the control should not be modified when a 1036 * proposal is chosen. This is typically used when a client needs more 1037 * specialized behavior when a proposal is chosen. In this case, clients 1038 * typically register an IContentProposalListener so that they are notified 1039 * when a proposal is chosen. 1040 */ 1041 public static final int PROPOSAL_IGNORE = 3; 1042 1043 /** 1044 * Indicates that there should be no filter applied as keys are typed in the 1045 * popup. 1046 */ 1047 public static final int FILTER_NONE = 1; 1048 1049 /** 1050 * Indicates that a single character filter applies as keys are typed in the 1051 * popup. 1052 */ 1053 public static final int FILTER_CHARACTER = 2; 1054 1055 /** 1056 * Indicates that a cumulative filter applies as keys are typed in the 1057 * popup. That is, each character typed will be added to the filter. 1058 * 1059 * @deprecated As of 3.4, filtering that is sensitive to changes in the 1060 * control content should be performed by the supplied 1061 * {@link IContentProposalProvider}, such as that performed by 1062 * {@link SimpleContentProposalProvider} 1063 */ 1064 @Deprecated 1065 public static final int FILTER_CUMULATIVE = 3; 1066 1067 /* 1068 * The delay before showing a secondary popup. 1069 */ 1070 private static final int POPUP_DELAY = 750; 1071 1072 /* 1073 * The character height hint for the popup. May be overridden by using 1074 * setInitialPopupSize. 1075 */ 1076 private static final int POPUP_CHAR_HEIGHT = 10; 1077 1078 /* 1079 * The minimum pixel width for the popup. May be overridden by using 1080 * setInitialPopupSize. 1081 */ 1082 private static final int POPUP_MINIMUM_WIDTH = 300; 1083 1084 /* 1085 * The pixel offset of the popup from the bottom corner of the control. 1086 */ 1087 private static final int POPUP_OFFSET = 3; 1088 1089 /* 1090 * Empty string. 1091 */ 1092 private static final String EMPTY = ""; //$NON-NLS-1$ 1093 1094 /* 1095 * The object that provides content proposals. 1096 */ 1097 private IContentProposalProvider proposalProvider; 1098 1099 /* 1100 * A label provider used to display proposals in the popup, and to extract 1101 * Strings from non-String proposals. 1102 */ 1103 private ILabelProvider labelProvider; 1104 1105 /* 1106 * The control for which content proposals are provided. 1107 */ 1108 private Control control; 1109 1110 /* 1111 * The adapter used to extract the String contents from an arbitrary 1112 * control. 1113 */ 1114 private IControlContentAdapter controlContentAdapter; 1115 1116 /* 1117 * The popup used to show proposals. 1118 */ 1119 private ContentProposalPopup popup; 1120 1121 /* 1122 * The keystroke that signifies content proposals should be shown. 1123 */ 1124 private KeyStroke triggerKeyStroke; 1125 1126 /* 1127 * The String containing characters that auto-activate the popup. 1128 */ 1129 private String autoActivateString; 1130 1131 /* 1132 * Integer that indicates how an accepted proposal should affect the 1133 * control. One of PROPOSAL_IGNORE, PROPOSAL_INSERT, or PROPOSAL_REPLACE. 1134 * Default value is PROPOSAL_INSERT. 1135 */ 1136 private int proposalAcceptanceStyle = PROPOSAL_INSERT; 1137 1138 /* 1139 * A boolean that indicates whether key events received while the proposal 1140 * popup is open should also be propagated to the control. Default value is 1141 * true. 1142 */ 1143 private boolean propagateKeys = true; 1144 1145 /* 1146 * Integer that indicates the filtering style. One of FILTER_CHARACTER, 1147 * FILTER_CUMULATIVE, FILTER_NONE. 1148 */ 1149 private int filterStyle = FILTER_NONE; 1150 1151 /* 1152 * The listener we install on the control. 1153 */ 1154 private Listener controlListener; 1155 1156 /* 1157 * The list of IContentProposalListener listeners. 1158 */ 1159 private ListenerList<IContentProposalListener> proposalListeners = new ListenerList<>(); 1160 1161 /* 1162 * The list of IContentProposalListener2 listeners. 1163 */ 1164 private ListenerList<IContentProposalListener2> proposalListeners2 = new ListenerList<>(); 1165 1166 /* 1167 * Flag that indicates whether the adapter is enabled. In some cases, 1168 * adapters may be installed but depend upon outside state. 1169 */ 1170 private boolean isEnabled = true; 1171 1172 /* 1173 * The delay in milliseconds used when autoactivating the popup. 1174 */ 1175 private int autoActivationDelay = 0; 1176 1177 /* 1178 * A boolean indicating whether a keystroke has been received. Used to see 1179 * if an autoactivation delay was interrupted by a keystroke. 1180 */ 1181 private boolean receivedKeyDown; 1182 1183 /* 1184 * The desired size in pixels of the proposal popup. 1185 */ 1186 private Point popupSize; 1187 1188 /* 1189 * The remembered position of the insertion position. Not all controls will 1190 * restore the insertion position if the proposal popup gets focus, so we 1191 * need to remember it. 1192 */ 1193 private int insertionPos = -1; 1194 1195 /* 1196 * The remembered selection range. Not all controls will restore the 1197 * selection position if the proposal popup gets focus, so we need to 1198 * remember it. 1199 */ 1200 private Point selectionRange = new Point(-1, -1); 1201 1202 /* 1203 * A flag that indicates that we are watching modify events 1204 */ 1205 private boolean watchModify = false; 1206 1207 /** 1208 * Construct a content proposal adapter that can assist the user with 1209 * choosing content for the field. 1210 * 1211 * @param control 1212 * the control for which the adapter is providing content assist. 1213 * May not be <code>null</code>. 1214 * @param controlContentAdapter 1215 * the <code>IControlContentAdapter</code> used to obtain and 1216 * update the control's contents as proposals are accepted. May 1217 * not be <code>null</code>. 1218 * @param proposalProvider 1219 * the <code>IContentProposalProvider</code> used to obtain 1220 * content proposals for this control, or <code>null</code> if 1221 * no content proposal is available. 1222 * @param keyStroke 1223 * the keystroke that will invoke the content proposal popup. If 1224 * this value is <code>null</code>, then proposals will be 1225 * activated automatically when any of the auto activation 1226 * characters are typed. 1227 * @param autoActivationCharacters 1228 * An array of characters that trigger auto-activation of content 1229 * proposal. If specified, these characters will trigger 1230 * auto-activation of the proposal popup, regardless of whether 1231 * an explicit invocation keyStroke was specified. If this 1232 * parameter is <code>null</code>, then only a specified 1233 * keyStroke will invoke content proposal. If this parameter is 1234 * <code>null</code> and the keyStroke parameter is 1235 * <code>null</code>, then all alphanumeric characters will 1236 * auto-activate content proposal. 1237 */ ContentProposalAdapter(Control control, IControlContentAdapter controlContentAdapter, IContentProposalProvider proposalProvider, KeyStroke keyStroke, char[] autoActivationCharacters)1238 public ContentProposalAdapter(Control control, 1239 IControlContentAdapter controlContentAdapter, 1240 IContentProposalProvider proposalProvider, KeyStroke keyStroke, 1241 char[] autoActivationCharacters) { 1242 super(); 1243 // We always assume the control and content adapter are valid. 1244 Assert.isNotNull(control); 1245 Assert.isNotNull(controlContentAdapter); 1246 this.control = control; 1247 this.controlContentAdapter = controlContentAdapter; 1248 1249 // The rest of these may be null 1250 this.proposalProvider = proposalProvider; 1251 this.triggerKeyStroke = keyStroke; 1252 if (autoActivationCharacters != null) { 1253 this.autoActivateString = new String(autoActivationCharacters); 1254 } 1255 addControlListener(control); 1256 } 1257 1258 /** 1259 * Get the control on which the content proposal adapter is installed. 1260 * 1261 * @return the control on which the proposal adapter is installed. 1262 */ getControl()1263 public Control getControl() { 1264 return control; 1265 } 1266 1267 /** 1268 * Get the label provider that is used to show proposals. 1269 * 1270 * @return the {@link ILabelProvider} used to show proposals, or 1271 * <code>null</code> if one has not been installed. 1272 */ getLabelProvider()1273 public ILabelProvider getLabelProvider() { 1274 return labelProvider; 1275 } 1276 1277 /** 1278 * Return a boolean indicating whether the receiver is enabled. 1279 * 1280 * @return <code>true</code> if the adapter is enabled, and 1281 * <code>false</code> if it is not. 1282 */ isEnabled()1283 public boolean isEnabled() { 1284 return isEnabled; 1285 } 1286 1287 /** 1288 * Set the label provider that is used to show proposals. The lifecycle of 1289 * the specified label provider is not managed by this adapter. Clients must 1290 * dispose the label provider when it is no longer needed. 1291 * 1292 * @param labelProvider 1293 * the {@link ILabelProvider} used to show proposals. 1294 */ setLabelProvider(ILabelProvider labelProvider)1295 public void setLabelProvider(ILabelProvider labelProvider) { 1296 this.labelProvider = labelProvider; 1297 } 1298 1299 /** 1300 * Return the proposal provider that provides content proposals given the 1301 * current content of the field. A value of <code>null</code> indicates 1302 * that there are no content proposals available for the field. 1303 * 1304 * @return the {@link IContentProposalProvider} used to show proposals. May 1305 * be <code>null</code>. 1306 */ getContentProposalProvider()1307 public IContentProposalProvider getContentProposalProvider() { 1308 return proposalProvider; 1309 } 1310 1311 /** 1312 * Set the content proposal provider that is used to show proposals. 1313 * 1314 * @param proposalProvider 1315 * the {@link IContentProposalProvider} used to show proposals 1316 */ setContentProposalProvider( IContentProposalProvider proposalProvider)1317 public void setContentProposalProvider( 1318 IContentProposalProvider proposalProvider) { 1319 this.proposalProvider = proposalProvider; 1320 } 1321 1322 /** 1323 * Return the array of characters on which the popup is autoactivated. 1324 * 1325 * @return An array of characters that trigger auto-activation of content 1326 * proposal. If specified, these characters will trigger 1327 * auto-activation of the proposal popup, regardless of whether an 1328 * explicit invocation keyStroke was specified. If this parameter is 1329 * <code>null</code>, then only a specified keyStroke will invoke 1330 * content proposal. If this value is <code>null</code> and the 1331 * keyStroke value is <code>null</code>, then all alphanumeric 1332 * characters will auto-activate content proposal. 1333 */ getAutoActivationCharacters()1334 public char[] getAutoActivationCharacters() { 1335 if (autoActivateString == null) { 1336 return null; 1337 } 1338 return autoActivateString.toCharArray(); 1339 } 1340 1341 /** 1342 * Set the array of characters that will trigger autoactivation of the 1343 * popup. 1344 * 1345 * @param autoActivationCharacters 1346 * An array of characters that trigger auto-activation of content 1347 * proposal. If specified, these characters will trigger 1348 * auto-activation of the proposal popup, regardless of whether 1349 * an explicit invocation keyStroke was specified. If this 1350 * parameter is <code>null</code>, then only a specified 1351 * keyStroke will invoke content proposal. If this parameter is 1352 * <code>null</code> and the keyStroke value is 1353 * <code>null</code>, then all alphanumeric characters will 1354 * auto-activate content proposal. 1355 * 1356 */ setAutoActivationCharacters(char[] autoActivationCharacters)1357 public void setAutoActivationCharacters(char[] autoActivationCharacters) { 1358 if (autoActivationCharacters == null) { 1359 this.autoActivateString = null; 1360 } else { 1361 this.autoActivateString = new String(autoActivationCharacters); 1362 } 1363 } 1364 1365 /** 1366 * Set the delay, in milliseconds, used before any autoactivation is 1367 * triggered. 1368 * 1369 * @return the time in milliseconds that will pass before a popup is 1370 * automatically opened 1371 */ getAutoActivationDelay()1372 public int getAutoActivationDelay() { 1373 return autoActivationDelay; 1374 1375 } 1376 1377 /** 1378 * Set the delay, in milliseconds, used before autoactivation is triggered. 1379 * 1380 * @param delay 1381 * the time in milliseconds that will pass before a popup is 1382 * automatically opened 1383 */ setAutoActivationDelay(int delay)1384 public void setAutoActivationDelay(int delay) { 1385 autoActivationDelay = delay; 1386 1387 } 1388 1389 /** 1390 * Get the integer style that indicates how an accepted proposal affects the 1391 * control's content. 1392 * 1393 * @return a constant indicating how an accepted proposal should affect the 1394 * control's content. Should be one of <code>PROPOSAL_INSERT</code>, 1395 * <code>PROPOSAL_REPLACE</code>, or <code>PROPOSAL_IGNORE</code>. 1396 * (Default is <code>PROPOSAL_INSERT</code>). 1397 */ getProposalAcceptanceStyle()1398 public int getProposalAcceptanceStyle() { 1399 return proposalAcceptanceStyle; 1400 } 1401 1402 /** 1403 * Set the integer style that indicates how an accepted proposal affects the 1404 * control's content. 1405 * 1406 * @param acceptance 1407 * a constant indicating how an accepted proposal should affect 1408 * the control's content. Should be one of 1409 * <code>PROPOSAL_INSERT</code>, <code>PROPOSAL_REPLACE</code>, 1410 * or <code>PROPOSAL_IGNORE</code> 1411 */ setProposalAcceptanceStyle(int acceptance)1412 public void setProposalAcceptanceStyle(int acceptance) { 1413 proposalAcceptanceStyle = acceptance; 1414 } 1415 1416 /** 1417 * Return the integer style that indicates how keystrokes affect the content 1418 * of the proposal popup while it is open. 1419 * 1420 * @return a constant indicating how keystrokes in the proposal popup affect 1421 * filtering of the proposals shown. <code>FILTER_NONE</code> 1422 * specifies that no filtering will occur in the content proposal 1423 * list as keys are typed. <code>FILTER_CHARACTER</code> specifies 1424 * the content of the popup will be filtered by the most recently 1425 * typed character. <code>FILTER_CUMULATIVE</code> is deprecated 1426 * and no longer recommended. It specifies that the content of the 1427 * popup will be filtered by a string containing all the characters 1428 * typed since the popup has been open. The default is 1429 * <code>FILTER_NONE</code>. 1430 */ getFilterStyle()1431 public int getFilterStyle() { 1432 return filterStyle; 1433 } 1434 1435 /** 1436 * Set the integer style that indicates how keystrokes affect the content of 1437 * the proposal popup while it is open. Popup-based filtering is useful for 1438 * narrowing and navigating the list of proposals provided once the popup is 1439 * open. Filtering of the proposals will occur even when the control content 1440 * is not affected by user typing. Note that automatic filtering is not used 1441 * to achieve content-sensitive filtering such as auto-completion. Filtering 1442 * that is sensitive to changes in the control content should be performed 1443 * by the supplied {@link IContentProposalProvider}. 1444 * 1445 * @param filterStyle 1446 * a constant indicating how keystrokes received in the proposal 1447 * popup affect filtering of the proposals shown. 1448 * <code>FILTER_NONE</code> specifies that no automatic 1449 * filtering of the content proposal list will occur as keys are 1450 * typed in the popup. <code>FILTER_CHARACTER</code> specifies 1451 * that the content of the popup will be filtered by the most 1452 * recently typed character. <code>FILTER_CUMULATIVE</code> is 1453 * deprecated and no longer recommended. It specifies that the 1454 * content of the popup will be filtered by a string containing 1455 * all the characters typed since the popup has been open. 1456 */ setFilterStyle(int filterStyle)1457 public void setFilterStyle(int filterStyle) { 1458 this.filterStyle = filterStyle; 1459 } 1460 1461 /** 1462 * Return the size, in pixels, of the content proposal popup. 1463 * 1464 * @return a Point specifying the last width and height, in pixels, of the 1465 * content proposal popup. 1466 */ getPopupSize()1467 public Point getPopupSize() { 1468 return popupSize; 1469 } 1470 1471 /** 1472 * Set the size, in pixels, of the content proposal popup. This size will be 1473 * used the next time the content proposal popup is opened. 1474 * 1475 * @param size 1476 * a Point specifying the desired width and height, in pixels, of 1477 * the content proposal popup. 1478 */ setPopupSize(Point size)1479 public void setPopupSize(Point size) { 1480 popupSize = size; 1481 } 1482 1483 /** 1484 * Get the boolean that indicates whether key events (including 1485 * auto-activation characters) received by the content proposal popup should 1486 * also be propagated to the adapted control when the proposal popup is 1487 * open. 1488 * 1489 * @return a boolean that indicates whether key events (including 1490 * auto-activation characters) should be propagated to the adapted 1491 * control when the proposal popup is open. Default value is 1492 * <code>true</code>. 1493 */ getPropagateKeys()1494 public boolean getPropagateKeys() { 1495 return propagateKeys; 1496 } 1497 1498 /** 1499 * Set the boolean that indicates whether key events (including 1500 * auto-activation characters) received by the content proposal popup should 1501 * also be propagated to the adapted control when the proposal popup is 1502 * open. 1503 * 1504 * @param propagateKeys 1505 * a boolean that indicates whether key events (including 1506 * auto-activation characters) should be propagated to the 1507 * adapted control when the proposal popup is open. 1508 */ setPropagateKeys(boolean propagateKeys)1509 public void setPropagateKeys(boolean propagateKeys) { 1510 this.propagateKeys = propagateKeys; 1511 } 1512 1513 /** 1514 * Return the content adapter that can get or retrieve the text contents 1515 * from the adapter's control. This method is used when a client, such as a 1516 * content proposal listener, needs to update the control's contents 1517 * manually. 1518 * 1519 * @return the {@link IControlContentAdapter} which can update the control 1520 * text. 1521 */ getControlContentAdapter()1522 public IControlContentAdapter getControlContentAdapter() { 1523 return controlContentAdapter; 1524 } 1525 1526 /** 1527 * Set the boolean flag that determines whether the adapter is enabled. 1528 * 1529 * @param enabled 1530 * <code>true</code> if the adapter is enabled and responding 1531 * to user input, <code>false</code> if it is ignoring user 1532 * input. 1533 * 1534 */ setEnabled(boolean enabled)1535 public void setEnabled(boolean enabled) { 1536 // If we are disabling it while it's proposing content, close the 1537 // content proposal popup. 1538 if (isEnabled && !enabled) { 1539 if (popup != null) { 1540 popup.close(); 1541 } 1542 } 1543 isEnabled = enabled; 1544 } 1545 1546 /** 1547 * Add the specified listener to the list of content proposal listeners that 1548 * are notified when content proposals are chosen. 1549 * 1550 * @param listener 1551 * the IContentProposalListener to be added as a listener. Must 1552 * not be <code>null</code>. If an attempt is made to register 1553 * an instance which is already registered with this instance, 1554 * this method has no effect. 1555 * 1556 * @see org.eclipse.jface.fieldassist.IContentProposalListener 1557 */ addContentProposalListener(IContentProposalListener listener)1558 public void addContentProposalListener(IContentProposalListener listener) { 1559 proposalListeners.add(listener); 1560 } 1561 1562 /** 1563 * Removes the specified listener from the list of content proposal 1564 * listeners that are notified when content proposals are chosen. 1565 * 1566 * @param listener 1567 * the IContentProposalListener to be removed as a listener. Must 1568 * not be <code>null</code>. If the listener has not already 1569 * been registered, this method has no effect. 1570 * 1571 * @since 3.3 1572 * @see org.eclipse.jface.fieldassist.IContentProposalListener 1573 */ removeContentProposalListener(IContentProposalListener listener)1574 public void removeContentProposalListener(IContentProposalListener listener) { 1575 proposalListeners.remove(listener); 1576 } 1577 1578 /** 1579 * Add the specified listener to the list of content proposal listeners that 1580 * are notified when a content proposal popup is opened or closed. 1581 * 1582 * @param listener 1583 * the IContentProposalListener2 to be added as a listener. Must 1584 * not be <code>null</code>. If an attempt is made to register 1585 * an instance which is already registered with this instance, 1586 * this method has no effect. 1587 * 1588 * @since 3.3 1589 * @see org.eclipse.jface.fieldassist.IContentProposalListener2 1590 */ addContentProposalListener(IContentProposalListener2 listener)1591 public void addContentProposalListener(IContentProposalListener2 listener) { 1592 proposalListeners2.add(listener); 1593 } 1594 1595 /** 1596 * Remove the specified listener from the list of content proposal listeners 1597 * that are notified when a content proposal popup is opened or closed. 1598 * 1599 * @param listener 1600 * the IContentProposalListener2 to be removed as a listener. 1601 * Must not be <code>null</code>. If the listener has not 1602 * already been registered, this method has no effect. 1603 * 1604 * @since 3.3 1605 * @see org.eclipse.jface.fieldassist.IContentProposalListener2 1606 */ removeContentProposalListener(IContentProposalListener2 listener)1607 public void removeContentProposalListener(IContentProposalListener2 listener) { 1608 proposalListeners2.remove(listener); 1609 } 1610 1611 /* 1612 * Add our listener to the control. Debug information to be left in until 1613 * this support is stable on all platforms. 1614 */ addControlListener(Control control)1615 private void addControlListener(Control control) { 1616 if (DEBUG) { 1617 System.out 1618 .println("ContentProposalListener#installControlListener()"); //$NON-NLS-1$ 1619 } 1620 1621 if (controlListener != null) { 1622 return; 1623 } 1624 controlListener = new Listener() { 1625 @Override 1626 public void handleEvent(Event e) { 1627 if (!isEnabled) { 1628 return; 1629 } 1630 1631 switch (e.type) { 1632 case SWT.Traverse: 1633 case SWT.KeyDown: 1634 if (DEBUG) { 1635 StringBuilder sb; 1636 if (e.type == SWT.Traverse) { 1637 sb = new StringBuilder("Traverse"); //$NON-NLS-1$ 1638 } else { 1639 sb = new StringBuilder("KeyDown"); //$NON-NLS-1$ 1640 } 1641 sb.append(" received by adapter"); //$NON-NLS-1$ 1642 dump(sb.toString(), e); 1643 } 1644 // If the popup is open, it gets first shot at the 1645 // keystroke and should set the doit flags appropriately. 1646 if (popup != null) { 1647 popup.getTargetControlListener().handleEvent(e); 1648 if (DEBUG) { 1649 StringBuilder sb; 1650 if (e.type == SWT.Traverse) { 1651 sb = new StringBuilder("Traverse"); //$NON-NLS-1$ 1652 } else { 1653 sb = new StringBuilder("KeyDown"); //$NON-NLS-1$ 1654 } 1655 sb.append(" after being handled by popup"); //$NON-NLS-1$ 1656 dump(sb.toString(), e); 1657 } 1658 // See https://bugs.eclipse.org/bugs/show_bug.cgi?id=192633 1659 // If the popup is open and this is a valid character, we 1660 // want to watch for the modified text. 1661 if (propagateKeys && e.character != 0) 1662 watchModify = true; 1663 1664 return; 1665 } 1666 1667 // We were only listening to traverse events for the popup 1668 if (e.type == SWT.Traverse) { 1669 // See https://bugs.eclipse.org/bugs/show_bug.cgi?id=520372 1670 // The popup is null so record tab as a means to interrupt 1671 // any autoactivation that is pending due to autoactivation 1672 // delay. 1673 if (popup == null) { 1674 switch (e.detail) { 1675 case SWT.TRAVERSE_TAB_NEXT: 1676 case SWT.TRAVERSE_TAB_PREVIOUS: 1677 receivedKeyDown = true; 1678 break; 1679 } 1680 } 1681 return; 1682 } 1683 1684 // The popup is not open. We are looking at keydown events 1685 // for a trigger to open the popup. 1686 if (triggerKeyStroke != null) { 1687 // Either there are no modifiers for the trigger and we 1688 // check the character field... 1689 if ((triggerKeyStroke.getModifierKeys() == KeyStroke.NO_KEY && triggerKeyStroke 1690 .getNaturalKey() == e.character) 1691 || 1692 // ...or there are modifiers, in which case the 1693 // keycode and state must match 1694 (triggerKeyStroke.getNaturalKey() == e.keyCode && ((triggerKeyStroke 1695 .getModifierKeys() & e.stateMask) == triggerKeyStroke 1696 .getModifierKeys()))) { 1697 // We never propagate the keystroke for an explicit 1698 // keystroke invocation of the popup 1699 e.doit = false; 1700 openProposalPopup(false); 1701 return; 1702 } 1703 } 1704 /* 1705 * The triggering keystroke was not invoked. If a character 1706 * was typed, compare it to the autoactivation characters. 1707 */ 1708 if (e.character != 0) { 1709 if (autoActivateString != null) { 1710 if (autoActivateString.indexOf(e.character) >= 0) { 1711 autoActivate(); 1712 } else { 1713 // No autoactivation occurred, so record the key 1714 // down as a means to interrupt any 1715 // autoactivation that is pending due to 1716 // autoactivation delay. 1717 receivedKeyDown = true; 1718 // watch the modify so we can close the popup in 1719 // cases where there is no longer a trigger 1720 // character in the content 1721 watchModify = true; 1722 } 1723 } else { 1724 // The autoactivate string is null. If the trigger 1725 // is also null, we want to act on any modification 1726 // to the content. Set a flag so we'll catch this 1727 // in the modify event. 1728 if (triggerKeyStroke == null) { 1729 watchModify = true; 1730 } 1731 1732 // See https://bugs.eclipse.org/bugs/show_bug.cgi?id=520372 1733 // mimic close cases of popup in TargetControlListener 1734 if (popup == null) { 1735 switch (e.character) { 1736 case SWT.CR: 1737 case SWT.LF: 1738 case SWT.ESC: 1739 // Interrupt any autoactivation that is pending due to 1740 // autoactivation delay. 1741 receivedKeyDown = true; 1742 break; 1743 } 1744 } 1745 } 1746 } else { 1747 // A non-character key has been pressed. Interrupt any 1748 // autoactivation that is pending due to autoactivation delay. 1749 receivedKeyDown = true; 1750 } 1751 break; 1752 1753 1754 // There are times when we want to monitor content changes 1755 // rather than individual keystrokes to determine whether 1756 // the popup should be closed or opened based on the entire 1757 // content of the control. 1758 // The watchModify flag ensures that we don't autoactivate if 1759 // the content change was caused by something other than typing. 1760 // See https://bugs.eclipse.org/bugs/show_bug.cgi?id=183650 1761 case SWT.Modify: 1762 if (allowsAutoActivate() && watchModify) { 1763 if (DEBUG) { 1764 dump("Modify event triggers popup open or close", e); //$NON-NLS-1$ 1765 } 1766 watchModify = false; 1767 // We are in autoactivation mode, either for specific 1768 // characters or for all characters. In either case, 1769 // we should close the proposal popup when there is no 1770 // content in the control. 1771 if (isControlContentEmpty()) { 1772 // see https://bugs.eclipse.org/bugs/show_bug.cgi?id=192633 1773 closeProposalPopup(); 1774 } else { 1775 // See https://bugs.eclipse.org/bugs/show_bug.cgi?id=147377 1776 // Given that we will close the popup when there are 1777 // no valid proposals, we must consider reopening it on any 1778 // content change when there are no particular autoActivation 1779 // characters 1780 if (autoActivateString == null) { 1781 autoActivate(); 1782 } else { 1783 // Autoactivation characters are defined, but this 1784 // modify event does not involve one of them. See 1785 // if any of the autoactivation characters are left 1786 // in the content and close the popup if none remain. 1787 if (!shouldPopupRemainOpen()) 1788 closeProposalPopup(); 1789 } 1790 } 1791 } 1792 break; 1793 default: 1794 break; 1795 } 1796 } 1797 1798 /** 1799 * Dump the given events to "standard" output. 1800 * 1801 * @param who 1802 * who is dumping the event 1803 * @param e 1804 * the event 1805 */ 1806 private void dump(String who, Event e) { 1807 StringBuilder sb = new StringBuilder( 1808 "--- [ContentProposalAdapter]\n"); //$NON-NLS-1$ 1809 sb.append(who); 1810 sb.append(" - e: keyCode=" + e.keyCode + hex(e.keyCode)); //$NON-NLS-1$ 1811 sb.append("; character=" + e.character + hex(e.character)); //$NON-NLS-1$ 1812 sb.append("; stateMask=" + e.stateMask + hex(e.stateMask)); //$NON-NLS-1$ 1813 sb.append("; doit=" + e.doit); //$NON-NLS-1$ 1814 sb.append("; detail=" + e.detail + hex(e.detail)); //$NON-NLS-1$ 1815 sb.append("; widget=" + e.widget); //$NON-NLS-1$ 1816 System.out.println(sb); 1817 } 1818 1819 private String hex(int i) { 1820 return "[0x" + Integer.toHexString(i) + ']'; //$NON-NLS-1$ 1821 } 1822 }; 1823 control.addListener(SWT.KeyDown, controlListener); 1824 control.addListener(SWT.Traverse, controlListener); 1825 control.addListener(SWT.Modify, controlListener); 1826 1827 if (DEBUG) { 1828 System.out 1829 .println("ContentProposalAdapter#installControlListener() - installed"); //$NON-NLS-1$ 1830 } 1831 } 1832 1833 /** 1834 * Open the proposal popup and display the proposals provided by the 1835 * proposal provider. If there are no proposals to be shown, do not show the 1836 * popup. This method returns immediately. That is, it does not wait for the 1837 * popup to open or a proposal to be selected. 1838 * 1839 * @param autoActivated 1840 * a boolean indicating whether the popup was autoactivated. If 1841 * false, a beep will sound when no proposals can be shown. 1842 */ openProposalPopup(boolean autoActivated)1843 private void openProposalPopup(boolean autoActivated) { 1844 if (isValid()) { 1845 if (popup == null) { 1846 // Check whether there are any proposals to be shown. 1847 recordCursorPosition(); // must be done before getting proposals 1848 IContentProposal[] proposals = getProposals(); 1849 if (proposals == null) 1850 return; 1851 if (proposals.length > 0) { 1852 if (DEBUG) { 1853 System.out.println("POPUP OPENED BY PRECEDING EVENT"); //$NON-NLS-1$ 1854 } 1855 recordCursorPosition(); 1856 popup = new ContentProposalPopup(null, proposals); 1857 popup.open(); 1858 popup.getShell().addDisposeListener(event -> popup = null); 1859 internalPopupOpened(); 1860 notifyPopupOpened(); 1861 } else if (!autoActivated) { 1862 getControl().getDisplay().beep(); 1863 } 1864 } 1865 } 1866 } 1867 1868 /** 1869 * Open the proposal popup and display the proposals provided by the 1870 * proposal provider. This method returns immediately. That is, it does not 1871 * wait for a proposal to be selected. This method is used by subclasses to 1872 * explicitly invoke the opening of the popup. If there are no proposals to 1873 * show, the popup will not open and a beep will be sounded. 1874 */ openProposalPopup()1875 protected void openProposalPopup() { 1876 openProposalPopup(false); 1877 } 1878 1879 /** 1880 * Close the proposal popup without accepting a proposal. This method 1881 * returns immediately, and has no effect if the proposal popup was not 1882 * open. This method is used by subclasses to explicitly close the popup 1883 * based on additional logic. 1884 * 1885 * @since 3.3 1886 */ closeProposalPopup()1887 protected void closeProposalPopup() { 1888 if (popup != null) { 1889 popup.close(); 1890 } 1891 } 1892 1893 /* 1894 * A content proposal has been accepted. Update the control contents 1895 * accordingly and notify any listeners. 1896 * 1897 * @param proposal the accepted proposal 1898 */ proposalAccepted(IContentProposal proposal)1899 private void proposalAccepted(IContentProposal proposal) { 1900 switch (proposalAcceptanceStyle) { 1901 case (PROPOSAL_REPLACE): 1902 setControlContent(proposal.getContent(), proposal 1903 .getCursorPosition()); 1904 break; 1905 case (PROPOSAL_INSERT): 1906 insertControlContent(proposal.getContent(), proposal 1907 .getCursorPosition()); 1908 break; 1909 default: 1910 // do nothing. Typically a listener is installed to handle this in 1911 // a custom way. 1912 break; 1913 } 1914 1915 // In all cases, notify listeners of an accepted proposal. 1916 notifyProposalAccepted(proposal); 1917 } 1918 1919 /* 1920 * Set the text content of the control to the specified text, setting the 1921 * cursorPosition at the desired location within the new contents. 1922 */ setControlContent(String text, int cursorPosition)1923 private void setControlContent(String text, int cursorPosition) { 1924 if (isValid()) { 1925 // should already be false, but just in case. 1926 watchModify = false; 1927 controlContentAdapter.setControlContents(control, text, 1928 cursorPosition); 1929 } 1930 } 1931 1932 /* 1933 * Insert the specified text into the control content, setting the 1934 * cursorPosition at the desired location within the new contents. 1935 */ insertControlContent(String text, int cursorPosition)1936 private void insertControlContent(String text, int cursorPosition) { 1937 if (isValid()) { 1938 // should already be false, but just in case. 1939 watchModify = false; 1940 // Not all controls preserve their selection index when they lose 1941 // focus, so we must set it explicitly here to what it was before 1942 // the popup opened. 1943 // See https://bugs.eclipse.org/bugs/show_bug.cgi?id=127108 1944 // See https://bugs.eclipse.org/bugs/show_bug.cgi?id=139063 1945 if (controlContentAdapter instanceof IControlContentAdapter2 1946 && selectionRange.x != -1) { 1947 ((IControlContentAdapter2) controlContentAdapter).setSelection( 1948 control, selectionRange); 1949 } else if (insertionPos != -1) { 1950 controlContentAdapter.setCursorPosition(control, insertionPos); 1951 } 1952 controlContentAdapter.insertControlContents(control, text, 1953 cursorPosition); 1954 } 1955 } 1956 1957 /* 1958 * Check that the control and content adapter are valid. 1959 */ isValid()1960 private boolean isValid() { 1961 return control != null && !control.isDisposed() 1962 && controlContentAdapter != null; 1963 } 1964 1965 /* 1966 * Record the control's cursor position. 1967 */ recordCursorPosition()1968 private void recordCursorPosition() { 1969 if (isValid()) { 1970 IControlContentAdapter adapter = getControlContentAdapter(); 1971 insertionPos = adapter.getCursorPosition(control); 1972 // see https://bugs.eclipse.org/bugs/show_bug.cgi?id=139063 1973 if (adapter instanceof IControlContentAdapter2) { 1974 selectionRange = ((IControlContentAdapter2) adapter) 1975 .getSelection(control); 1976 } 1977 1978 } 1979 } 1980 1981 /* 1982 * Get the proposals from the proposal provider. Gets all of the proposals 1983 * without doing any filtering. 1984 */ getProposals()1985 private IContentProposal[] getProposals() { 1986 if (proposalProvider == null || !isValid()) { 1987 return null; 1988 } 1989 if (DEBUG) { 1990 System.out.println(">>> obtaining proposals from provider"); //$NON-NLS-1$ 1991 } 1992 int position = insertionPos; 1993 if (position == -1) { 1994 position = getControlContentAdapter().getCursorPosition( 1995 getControl()); 1996 } 1997 String contents = getControlContentAdapter().getControlContents( 1998 getControl()); 1999 return proposalProvider.getProposals(contents, 2000 position); 2001 } 2002 2003 /** 2004 * Autoactivation has been triggered. Open the popup using any specified 2005 * delay. 2006 */ autoActivate()2007 private void autoActivate() { 2008 if (autoActivationDelay > 0) { 2009 Runnable runnable = () -> { 2010 receivedKeyDown = false; 2011 try { 2012 Thread.sleep(autoActivationDelay); 2013 } catch (InterruptedException e) { 2014 } 2015 if (!isValid() || receivedKeyDown) { 2016 return; 2017 } 2018 getControl().getDisplay().syncExec(() -> openProposalPopup(true)); 2019 }; 2020 Thread t = new Thread(runnable); 2021 t.start(); 2022 } else { 2023 // Since we do not sleep, we must open the popup 2024 // in an async exec. This is necessary because 2025 // this method may be called in the middle of handling 2026 // some event that will cause the cursor position or 2027 // other important info to change as a result of this 2028 // event occurring. 2029 getControl().getDisplay().asyncExec(() -> { 2030 if (isValid()) { 2031 openProposalPopup(true); 2032 } 2033 }); 2034 } 2035 } 2036 2037 /* 2038 * A proposal has been accepted. Notify interested listeners. 2039 */ notifyProposalAccepted(IContentProposal proposal)2040 private void notifyProposalAccepted(IContentProposal proposal) { 2041 if (DEBUG) { 2042 System.out.println("Notify listeners - proposal accepted."); //$NON-NLS-1$ 2043 } 2044 for (IContentProposalListener l : proposalListeners) { 2045 l.proposalAccepted(proposal); 2046 } 2047 } 2048 2049 /* 2050 * The proposal popup has opened. Notify interested listeners. 2051 */ notifyPopupOpened()2052 private void notifyPopupOpened() { 2053 if (DEBUG) { 2054 System.out.println("Notify listeners - popup opened."); //$NON-NLS-1$ 2055 } 2056 for (IContentProposalListener2 l : proposalListeners2) { 2057 l.proposalPopupOpened(this); 2058 } 2059 } 2060 2061 /* 2062 * The proposal popup has closed. Notify interested listeners. 2063 */ notifyPopupClosed()2064 private void notifyPopupClosed() { 2065 if (DEBUG) { 2066 System.out.println("Notify listeners - popup closed."); //$NON-NLS-1$ 2067 } 2068 for (IContentProposalListener2 l : proposalListeners2) { 2069 l.proposalPopupClosed(this); 2070 } 2071 } 2072 2073 /** 2074 * Returns whether the content proposal popup has the focus. This includes 2075 * both the primary popup and any secondary info popup that may have focus. 2076 * 2077 * @return <code>true</code> if the proposal popup or its secondary info 2078 * popup has the focus 2079 * @since 3.4 2080 */ hasProposalPopupFocus()2081 public boolean hasProposalPopupFocus() { 2082 return popup != null && popup.hasFocus(); 2083 } 2084 2085 /* 2086 * Return whether the control content is empty 2087 */ isControlContentEmpty()2088 private boolean isControlContentEmpty() { 2089 return getControlContentAdapter().getControlContents(getControl()) 2090 .length() == 0; 2091 } 2092 2093 /* 2094 * The popup has just opened, but listeners have not yet 2095 * been notified. Perform any cleanup that is needed. 2096 */ internalPopupOpened()2097 private void internalPopupOpened() { 2098 // see https://bugs.eclipse.org/bugs/show_bug.cgi?id=243612 2099 if (control instanceof Combo) { 2100 ((Combo)control).setListVisible(false); 2101 } 2102 } 2103 2104 /* 2105 * Return whether a proposal popup should remain open. 2106 * If it was autoactivated by specific characters, and 2107 * none of those characters remain, then it should not remain 2108 * open. This method should not be used to determine 2109 * whether autoactivation has occurred or should occur, only whether 2110 * the circumstances would dictate that a popup remain open. 2111 */ shouldPopupRemainOpen()2112 private boolean shouldPopupRemainOpen() { 2113 // If we always autoactivate or never autoactivate, it should remain open 2114 if (autoActivateString == null || autoActivateString.length() == 0) 2115 return true; 2116 String content = getControlContentAdapter().getControlContents(getControl()); 2117 for (int i=0; i<autoActivateString.length(); i++) { 2118 if (content.indexOf(autoActivateString.charAt(i)) >= 0) 2119 return true; 2120 } 2121 return false; 2122 } 2123 2124 /* 2125 * Return whether this adapter is configured for autoactivation, by 2126 * specific characters or by any characters. 2127 */ allowsAutoActivate()2128 private boolean allowsAutoActivate() { 2129 return (autoActivateString != null && autoActivateString.length() > 0) // there are specific autoactivation chars supplied 2130 || (autoActivateString == null && triggerKeyStroke == null); // we autoactivate on everything 2131 } 2132 2133 /** 2134 * Sets focus to the proposal popup. If the proposal popup is not opened, 2135 * this method is ignored. If the secondary popup has focus, focus is 2136 * returned to the main proposal popup. 2137 * 2138 * @since 3.6 2139 */ setProposalPopupFocus()2140 public void setProposalPopupFocus() { 2141 if (isValid() && popup != null) 2142 popup.getShell().setFocus(); 2143 } 2144 2145 /** 2146 * Answers a boolean indicating whether the main proposal popup is open. 2147 * 2148 * @return <code>true</code> if the proposal popup is open, and 2149 * <code>false</code> if it is not. 2150 * 2151 * @since 3.6 2152 */ isProposalPopupOpen()2153 public boolean isProposalPopupOpen() { 2154 if (isValid() && popup != null) 2155 return true; 2156 return false; 2157 } 2158 2159 /** 2160 * Reloads the proposals from the content provider and fills them into the 2161 * proposal pop-up, if the pop-up is currently open. 2162 * 2163 * @since 3.15 2164 */ refresh()2165 public void refresh() { 2166 if (isProposalPopupOpen()) { 2167 popup.refresh(); 2168 } 2169 } 2170 2171 } 2172