// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. /*----------------------------------------------------------------------------- | Copyright (c) 2014-2017, PhosphorJS Contributors | | Distributed under the terms of the BSD 3-Clause License. | | The full license is in the file LICENSE, distributed with this software. |----------------------------------------------------------------------------*/ import { ArrayExt, StringExt } from '@lumino/algorithm'; import { JSONExt, ReadonlyJSONObject } from '@lumino/coreutils'; import { CommandRegistry } from '@lumino/commands'; import { ElementExt } from '@lumino/domutils'; import { Message } from '@lumino/messaging'; import { ElementDataset, h, VirtualDOM, VirtualElement } from '@lumino/virtualdom'; import { Widget } from './widget'; /** * A widget which displays command items as a searchable palette. */ export class CommandPalette extends Widget { /** * Construct a new command palette. * * @param options - The options for initializing the palette. */ constructor(options: CommandPalette.IOptions) { super({ node: Private.createNode() }); this.addClass('lm-CommandPalette'); /* */ this.addClass('p-CommandPalette'); /* */ this.setFlag(Widget.Flag.DisallowLayout); this.commands = options.commands; this.renderer = options.renderer || CommandPalette.defaultRenderer; this.commands.commandChanged.connect(this._onGenericChange, this); this.commands.keyBindingChanged.connect(this._onGenericChange, this); } /** * Dispose of the resources held by the widget. */ dispose(): void { this._items.length = 0; this._results = null; super.dispose(); } /** * The command registry used by the command palette. */ readonly commands: CommandRegistry; /** * The renderer used by the command palette. */ readonly renderer: CommandPalette.IRenderer; /** * The command palette search node. * * #### Notes * This is the node which contains the search-related elements. */ get searchNode(): HTMLDivElement { return this.node.getElementsByClassName( 'lm-CommandPalette-search' )[0] as HTMLDivElement; } /** * The command palette input node. * * #### Notes * This is the actual input node for the search area. */ get inputNode(): HTMLInputElement { return this.node.getElementsByClassName( 'lm-CommandPalette-input' )[0] as HTMLInputElement; } /** * The command palette content node. * * #### Notes * This is the node which holds the command item nodes. * * Modifying this node directly can lead to undefined behavior. */ get contentNode(): HTMLUListElement { return this.node.getElementsByClassName( 'lm-CommandPalette-content' )[0] as HTMLUListElement; } /** * A read-only array of the command items in the palette. */ get items(): ReadonlyArray { return this._items; } /** * Add a command item to the command palette. * * @param options - The options for creating the command item. * * @returns The command item added to the palette. */ addItem(options: CommandPalette.IItemOptions): CommandPalette.IItem { // Create a new command item for the options. let item = Private.createItem(this.commands, options); // Add the item to the array. this._items.push(item); // Refresh the search results. this.refresh(); // Return the item added to the palette. return item; } /** * Adds command items to the command palette. * * @param items - An array of options for creating each command item. * * @returns The command items added to the palette. */ addItems(items: CommandPalette.IItemOptions[]): CommandPalette.IItem[] { const newItems = items.map(item => Private.createItem(this.commands, item)); newItems.forEach(item => this._items.push(item)); this.refresh(); return newItems; } /** * Remove an item from the command palette. * * @param item - The item to remove from the palette. * * #### Notes * This is a no-op if the item is not in the palette. */ removeItem(item: CommandPalette.IItem): void { this.removeItemAt(this._items.indexOf(item)); } /** * Remove the item at a given index from the command palette. * * @param index - The index of the item to remove. * * #### Notes * This is a no-op if the index is out of range. */ removeItemAt(index: number): void { // Remove the item from the array. let item = ArrayExt.removeAt(this._items, index); // Bail if the index is out of range. if (!item) { return; } // Refresh the search results. this.refresh(); } /** * Remove all items from the command palette. */ clearItems(): void { // Bail if there is nothing to remove. if (this._items.length === 0) { return; } // Clear the array of items. this._items.length = 0; // Refresh the search results. this.refresh(); } /** * Clear the search results and schedule an update. * * #### Notes * This should be called whenever the search results of the palette * should be updated. * * This is typically called automatically by the palette as needed, * but can be called manually if the input text is programatically * changed. * * The rendered results are updated asynchronously. */ refresh(): void { this._results = null; if (this.inputNode.value !== '') { let clear = this.node.getElementsByClassName( 'lm-close-icon' )[0] as HTMLInputElement; clear.style.display = 'inherit'; } else { let clear = this.node.getElementsByClassName( 'lm-close-icon' )[0] as HTMLInputElement; clear.style.display = 'none'; } this.update(); } /** * Handle the DOM events for the command palette. * * @param event - The DOM event sent to the command palette. * * #### Notes * This method implements the DOM `EventListener` interface and is * called in response to events on the command palette's DOM node. * It should not be called directly by user code. */ handleEvent(event: Event): void { switch (event.type) { case 'click': this._evtClick(event as MouseEvent); break; case 'keydown': this._evtKeyDown(event as KeyboardEvent); break; case 'input': this.refresh(); break; case 'focus': case 'blur': this._toggleFocused(); break; } } /** * A message handler invoked on a `'before-attach'` message. */ protected onBeforeAttach(msg: Message): void { this.node.addEventListener('click', this); this.node.addEventListener('keydown', this); this.node.addEventListener('input', this); this.node.addEventListener('focus', this, true); this.node.addEventListener('blur', this, true); } /** * A message handler invoked on an `'after-detach'` message. */ protected onAfterDetach(msg: Message): void { this.node.removeEventListener('click', this); this.node.removeEventListener('keydown', this); this.node.removeEventListener('input', this); this.node.removeEventListener('focus', this, true); this.node.removeEventListener('blur', this, true); } /** * A message handler invoked on an `'activate-request'` message. */ protected onActivateRequest(msg: Message): void { if (this.isAttached) { let input = this.inputNode; input.focus(); input.select(); } } /** * A message handler invoked on an `'update-request'` message. */ protected onUpdateRequest(msg: Message): void { // Fetch the current query text and content node. let query = this.inputNode.value; let contentNode = this.contentNode; // Ensure the search results are generated. let results = this._results; if (!results) { // Generate and store the new search results. results = this._results = Private.search(this._items, query); // Reset the active index. this._activeIndex = query ? ArrayExt.findFirstIndex(results, Private.canActivate) : -1; } // If there is no query and no results, clear the content. if (!query && results.length === 0) { VirtualDOM.render(null, contentNode); return; } // If the is a query but no results, render the empty message. if (query && results.length === 0) { let content = this.renderer.renderEmptyMessage({ query }); VirtualDOM.render(content, contentNode); return; } // Create the render content for the search results. let renderer = this.renderer; let activeIndex = this._activeIndex; let content = new Array(results.length); for (let i = 0, n = results.length; i < n; ++i) { let result = results[i]; if (result.type === 'header') { let indices = result.indices; let category = result.category; content[i] = renderer.renderHeader({ category, indices }); } else { let item = result.item; let indices = result.indices; let active = i === activeIndex; content[i] = renderer.renderItem({ item, indices, active }); } } // Render the search result content. VirtualDOM.render(content, contentNode); // Adjust the scroll position as needed. if (activeIndex < 0 || activeIndex >= results.length) { contentNode.scrollTop = 0; } else { let element = contentNode.children[activeIndex]; ElementExt.scrollIntoViewIfNeeded(contentNode, element); } } /** * Handle the `'click'` event for the command palette. */ private _evtClick(event: MouseEvent): void { // Bail if the click is not the left button. if (event.button !== 0) { return; } // Clear input if the target is clear button if ((event.target as HTMLElement).classList.contains('lm-close-icon')) { this.inputNode.value = ''; this.refresh(); return; } // Find the index of the item which was clicked. let index = ArrayExt.findFirstIndex(this.contentNode.children, node => { return node.contains(event.target as HTMLElement); }); // Bail if the click was not on an item. if (index === -1) { return; } // Kill the event when a content item is clicked. event.preventDefault(); event.stopPropagation(); // Execute the item if possible. this._execute(index); } /** * Handle the `'keydown'` event for the command palette. */ private _evtKeyDown(event: KeyboardEvent): void { if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { return; } switch (event.keyCode) { case 13: // Enter event.preventDefault(); event.stopPropagation(); this._execute(this._activeIndex); break; case 38: // Up Arrow event.preventDefault(); event.stopPropagation(); this._activatePreviousItem(); break; case 40: // Down Arrow event.preventDefault(); event.stopPropagation(); this._activateNextItem(); break; } } /** * Activate the next enabled command item. */ private _activateNextItem(): void { // Bail if there are no search results. if (!this._results || this._results.length === 0) { return; } // Find the next enabled item index. let ai = this._activeIndex; let n = this._results.length; let start = ai < n - 1 ? ai + 1 : 0; let stop = start === 0 ? n - 1 : start - 1; this._activeIndex = ArrayExt.findFirstIndex( this._results, Private.canActivate, start, stop ); // Schedule an update of the items. this.update(); } /** * Activate the previous enabled command item. */ private _activatePreviousItem(): void { // Bail if there are no search results. if (!this._results || this._results.length === 0) { return; } // Find the previous enabled item index. let ai = this._activeIndex; let n = this._results.length; let start = ai <= 0 ? n - 1 : ai - 1; let stop = start === n - 1 ? 0 : start + 1; this._activeIndex = ArrayExt.findLastIndex( this._results, Private.canActivate, start, stop ); // Schedule an update of the items. this.update(); } /** * Execute the command item at the given index, if possible. */ private _execute(index: number): void { // Bail if there are no search results. if (!this._results) { return; } // Bail if the index is out of range. let part = this._results[index]; if (!part) { return; } // Update the search text if the item is a header. if (part.type === 'header') { let input = this.inputNode; input.value = `${part.category.toLowerCase()} `; input.focus(); this.refresh(); return; } // Bail if item is not enabled. if (!part.item.isEnabled) { return; } // Execute the item. this.commands.execute(part.item.command, part.item.args); // Clear the query text. this.inputNode.value = ''; // Refresh the search results. this.refresh(); } /** * Toggle the focused modifier based on the input node focus state. */ private _toggleFocused(): void { let focused = document.activeElement === this.inputNode; this.toggleClass('lm-mod-focused', focused); /* */ this.toggleClass('p-mod-focused', focused); /* */ } /** * A signal handler for generic command changes. */ private _onGenericChange(): void { this.refresh(); } private _activeIndex = -1; private _items: CommandPalette.IItem[] = []; private _results: Private.SearchResult[] | null = null; } /** * The namespace for the `CommandPalette` class statics. */ export namespace CommandPalette { /** * An options object for creating a command palette. */ export interface IOptions { /** * The command registry for use with the command palette. */ commands: CommandRegistry; /** * A custom renderer for use with the command palette. * * The default is a shared renderer instance. */ renderer?: IRenderer; } /** * An options object for creating a command item. */ export interface IItemOptions { /** * The category for the item. */ category: string; /** * The command to execute when the item is triggered. */ command: string; /** * The arguments for the command. * * The default value is an empty object. */ args?: ReadonlyJSONObject; /** * The rank for the command item. * * The rank is used as a tie-breaker when ordering command items * for display. Items are sorted in the following order: * 1. Text match (lower is better) * 2. Category (locale order) * 3. Rank (lower is better) * 4. Label (locale order) * * The default rank is `Infinity`. */ rank?: number; } /** * An object which represents an item in a command palette. * * #### Notes * Item objects are created automatically by a command palette. */ export interface IItem { /** * The command to execute when the item is triggered. */ readonly command: string; /** * The arguments for the command. */ readonly args: ReadonlyJSONObject; /** * The category for the command item. */ readonly category: string; /** * The rank for the command item. */ readonly rank: number; /** * The display label for the command item. */ readonly label: string; /** * The display caption for the command item. */ readonly caption: string; /** * The icon renderer for the command item. */ readonly icon: | VirtualElement.IRenderer | undefined /* */ | string /* */; /** * The icon class for the command item. */ readonly iconClass: string; /** * The icon label for the command item. */ readonly iconLabel: string; /** * The extra class name for the command item. */ readonly className: string; /** * The dataset for the command item. */ readonly dataset: CommandRegistry.Dataset; /** * Whether the command item is enabled. */ readonly isEnabled: boolean; /** * Whether the command item is toggled. */ readonly isToggled: boolean; /** * Whether the command item is toggleable. */ readonly isToggleable: boolean; /** * Whether the command item is visible. */ readonly isVisible: boolean; /** * The key binding for the command item. */ readonly keyBinding: CommandRegistry.IKeyBinding | null; } /** * The render data for a command palette header. */ export interface IHeaderRenderData { /** * The category of the header. */ readonly category: string; /** * The indices of the matched characters in the category. */ readonly indices: ReadonlyArray | null; } /** * The render data for a command palette item. */ export interface IItemRenderData { /** * The command palette item to render. */ readonly item: IItem; /** * The indices of the matched characters in the label. */ readonly indices: ReadonlyArray | null; /** * Whether the item is the active item. */ readonly active: boolean; } /** * The render data for a command palette empty message. */ export interface IEmptyMessageRenderData { /** * The query which failed to match any commands. */ query: string; } /** * A renderer for use with a command palette. */ export interface IRenderer { /** * Render the virtual element for a command palette header. * * @param data - The data to use for rendering the header. * * @returns A virtual element representing the header. */ renderHeader(data: IHeaderRenderData): VirtualElement; /** * Render the virtual element for a command palette item. * * @param data - The data to use for rendering the item. * * @returns A virtual element representing the item. * * #### Notes * The command palette will not render invisible items. */ renderItem(data: IItemRenderData): VirtualElement; /** * Render the empty results message for a command palette. * * @param data - The data to use for rendering the message. * * @returns A virtual element representing the message. */ renderEmptyMessage(data: IEmptyMessageRenderData): VirtualElement; } /** * The default implementation of `IRenderer`. */ export class Renderer implements IRenderer { /** * Render the virtual element for a command palette header. * * @param data - The data to use for rendering the header. * * @returns A virtual element representing the header. */ renderHeader(data: IHeaderRenderData): VirtualElement { let content = this.formatHeader(data); return h.li( { className: 'lm-CommandPalette-header' + /* */ ' p-CommandPalette-header' /* */ }, content ); } /** * Render the virtual element for a command palette item. * * @param data - The data to use for rendering the item. * * @returns A virtual element representing the item. */ renderItem(data: IItemRenderData): VirtualElement { let className = this.createItemClass(data); let dataset = this.createItemDataset(data); if (data.item.isToggleable) { return h.li( { className, dataset, role: 'checkbox', 'aria-checked': `${data.item.isToggled}` }, this.renderItemIcon(data), this.renderItemContent(data), this.renderItemShortcut(data) ); } return h.li( { className, dataset }, this.renderItemIcon(data), this.renderItemContent(data), this.renderItemShortcut(data) ); } /** * Render the empty results message for a command palette. * * @param data - The data to use for rendering the message. * * @returns A virtual element representing the message. */ renderEmptyMessage(data: IEmptyMessageRenderData): VirtualElement { let content = this.formatEmptyMessage(data); return h.li( { className: 'lm-CommandPalette-emptyMessage' + /* */ ' p-CommandPalette-emptyMessage' /* */ }, content ); } /** * Render the icon for a command palette item. * * @param data - The data to use for rendering the icon. * * @returns A virtual element representing the icon. */ renderItemIcon(data: IItemRenderData): VirtualElement { let className = this.createIconClass(data); /* */ if (typeof data.item.icon === 'string') { return h.div({ className }, data.item.iconLabel); } /* */ // if data.item.icon is undefined, it will be ignored return h.div({ className }, data.item.icon!, data.item.iconLabel); } /** * Render the content for a command palette item. * * @param data - The data to use for rendering the content. * * @returns A virtual element representing the content. */ renderItemContent(data: IItemRenderData): VirtualElement { return h.div( { className: 'lm-CommandPalette-itemContent' + /* */ ' p-CommandPalette-itemContent' /* */ }, this.renderItemLabel(data), this.renderItemCaption(data) ); } /** * Render the label for a command palette item. * * @param data - The data to use for rendering the label. * * @returns A virtual element representing the label. */ renderItemLabel(data: IItemRenderData): VirtualElement { let content = this.formatItemLabel(data); return h.div( { className: 'lm-CommandPalette-itemLabel' + /* */ ' p-CommandPalette-itemLabel' /* */ }, content ); } /** * Render the caption for a command palette item. * * @param data - The data to use for rendering the caption. * * @returns A virtual element representing the caption. */ renderItemCaption(data: IItemRenderData): VirtualElement { let content = this.formatItemCaption(data); return h.div( { className: 'lm-CommandPalette-itemCaption' + /* */ ' p-CommandPalette-itemCaption' /* */ }, content ); } /** * Render the shortcut for a command palette item. * * @param data - The data to use for rendering the shortcut. * * @returns A virtual element representing the shortcut. */ renderItemShortcut(data: IItemRenderData): VirtualElement { let content = this.formatItemShortcut(data); return h.div( { className: 'lm-CommandPalette-itemShortcut' + /* */ ' p-CommandPalette-itemShortcut' /* */ }, content ); } /** * Create the class name for the command palette item. * * @param data - The data to use for the class name. * * @returns The full class name for the command palette item. */ createItemClass(data: IItemRenderData): string { // Set up the initial class name. let name = 'lm-CommandPalette-item'; /* */ name += ' p-CommandPalette-item'; /* */ // Add the boolean state classes. if (!data.item.isEnabled) { name += ' lm-mod-disabled'; /* */ name += ' p-mod-disabled'; /* */ } if (data.item.isToggled) { name += ' lm-mod-toggled'; /* */ name += ' p-mod-toggled'; /* */ } if (data.active) { name += ' lm-mod-active'; /* */ name += ' p-mod-active'; /* */ } // Add the extra class. let extra = data.item.className; if (extra) { name += ` ${extra}`; } // Return the complete class name. return name; } /** * Create the dataset for the command palette item. * * @param data - The data to use for creating the dataset. * * @returns The dataset for the command palette item. */ createItemDataset(data: IItemRenderData): ElementDataset { return { ...data.item.dataset, command: data.item.command }; } /** * Create the class name for the command item icon. * * @param data - The data to use for the class name. * * @returns The full class name for the item icon. */ createIconClass(data: IItemRenderData): string { let name = 'lm-CommandPalette-itemIcon'; /* */ name += ' p-CommandPalette-itemIcon'; /* */ let extra = data.item.iconClass; return extra ? `${name} ${extra}` : name; } /** * Create the render content for the header node. * * @param data - The data to use for the header content. * * @returns The content to add to the header node. */ formatHeader(data: IHeaderRenderData): h.Child { if (!data.indices || data.indices.length === 0) { return data.category; } return StringExt.highlight(data.category, data.indices, h.mark); } /** * Create the render content for the empty message node. * * @param data - The data to use for the empty message content. * * @returns The content to add to the empty message node. */ formatEmptyMessage(data: IEmptyMessageRenderData): h.Child { return `No commands found that match '${data.query}'`; } /** * Create the render content for the item shortcut node. * * @param data - The data to use for the shortcut content. * * @returns The content to add to the shortcut node. */ formatItemShortcut(data: IItemRenderData): h.Child { let kb = data.item.keyBinding; return kb ? kb.keys.map(CommandRegistry.formatKeystroke).join(', ') : null; } /** * Create the render content for the item label node. * * @param data - The data to use for the label content. * * @returns The content to add to the label node. */ formatItemLabel(data: IItemRenderData): h.Child { if (!data.indices || data.indices.length === 0) { return data.item.label; } return StringExt.highlight(data.item.label, data.indices, h.mark); } /** * Create the render content for the item caption node. * * @param data - The data to use for the caption content. * * @returns The content to add to the caption node. */ formatItemCaption(data: IItemRenderData): h.Child { return data.item.caption; } } /** * The default `Renderer` instance. */ export const defaultRenderer = new Renderer(); } /** * The namespace for the module implementation details. */ namespace Private { /** * Create the DOM node for a command palette. */ export function createNode(): HTMLDivElement { let node = document.createElement('div'); let search = document.createElement('div'); let wrapper = document.createElement('div'); let input = document.createElement('input'); let content = document.createElement('ul'); let clear = document.createElement('button'); search.className = 'lm-CommandPalette-search'; wrapper.className = 'lm-CommandPalette-wrapper'; input.className = 'lm-CommandPalette-input'; clear.className = 'lm-close-icon'; content.className = 'lm-CommandPalette-content'; /* */ search.classList.add('p-CommandPalette-search'); wrapper.classList.add('p-CommandPalette-wrapper'); input.classList.add('p-CommandPalette-input'); content.classList.add('p-CommandPalette-content'); /* */ input.spellcheck = false; wrapper.appendChild(input); wrapper.appendChild(clear); search.appendChild(wrapper); node.appendChild(search); node.appendChild(content); return node; } /** * Create a new command item from a command registry and options. */ export function createItem( commands: CommandRegistry, options: CommandPalette.IItemOptions ): CommandPalette.IItem { return new CommandItem(commands, options); } /** * A search result object for a header label. */ export interface IHeaderResult { /** * The discriminated type of the object. */ readonly type: 'header'; /** * The category for the header. */ readonly category: string; /** * The indices of the matched category characters. */ readonly indices: ReadonlyArray | null; } /** * A search result object for a command item. */ export interface IItemResult { /** * The discriminated type of the object. */ readonly type: 'item'; /** * The command item which was matched. */ readonly item: CommandPalette.IItem; /** * The indices of the matched label characters. */ readonly indices: ReadonlyArray | null; } /** * A type alias for a search result item. */ export type SearchResult = IHeaderResult | IItemResult; /** * Search an array of command items for fuzzy matches. */ export function search( items: CommandPalette.IItem[], query: string ): SearchResult[] { // Fuzzy match the items for the query. let scores = matchItems(items, query); // Sort the items based on their score. scores.sort(scoreCmp); // Create the results for the search. return createResults(scores); } /** * Test whether a result item can be activated. */ export function canActivate(result: SearchResult): boolean { return result.type === 'item' && result.item.isEnabled; } /** * Normalize a category for a command item. */ function normalizeCategory(category: string): string { return category.trim().replace(/\s+/g, ' '); } /** * Normalize the query text for a fuzzy search. */ function normalizeQuery(text: string): string { return text.replace(/\s+/g, '').toLowerCase(); } /** * An enum of the supported match types. */ const enum MatchType { Label, Category, Split, Default } /** * A text match score with associated command item. */ interface IScore { /** * The numerical type for the text match. */ matchType: MatchType; /** * The numerical score for the text match. */ score: number; /** * The indices of the matched category characters. */ categoryIndices: number[] | null; /** * The indices of the matched label characters. */ labelIndices: number[] | null; /** * The command item associated with the match. */ item: CommandPalette.IItem; } /** * Perform a fuzzy match on an array of command items. */ function matchItems(items: CommandPalette.IItem[], query: string): IScore[] { // Normalize the query text to lower case with no whitespace. query = normalizeQuery(query); // Create the array to hold the scores. let scores: IScore[] = []; // Iterate over the items and match against the query. for (let i = 0, n = items.length; i < n; ++i) { // Ignore items which are not visible. let item = items[i]; if (!item.isVisible) { continue; } // If the query is empty, all items are matched by default. if (!query) { scores.push({ matchType: MatchType.Default, categoryIndices: null, labelIndices: null, score: 0, item }); continue; } // Run the fuzzy search for the item and query. let score = fuzzySearch(item, query); // Ignore the item if it is not a match. if (!score) { continue; } // Penalize disabled items. // TODO - push disabled items all the way down in sort cmp? if (!item.isEnabled) { score.score += 1000; } // Add the score to the results. scores.push(score); } // Return the final array of scores. return scores; } /** * Perform a fuzzy search on a single command item. */ function fuzzySearch( item: CommandPalette.IItem, query: string ): IScore | null { // Create the source text to be searched. let category = item.category.toLowerCase(); let label = item.label.toLowerCase(); let source = `${category} ${label}`; // Set up the match score and indices array. let score = Infinity; let indices: number[] | null = null; // The regex for search word boundaries let rgx = /\b\w/g; // Search the source by word boundary. // eslint-disable-next-line no-constant-condition while (true) { // Find the next word boundary in the source. let rgxMatch = rgx.exec(source); // Break if there is no more source context. if (!rgxMatch) { break; } // Run the string match on the relevant substring. let match = StringExt.matchSumOfDeltas(source, query, rgxMatch.index); // Break if there is no match. if (!match) { break; } // Update the match if the score is better. if (match && match.score <= score) { score = match.score; indices = match.indices; } } // Bail if there was no match. if (!indices || score === Infinity) { return null; } // Compute the pivot index between category and label text. let pivot = category.length + 1; // Find the slice index to separate matched indices. let j = ArrayExt.lowerBound(indices, pivot, (a, b) => a - b); // Extract the matched category and label indices. let categoryIndices = indices.slice(0, j); let labelIndices = indices.slice(j); // Adjust the label indices for the pivot offset. for (let i = 0, n = labelIndices.length; i < n; ++i) { labelIndices[i] -= pivot; } // Handle a pure label match. if (categoryIndices.length === 0) { return { matchType: MatchType.Label, categoryIndices: null, labelIndices, score, item }; } // Handle a pure category match. if (labelIndices.length === 0) { return { matchType: MatchType.Category, categoryIndices, labelIndices: null, score, item }; } // Handle a split match. return { matchType: MatchType.Split, categoryIndices, labelIndices, score, item }; } /** * A sort comparison function for a match score. */ function scoreCmp(a: IScore, b: IScore): number { // First compare based on the match type let m1 = a.matchType - b.matchType; if (m1 !== 0) { return m1; } // Otherwise, compare based on the match score. let d1 = a.score - b.score; if (d1 !== 0) { return d1; } // Find the match index based on the match type. let i1 = 0; let i2 = 0; switch (a.matchType) { case MatchType.Label: i1 = a.labelIndices![0]; i2 = b.labelIndices![0]; break; case MatchType.Category: case MatchType.Split: i1 = a.categoryIndices![0]; i2 = b.categoryIndices![0]; break; } // Compare based on the match index. if (i1 !== i2) { return i1 - i2; } // Otherwise, compare by category. let d2 = a.item.category.localeCompare(b.item.category); if (d2 !== 0) { return d2; } // Otherwise, compare by rank. let r1 = a.item.rank; let r2 = b.item.rank; if (r1 !== r2) { return r1 < r2 ? -1 : 1; // Infinity safe } // Finally, compare by label. return a.item.label.localeCompare(b.item.label); } /** * Create the results from an array of sorted scores. */ function createResults(scores: IScore[]): SearchResult[] { // Set up an array to track which scores have been visited. let visited = new Array(scores.length); ArrayExt.fill(visited, false); // Set up the search results array. let results: SearchResult[] = []; // Iterate over each score in the array. for (let i = 0, n = scores.length; i < n; ++i) { // Ignore a score which has already been processed. if (visited[i]) { continue; } // Extract the current item and indices. let { item, categoryIndices } = scores[i]; // Extract the category for the current item. let category = item.category; // Add the header result for the category. results.push({ type: 'header', category, indices: categoryIndices }); // Find the rest of the scores with the same category. for (let j = i; j < n; ++j) { // Ignore a score which has already been processed. if (visited[j]) { continue; } // Extract the data for the current score. let { item, labelIndices } = scores[j]; // Ignore an item with a different category. if (item.category !== category) { continue; } // Create the item result for the score. results.push({ type: 'item', item, indices: labelIndices }); // Mark the score as processed. visited[j] = true; } } // Return the final results. return results; } /** * A concrete implementation of `CommandPalette.IItem`. */ class CommandItem implements CommandPalette.IItem { /** * Construct a new command item. */ constructor( commands: CommandRegistry, options: CommandPalette.IItemOptions ) { this._commands = commands; this.category = normalizeCategory(options.category); this.command = options.command; this.args = options.args || JSONExt.emptyObject; this.rank = options.rank !== undefined ? options.rank : Infinity; } /** * The category for the command item. */ readonly category: string; /** * The command to execute when the item is triggered. */ readonly command: string; /** * The arguments for the command. */ readonly args: ReadonlyJSONObject; /** * The rank for the command item. */ readonly rank: number; /** * The display label for the command item. */ get label(): string { return this._commands.label(this.command, this.args); } /** * The icon renderer for the command item. */ get icon(): | VirtualElement.IRenderer | undefined /* */ | string /* */ { return this._commands.icon(this.command, this.args); } /** * The icon class for the command item. */ get iconClass(): string { return this._commands.iconClass(this.command, this.args); } /** * The icon label for the command item. */ get iconLabel(): string { return this._commands.iconLabel(this.command, this.args); } /** * The display caption for the command item. */ get caption(): string { return this._commands.caption(this.command, this.args); } /** * The extra class name for the command item. */ get className(): string { return this._commands.className(this.command, this.args); } /** * The dataset for the command item. */ get dataset(): CommandRegistry.Dataset { return this._commands.dataset(this.command, this.args); } /** * Whether the command item is enabled. */ get isEnabled(): boolean { return this._commands.isEnabled(this.command, this.args); } /** * Whether the command item is toggled. */ get isToggled(): boolean { return this._commands.isToggled(this.command, this.args); } /** * Whether the command item is toggleable. */ get isToggleable(): boolean { return this._commands.isToggleable(this.command, this.args); } /** * Whether the command item is visible. */ get isVisible(): boolean { return this._commands.isVisible(this.command, this.args); } /** * The key binding for the command item. */ get keyBinding(): CommandRegistry.IKeyBinding | null { let { command, args } = this; return ( ArrayExt.findLastValue(this._commands.keyBindings, kb => { return kb.command === command && JSONExt.deepEqual(kb.args, args); }) || null ); } private _commands: CommandRegistry; } }