1 /* 2 * CommandPalette.java 3 * 4 * Copyright (C) 2021 by RStudio, PBC 5 * 6 * Unless you have received this program directly from RStudio pursuant 7 * to the terms of a commercial license agreement with RStudio, then 8 * this program is licensed to you under the terms of version 3 of the 9 * GNU Affero General Public License. This program is distributed WITHOUT 10 * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, 11 * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the 12 * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. 13 * 14 */ 15 16 package org.rstudio.studio.client.palette.ui; 17 18 import java.util.ArrayList; 19 import java.util.List; 20 21 import org.rstudio.core.client.DebouncedCommand; 22 import org.rstudio.core.client.ElementIds; 23 import org.rstudio.core.client.HandlerRegistrations; 24 import org.rstudio.core.client.StringUtil; 25 import org.rstudio.core.client.a11y.A11y; 26 import org.rstudio.core.client.widget.AriaLiveStatusWidget; 27 import org.rstudio.studio.client.RStudioGinjector; 28 import org.rstudio.studio.client.application.events.AriaLiveStatusEvent.Severity; 29 import org.rstudio.studio.client.palette.model.CommandPaletteEntryProvider; 30 import org.rstudio.studio.client.palette.model.CommandPaletteItem; 31 import org.rstudio.studio.client.palette.model.CommandPaletteItem.InvocationSource; 32 33 import com.google.gwt.aria.client.ExpandedValue; 34 import com.google.gwt.aria.client.Id; 35 import com.google.gwt.aria.client.Roles; 36 import com.google.gwt.core.client.GWT; 37 import com.google.gwt.core.client.Scheduler; 38 import com.google.gwt.dom.client.Element; 39 import com.google.gwt.event.dom.client.KeyCodes; 40 import com.google.gwt.resources.client.CssResource; 41 import com.google.gwt.uibinder.client.UiBinder; 42 import com.google.gwt.uibinder.client.UiField; 43 import com.google.gwt.user.client.ui.Composite; 44 import com.google.gwt.user.client.ui.HTMLPanel; 45 import com.google.gwt.user.client.ui.ScrollPanel; 46 import com.google.gwt.user.client.ui.TextBox; 47 import com.google.gwt.user.client.ui.Widget; 48 import com.google.gwt.user.client.ui.Label; 49 import org.rstudio.studio.client.palette.model.CommandPaletteMruEntry; 50 51 /** 52 * CommandPalette is a widget that displays all available RStudio commands in a 53 * searchable list. 54 */ 55 public class CommandPalette extends Composite 56 { 57 private static CommandPaletteUiBinder uiBinder = GWT.create(CommandPaletteUiBinder.class); 58 59 interface CommandPaletteUiBinder extends UiBinder<Widget, CommandPalette> 60 { 61 } 62 63 /** 64 * The host interface represents the class hosting the widget (not a widget 65 * itself), which is currently the CommandPaletteLauncher. 66 */ 67 public interface Host 68 { dismiss()69 void dismiss(); 70 } 71 72 public interface Styles extends CssResource 73 { popup()74 String popup(); searchBox()75 String searchBox(); commandList()76 String commandList(); commandPanel()77 String commandPanel(); mruSeparator()78 String mruSeparator(); 79 } 80 81 /** 82 * Constructs a new Command Palette widget. 83 * 84 * @param sources A list of sources from which to draw commands for the palette 85 * @param mru A list of the most recently used commands 86 * @param host The object hosting the Command Palette 87 */ CommandPalette(List<CommandPaletteEntryProvider> sources, List<CommandPaletteMruEntry> mru, Host host)88 public CommandPalette(List<CommandPaletteEntryProvider> sources, List<CommandPaletteMruEntry> mru, Host host) 89 { 90 initWidget(uiBinder.createAndBindUi(this)); 91 92 items_ = new ArrayList<>(); 93 visible_ = new ArrayList<>(); 94 host_ = host; 95 selected_ = -1; 96 attached_ = false; 97 pageSize_ = 0; 98 sources_ = sources; 99 needles_ = new String[0]; 100 registrations_ = new HandlerRegistrations(); 101 styles_.ensureInjected(); 102 mru_ = mru; 103 104 Element searchBox = searchBox_.getElement(); 105 searchBox.setAttribute("spellcheck", "false"); 106 searchBox.setAttribute("autocomplete", "off"); 107 108 // Accessibility attributes: list box 109 Element commandList = commandList_.getElement(); 110 ElementIds.assignElementId(commandList, ElementIds.COMMAND_PALETTE_LIST); 111 Roles.getListboxRole().set(commandList); 112 Roles.getListboxRole().setAriaLabelProperty(commandList, "Matching commands and settings"); 113 114 // Accessibility attributes: search box 115 ElementIds.assignElementId(searchBox_, ElementIds.COMMAND_PALETTE_SEARCH); 116 Roles.getComboboxRole().setAriaOwnsProperty(searchBox, Id.of(commandList_.getElement())); 117 Roles.getComboboxRole().set(searchBox); 118 Roles.getComboboxRole().setAriaLabelProperty(searchBox, "Search for commands and settings"); 119 Roles.getComboboxRole().setAriaExpandedState(searchBox, ExpandedValue.TRUE); 120 A11y.setARIAAutocomplete(searchBox_, "list"); 121 122 // Populate the palette on a deferred callback so that it appears immediately 123 Scheduler.get().scheduleDeferred(() -> 124 { 125 populate(); 126 }); 127 128 } 129 130 @Override onAttach()131 public void onAttach() 132 { 133 super.onAttach(); 134 135 attached_ = true; 136 137 // If we have already populated, compute the page size. Do this deferred 138 // so that a render pass occurs (otherwise the page size computations will 139 // take place with unrendered elements) 140 if (commandList_.getWidgetCount() > 0) 141 { 142 Scheduler.get().scheduleDeferred(() -> 143 { 144 computePageSize(); 145 }); 146 } 147 } 148 149 @Override onDetach()150 public void onDetach() 151 { 152 // Clean up event handlers 153 registrations_.removeHandler(); 154 } 155 156 /** 157 * Performs a one-time population of the palette with all available commands. 158 */ populate()159 private void populate() 160 { 161 // Handle most keystrokes on KeyUp so that the contents of the text box 162 // have already been changed 163 searchBox_.addKeyUpHandler((evt) -> 164 { 165 if (evt.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) 166 { 167 // Pressing ESC dismisses the host (removing the palette popup) 168 host_.dismiss(); 169 } 170 else 171 { 172 // Just update the filter if the text has changed 173 String searchText = searchBox_.getText(); 174 if (!StringUtil.equals(searchText_, searchText)) 175 { 176 searchText_ = searchText; 177 needles_ = searchBox_.getText().toLowerCase().split("\\s+"); 178 applyFilter_.nudge(); 179 } 180 } 181 }); 182 183 // Up and Down arrows need to be handled on KeyDown to account for 184 // repetition (a held arrow key will generate multiple KeyDown events and 185 // then a single KeyUp when released) 186 searchBox_.addKeyDownHandler((evt) -> 187 { 188 // Ignore the Tab key so we don't lose focus accidentally (there is 189 // only one focusable element in the palette and we don't want Tab to 190 // dismiss it) 191 if (evt.getNativeKeyCode() == KeyCodes.KEY_TAB) 192 { 193 evt.stopPropagation(); 194 evt.preventDefault(); 195 return; 196 } 197 else if (evt.getNativeKeyCode() == KeyCodes.KEY_ENTER) 198 { 199 // Enter runs the selected command. Turn off default behavior so 200 // that the Enter key-up isn't handled by the IDE once the palette 201 // is dismissed. 202 evt.stopPropagation(); 203 evt.preventDefault(); 204 invokeSelection(); 205 } 206 207 // Ignore modified arrows so that e.g. Shift Up/Down to select the 208 // contents of the textbox work as expected 209 if (evt.isAnyModifierKeyDown()) 210 return; 211 212 if (evt.getNativeKeyCode() == KeyCodes.KEY_UP) 213 { 214 // Directional keys often trigger behavior in textboxes (e.g. moving 215 // the cursor to the beginning/end of text) but we're hijacking them 216 // to do navigation in the results list, so disable that. 217 evt.stopPropagation(); 218 evt.preventDefault(); 219 moveSelection(-1); 220 } 221 else if (evt.getNativeKeyCode() == KeyCodes.KEY_DOWN) 222 { 223 evt.stopPropagation(); 224 evt.preventDefault(); 225 moveSelection(1); 226 } 227 else if (evt.getNativeKeyCode() == KeyCodes.KEY_PAGEUP) 228 { 229 // Page Up moves up by the page size (computed based on the size of 230 // entries in the DOM) 231 moveSelection(-1 * pageSize_); 232 } 233 else if (evt.getNativeKeyCode() == KeyCodes.KEY_PAGEDOWN) 234 { 235 moveSelection(pageSize_); 236 } 237 }); 238 239 // Render the first page of elements 240 renderNextPage(); 241 242 // If we are already attached to the DOM at this point, compute the page 243 // size for scrolling by pages. 244 if (attached_) 245 { 246 computePageSize(); 247 } 248 } 249 250 /** 251 * Compute the size of a "page" of results (for Page Up / Page Down). We do 252 * this dynamically based on measuring DOM elements since the number of items 253 * that fit in a page can vary based on platform, browser, and available 254 * fonts. 255 */ computePageSize()256 private void computePageSize() 257 { 258 // Find the first visible entry (we can't measure an invisible one) 259 for (CommandPaletteItem item: items_) 260 { 261 Widget entry = item.asWidget(); 262 if (entry.isVisible()) 263 { 264 // Compute the page size: the total size of the scrolling area 265 // divided by the size of a visible entry 266 pageSize_ = Math.floorDiv(scroller_.getOffsetHeight(), 267 entry.getOffsetHeight()); 268 break; 269 } 270 } 271 272 if (pageSize_ > 1) 273 { 274 // We want the virtual page to be very slightly smaller than the 275 // physical page 276 pageSize_--; 277 } 278 else 279 { 280 // Something went wrong and we got a tiny or meaningless page size. Use 281 // 10 items as a default. 282 pageSize_ = 10; 283 } 284 } 285 286 /** 287 * Filter the commands by the current contents of the search box 288 */ applyFilter()289 private void applyFilter() 290 { 291 // Clear the command list and render marker in preparation for a re-render 292 commandList_.clear(); 293 renderedItem_ = 0; 294 if (selected_ >= 0) 295 visible_.get(selected_).setSelected(false); 296 visible_.clear(); 297 298 selected_ = -1; 299 300 // Render the next page of command entries 301 renderNextPage(); 302 } 303 304 /** 305 * Runs when the render pass is completed. 306 */ completeRender()307 private void completeRender() 308 { 309 int matches = commandList_.getWidgetCount(); 310 311 // Show "no results" message if appropriate 312 if (matches == 0 && !noResults_.isVisible()) 313 { 314 scroller_.setVisible(false); 315 noResults_.setVisible(true); 316 } 317 else if (matches > 0 && noResults_.isVisible()) 318 { 319 scroller_.setVisible(true); 320 noResults_.setVisible(false); 321 } 322 323 // Report results count to screen reader 324 resultsCount_.reportStatus(matches + " " + 325 "commands found, press up and down to navigate", 326 RStudioGinjector.INSTANCE.getUserPrefs().typingStatusDelayMs().getValue(), 327 Severity.STATUS); 328 } 329 330 /** 331 * Changes the selected palette entry in response to user input. 332 * 333 * @param units The number of units to move selection (negative to go 334 * backwards) 335 */ moveSelection(int units)336 private void moveSelection(int units) 337 { 338 // Identify target element 339 int target = selected_ + units; 340 341 // Clip to boundaries of display 342 if (target < 0) 343 { 344 target = 0; 345 } 346 else if (target >= visible_.size()) 347 { 348 target = visible_.size() - 1; 349 } 350 351 // Select new command if we moved 352 if (target != selected_) 353 { 354 selectNewCommand(target); 355 } 356 } 357 358 /** 359 * Focuses the palette's search box in preparation for user input. 360 */ focus()361 public void focus() 362 { 363 searchBox_.setFocus(true); 364 } 365 366 /** 367 * Invoke the currently selected command. 368 */ invokeSelection()369 private void invokeSelection() 370 { 371 if (selected_ >= 0) 372 { 373 if (visible_.get(selected_).dismissOnInvoke()) 374 { 375 host_.dismiss(); 376 } 377 visible_.get(selected_).invoke(InvocationSource.Keyboard); 378 } 379 } 380 381 /** 382 * Change the selected command. 383 * 384 * @param target The index of the command to select. 385 */ selectNewCommand(int target)386 private void selectNewCommand(int target) 387 { 388 // No-op if target was already selected 389 if (selected_ == target) 390 return; 391 392 // Clear previous selection, if any 393 if (selected_ >= 0) 394 { 395 visible_.get(selected_).setSelected(false); 396 } 397 398 // Set new selection 399 selected_ = target; 400 CommandPaletteItem selected = visible_.get(selected_); 401 selected.setSelected(true); 402 selected.asWidget().getElement().scrollIntoView(); 403 404 // Update active descendant for accessibility 405 Roles.getComboboxRole().setAriaActivedescendantProperty( 406 searchBox_.getElement(), Id.of(selected.asWidget().getElement())); 407 } 408 409 /** 410 * Renders the next page of search results from the command palette. 411 * 412 * By far the slowest part of the command palette is the rendering of 413 * individual items into GWT widgets, so doing this all at once would cause 414 * the palette to take several seconds to appear. To make it performant, we 415 * render just a few widgets at a time, letting the browser do a render 416 * pass after each batch. 417 */ renderNextPage()418 private void renderNextPage() 419 { 420 // If we have no items yet, start with MRU items 421 if (items_.size() == 0 && mru_ != null) 422 { 423 for (CommandPaletteMruEntry mru: mru_) 424 { 425 // Look for the entry provider from which this MRU entry originated 426 for (CommandPaletteEntryProvider provider: sources_) 427 { 428 if (StringUtil.equals(provider.getProviderScope(), mru.getScope())) 429 { 430 // Found the entry provider; ask it to supply the command. 431 CommandPaletteItem item = provider.getCommandPaletteItem(mru.getId()); 432 if (item != null) 433 { 434 item.setIsMru(true); 435 items_.add(item); 436 } 437 438 // Command found; no need to look at other providers 439 break; 440 } 441 } 442 } 443 } 444 445 // If we haven't already pulled items from all our sources and we have 446 // less than a page of data left, pull in data from the next source. 447 if (renderedSource_ < sources_.size() && 448 items_.size() - renderedItem_ < RENDER_PAGE_SIZE) 449 { 450 // Read the next non-null data source 451 List<CommandPaletteItem> items = null; 452 do 453 { 454 items = null; 455 CommandPaletteEntryProvider provider = sources_.get(renderedSource_); 456 if (provider != null) 457 { 458 items = provider.getCommandPaletteItems(); 459 460 // Remove any items already present in the MRU 461 if (mru_ != null) 462 { 463 items.removeIf((item) -> 464 { 465 for (CommandPaletteMruEntry entry : mru_) 466 { 467 if (StringUtil.equals(entry.getScope(), provider.getProviderScope()) && 468 StringUtil.equals(entry.getId(), item.getId())) 469 { 470 // Item already present in MRU; remove it 471 return true; 472 } 473 } 474 // Item not present in MRU; don't remove 475 return false; 476 }); 477 } 478 } 479 renderedSource_++; 480 } while (items == null); 481 482 items_.addAll(items); 483 } 484 485 // Set initial conditions for render loop 486 int rendered = 0; 487 int idx = renderedItem_; 488 boolean mruSeparator = false; 489 490 // Main render loop; render items until we have rendered a full page 491 while (idx < items_.size() && rendered < RENDER_PAGE_SIZE) 492 { 493 CommandPaletteItem item = items_.get(idx); 494 495 // Render this item if non-null and matches the search keywords, if we 496 // have them 497 if (item != null && (needles_.length == 0 || item.matchesSearch(needles_))) 498 { 499 // Remember whether this item has been rendered 500 boolean isRendered = item.isRendered(); 501 502 // Render the item to a widget (this is the expensive step) 503 Widget widget = item.asWidget(); 504 if (widget != null) 505 { 506 if (item.getIsMru()) 507 { 508 // If this item came from the MRU, we need to render a separator to 509 // delineate the MRU and non MRU entries in the palette 510 mruSeparator = true; 511 } 512 else if (mruSeparator) 513 { 514 // Render the MRU separator if we've entered the region of non-MRU items 515 addMruSeparator(); 516 mruSeparator = false; 517 } 518 519 // Add and highlight the item 520 commandList_.add(item.asWidget()); 521 visible_.add(item); 522 item.setSearchHighlight(needles_); 523 524 // If we just added the first widget to the box, select it 525 if (visible_.size() == 1) 526 { 527 selectNewCommand(0); 528 } 529 rendered++; 530 } 531 532 // Attach an invocation handler if this is the first time we've 533 // rendered this item 534 if (!isRendered) 535 { 536 registrations_.add(item.addInvokeHandler((evt) -> 537 { 538 if (evt.getItem().dismissOnInvoke()) 539 { 540 host_.dismiss(); 541 } 542 evt.getItem().invoke(InvocationSource.Mouse); 543 })); 544 } 545 } 546 547 // Advance to next command palette item 548 idx++; 549 } 550 551 // Save our place so we'll start rendering at the next page 552 renderedItem_ = idx; 553 554 // If we didn't render everything, schedule another pass 555 if (renderedItem_ < items_.size() || renderedSource_ < sources_.size()) 556 { 557 // Don't populate while user is typing as dumping more elements into 558 // the DOM is distracting (plus the additional elements will be 559 // discarded once the timer finishes running) 560 if (!applyFilter_.isRunning()) 561 { 562 Scheduler.get().scheduleDeferred(() -> 563 { 564 renderNextPage(); 565 }); 566 } 567 } 568 else 569 { 570 completeRender(); 571 } 572 } 573 574 /** 575 * Adds an element to visually separate the MRU section of the palette from 576 * the rest of the commands 577 */ addMruSeparator()578 private void addMruSeparator() 579 { 580 Label separator = new Label(""); 581 separator.addStyleName(styles_.mruSeparator()); 582 // This separator is decorative from the perspective of a screen reader 583 A11y.setARIAHidden(separator); 584 commandList_.add(separator); 585 } 586 587 private final Host host_; 588 private final List<CommandPaletteEntryProvider> sources_; 589 private final List<CommandPaletteItem> items_; 590 private final List<CommandPaletteItem> visible_; 591 private final List<CommandPaletteMruEntry> mru_; 592 private final HandlerRegistrations registrations_; 593 private int selected_; 594 private String searchText_; 595 private String[] needles_; 596 private boolean attached_; 597 private int pageSize_; 598 599 private int renderedItem_; // The index of the last rendered item 600 private int renderedSource_ = 0; // The index of the last rendered data source 601 private final int RENDER_PAGE_SIZE = 50; 602 603 // These scopes serve two purposes: they ensure IDs are unique across different 604 // kinds of commands, and they serve as a key to look up MRU entries 605 public final static String SCOPE_APP_COMMAND = "command"; 606 public final static String SCOPE_R_ADDIN = "r_addin"; 607 public final static String SCOPE_USER_PREFS = "user_pref"; 608 public final static String SCOPE_VISUAL_EDITOR = "visual_editor"; 609 610 // The delimiter that separates the entry's scope from its ID in the MRU list 611 public final static String SCOPE_MRU_DELIMITER = "|"; 612 613 DebouncedCommand applyFilter_ = new DebouncedCommand(100) 614 { 615 @Override 616 protected void execute() 617 { 618 applyFilter(); 619 } 620 }; 621 622 @UiField public TextBox searchBox_; 623 @UiField public HTMLPanel commandList_; 624 @UiField AriaLiveStatusWidget resultsCount_; 625 @UiField HTMLPanel noResults_; 626 @UiField ScrollPanel scroller_; 627 @UiField Styles styles_; 628 } 629