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