From ccb95fd921349023027a0df25ed291b0992b9a18 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt Date: Wed, 16 Aug 2023 16:05:57 -0700 Subject: [PATCH] Create an `IQuickWidget` (#190627) To do this, I've pulled `QuickInputController` into its own file since `quickInput.ts` was getting too big... And then I made another thing that extends `QuickInput` that hides pretty much everything but does allow for a title bar. This could be useful for more "QuickWebView" type experiences if we wanna light anything like that up. --- .../quickInput/standaloneQuickInputService.ts | 8 +- .../platform/quickinput/browser/quickInput.ts | 727 +----------------- .../browser/quickInputController.ts | 726 +++++++++++++++++ .../quickinput/browser/quickInputService.ts | 10 +- .../platform/quickinput/common/quickInput.ts | 37 +- .../test/browser/quickinput.test.ts | 2 +- .../browser/actions/chatQuickInputActions.ts | 8 +- .../quickinput/browser/quickInputService.ts | 2 +- .../test/browser/workbenchTestServices.ts | 3 +- 9 files changed, 785 insertions(+), 738 deletions(-) create mode 100644 src/vs/platform/quickinput/browser/quickInputController.ts diff --git a/src/vs/editor/standalone/browser/quickInput/standaloneQuickInputService.ts b/src/vs/editor/standalone/browser/quickInput/standaloneQuickInputService.ts index 4aa2558480f26..8c35ee5b26940 100644 --- a/src/vs/editor/standalone/browser/quickInput/standaloneQuickInputService.ts +++ b/src/vs/editor/standalone/browser/quickInput/standaloneQuickInputService.ts @@ -8,13 +8,13 @@ import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition, OverlayWidgetPosit import { EditorContributionInstantiation, registerEditorContribution } from 'vs/editor/browser/editorExtensions'; import { IEditorContribution } from 'vs/editor/common/editorCommon'; import { IThemeService } from 'vs/platform/theme/common/themeService'; -import { IQuickInputService, IQuickInputButton, IQuickPickItem, IQuickPick, IInputBox, IQuickNavigateConfiguration, IPickOptions, QuickPickInput, IInputOptions } from 'vs/platform/quickinput/common/quickInput'; +import { IQuickInputService, IQuickInputButton, IQuickPickItem, IQuickPick, IInputBox, IQuickNavigateConfiguration, IPickOptions, QuickPickInput, IInputOptions, IQuickWidget } from 'vs/platform/quickinput/common/quickInput'; import { CancellationToken } from 'vs/base/common/cancellation'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { EditorScopedLayoutService } from 'vs/editor/standalone/browser/standaloneLayoutService'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; -import { IQuickInputControllerHost, QuickInputController } from 'vs/platform/quickinput/browser/quickInput'; +import { QuickInputController, IQuickInputControllerHost } from 'vs/platform/quickinput/browser/quickInputController'; import { QuickInputService } from 'vs/platform/quickinput/browser/quickInputService'; import { once } from 'vs/base/common/functional'; import { IQuickAccessController } from 'vs/platform/quickinput/common/quickAccess'; @@ -111,6 +111,10 @@ export class StandaloneQuickInputService implements IQuickInputService { return this.activeService.createInputBox(); } + createQuickWidget(): IQuickWidget { + return this.activeService.createQuickWidget(); + } + focus(): void { return this.activeService.focus(); } diff --git a/src/vs/platform/quickinput/browser/quickInput.ts b/src/vs/platform/quickinput/browser/quickInput.ts index 4f6affdefbb17..072f2f56e89c8 100644 --- a/src/vs/platform/quickinput/browser/quickInput.ts +++ b/src/vs/platform/quickinput/browser/quickInput.ts @@ -6,7 +6,6 @@ import * as dom from 'vs/base/browser/dom'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; -import { ActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; import { Button, IButtonStyles } from 'vs/base/browser/ui/button/button'; import { CountBadge, ICountBadgeStyles } from 'vs/base/browser/ui/countBadge/countBadge'; import { IHoverDelegate } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; @@ -19,19 +18,16 @@ import { IToggleStyles, Toggle } from 'vs/base/browser/ui/toggle/toggle'; import { Action } from 'vs/base/common/actions'; import { equals } from 'vs/base/common/arrays'; import { TimeoutTimer } from 'vs/base/common/async'; -import { CancellationToken } from 'vs/base/common/cancellation'; import { Codicon } from 'vs/base/common/codicons'; import { Emitter, Event } from 'vs/base/common/event'; import { KeyCode } from 'vs/base/common/keyCodes'; -import { Disposable, DisposableStore, dispose } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { isIOS } from 'vs/base/common/platform'; import Severity from 'vs/base/common/severity'; import { ThemeIcon } from 'vs/base/common/themables'; -import { isString } from 'vs/base/common/types'; import 'vs/css!./media/quickInput'; import { localize } from 'vs/nls'; -import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; -import { IInputBox, IInputOptions, IKeyMods, IPickOptions, IQuickInput, IQuickInputButton, IQuickInputHideEvent, IQuickInputToggle, IQuickNavigateConfiguration, IQuickPick, IQuickPickDidAcceptEvent, IQuickPickItem, IQuickPickItemButtonEvent, IQuickPickSeparator, IQuickPickSeparatorButtonEvent, IQuickPickWillAcceptEvent, ItemActivation, NO_KEY_MODS, QuickInputHideReason, QuickPickInput } from 'vs/platform/quickinput/common/quickInput'; +import { IInputBox, IKeyMods, IQuickInput, IQuickInputButton, IQuickInputHideEvent, IQuickInputToggle, IQuickNavigateConfiguration, IQuickPick, IQuickPickDidAcceptEvent, IQuickPickItem, IQuickPickItemButtonEvent, IQuickPickSeparator, IQuickPickSeparatorButtonEvent, IQuickPickWillAcceptEvent, IQuickWidget, ItemActivation, NO_KEY_MODS, QuickInputHideReason } from 'vs/platform/quickinput/common/quickInput'; import { QuickInputBox } from './quickInputBox'; import { QuickInputList, QuickInputListFocus } from './quickInputList'; import { getIconClass, renderQuickInputDescription } from './quickInputUtils'; @@ -77,17 +73,15 @@ export interface IQuickInputWidgetStyles { readonly widgetShadow: string | undefined; } -const $ = dom.$; +export type Writeable = { -readonly [P in keyof T]: T[P] }; -type Writeable = { -readonly [P in keyof T]: T[P] }; - -const backButton = { +export const backButton = { iconClass: ThemeIcon.asClassName(Codicon.quickInputBack), tooltip: localize('quickInput.back', "Back"), handle: -1 // TODO }; -interface QuickInputUI { +export interface QuickInputUI { container: HTMLElement; styleSheet: HTMLStyleElement; leftActionBar: ActionBar; @@ -125,7 +119,7 @@ interface QuickInputUI { hide(): void; } -type Visibilities = { +export type Visibilities = { title?: boolean; description?: boolean; checkAll?: boolean; @@ -496,7 +490,7 @@ class QuickInput extends Disposable implements IQuickInput { } } -class QuickPick extends QuickInput implements IQuickPick { +export class QuickPick extends QuickInput implements IQuickPick { private static readonly DEFAULT_ARIA_LABEL = localize('quickInputBox.ariaLabel', "Type to narrow down results."); @@ -1155,7 +1149,7 @@ class QuickPick extends QuickInput implements IQuickPi } } -class InputBox extends QuickInput implements IInputBox { +export class InputBox extends QuickInput implements IInputBox { private _value = ''; private _valueSelection: Readonly<[number, number]> | undefined; private valueSelectionUpdated = true; @@ -1262,704 +1256,19 @@ class InputBox extends QuickInput implements IInputBox { } } -export class QuickInputController extends Disposable { - private static readonly MAX_WIDTH = 600; // Max total width of quick input widget - - private idPrefix: string; - private ui: QuickInputUI | undefined; - private dimension?: dom.IDimension; - private titleBarOffset?: number; - private enabled = true; - private readonly onDidAcceptEmitter = this._register(new Emitter()); - private readonly onDidCustomEmitter = this._register(new Emitter()); - private readonly onDidTriggerButtonEmitter = this._register(new Emitter()); - private keyMods: Writeable = { ctrlCmd: false, alt: false }; - - private controller: QuickInput | null = null; - - private parentElement: HTMLElement; - private styles: IQuickInputStyles; - - private onShowEmitter = this._register(new Emitter()); - readonly onShow = this.onShowEmitter.event; - - private onHideEmitter = this._register(new Emitter()); - readonly onHide = this.onHideEmitter.event; - - private previousFocusElement?: HTMLElement; - - constructor(private options: IQuickInputOptions) { - super(); - this.idPrefix = options.idPrefix; - this.parentElement = options.container; - this.styles = options.styles; - this.registerKeyModsListeners(); - } - - private registerKeyModsListeners() { - const listener = (e: KeyboardEvent | MouseEvent) => { - this.keyMods.ctrlCmd = e.ctrlKey || e.metaKey; - this.keyMods.alt = e.altKey; - }; - this._register(dom.addDisposableListener(window, dom.EventType.KEY_DOWN, listener, true)); - this._register(dom.addDisposableListener(window, dom.EventType.KEY_UP, listener, true)); - this._register(dom.addDisposableListener(window, dom.EventType.MOUSE_DOWN, listener, true)); - } - - private getUI() { - if (this.ui) { - return this.ui; - } - - const container = dom.append(this.parentElement, $('.quick-input-widget.show-file-icons')); - container.tabIndex = -1; - container.style.display = 'none'; - - const styleSheet = dom.createStyleSheet(container); - - const titleBar = dom.append(container, $('.quick-input-titlebar')); - - const leftActionBar = this._register(new ActionBar(titleBar)); - leftActionBar.domNode.classList.add('quick-input-left-action-bar'); - - const title = dom.append(titleBar, $('.quick-input-title')); - - const rightActionBar = this._register(new ActionBar(titleBar)); - rightActionBar.domNode.classList.add('quick-input-right-action-bar'); - - const headerContainer = dom.append(container, $('.quick-input-header')); - - const checkAll = dom.append(headerContainer, $('input.quick-input-check-all')); - checkAll.type = 'checkbox'; - checkAll.setAttribute('aria-label', localize('quickInput.checkAll', "Toggle all checkboxes")); - this._register(dom.addStandardDisposableListener(checkAll, dom.EventType.CHANGE, e => { - const checked = checkAll.checked; - list.setAllVisibleChecked(checked); - })); - this._register(dom.addDisposableListener(checkAll, dom.EventType.CLICK, e => { - if (e.x || e.y) { // Avoid 'click' triggered by 'space'... - inputBox.setFocus(); - } - })); - - const description2 = dom.append(headerContainer, $('.quick-input-description')); - const inputContainer = dom.append(headerContainer, $('.quick-input-and-message')); - const filterContainer = dom.append(inputContainer, $('.quick-input-filter')); - - const inputBox = this._register(new QuickInputBox(filterContainer, this.styles.inputBox, this.styles.toggle)); - inputBox.setAttribute('aria-describedby', `${this.idPrefix}message`); - - const visibleCountContainer = dom.append(filterContainer, $('.quick-input-visible-count')); - visibleCountContainer.setAttribute('aria-live', 'polite'); - visibleCountContainer.setAttribute('aria-atomic', 'true'); - const visibleCount = new CountBadge(visibleCountContainer, { countFormat: localize({ key: 'quickInput.visibleCount', comment: ['This tells the user how many items are shown in a list of items to select from. The items can be anything. Currently not visible, but read by screen readers.'] }, "{0} Results") }, this.styles.countBadge); - - const countContainer = dom.append(filterContainer, $('.quick-input-count')); - countContainer.setAttribute('aria-live', 'polite'); - const count = new CountBadge(countContainer, { countFormat: localize({ key: 'quickInput.countSelected', comment: ['This tells the user how many items are selected in a list of items to select from. The items can be anything.'] }, "{0} Selected") }, this.styles.countBadge); - - const okContainer = dom.append(headerContainer, $('.quick-input-action')); - const ok = new Button(okContainer, this.styles.button); - ok.label = localize('ok', "OK"); - this._register(ok.onDidClick(e => { - this.onDidAcceptEmitter.fire(); - })); - - const customButtonContainer = dom.append(headerContainer, $('.quick-input-action')); - const customButton = new Button(customButtonContainer, this.styles.button); - customButton.label = localize('custom', "Custom"); - this._register(customButton.onDidClick(e => { - this.onDidCustomEmitter.fire(); - })); - - const message = dom.append(inputContainer, $(`#${this.idPrefix}message.quick-input-message`)); - - const progressBar = new ProgressBar(container, this.styles.progressBar); - progressBar.getContainer().classList.add('quick-input-progress'); - - const widget = dom.append(container, $('.quick-input-html-widget')); - widget.tabIndex = -1; - - const description1 = dom.append(container, $('.quick-input-description')); - - const listId = this.idPrefix + 'list'; - const list = this._register(new QuickInputList(container, listId, this.options)); - inputBox.setAttribute('aria-controls', listId); - this._register(list.onDidChangeFocus(() => { - inputBox.setAttribute('aria-activedescendant', list.getActiveDescendant() ?? ''); - })); - this._register(list.onChangedAllVisibleChecked(checked => { - checkAll.checked = checked; - })); - this._register(list.onChangedVisibleCount(c => { - visibleCount.setCount(c); - })); - this._register(list.onChangedCheckedCount(c => { - count.setCount(c); - })); - this._register(list.onLeave(() => { - // Defer to avoid the input field reacting to the triggering key. - setTimeout(() => { - inputBox.setFocus(); - if (this.controller instanceof QuickPick && this.controller.canSelectMany) { - list.clearFocus(); - } - }, 0); - })); - - const focusTracker = dom.trackFocus(container); - this._register(focusTracker); - this._register(dom.addDisposableListener(container, dom.EventType.FOCUS, e => { - // Ignore focus events within container - if (dom.isAncestor(e.relatedTarget as HTMLElement, container)) { - return; - } - this.previousFocusElement = e.relatedTarget instanceof HTMLElement ? e.relatedTarget : undefined; - }, true)); - this._register(focusTracker.onDidBlur(() => { - if (!this.getUI().ignoreFocusOut && !this.options.ignoreFocusOut()) { - this.hide(QuickInputHideReason.Blur); - } - this.previousFocusElement = undefined; - })); - this._register(dom.addDisposableListener(container, dom.EventType.FOCUS, (e: FocusEvent) => { - inputBox.setFocus(); - })); - // TODO: Turn into commands instead of handling KEY_DOWN - this._register(dom.addStandardDisposableListener(container, dom.EventType.KEY_DOWN, (event) => { - if (dom.isAncestor(event.target, widget)) { - return; // Ignore event if target is inside widget to allow the widget to handle the event. - } - switch (event.keyCode) { - case KeyCode.Enter: - dom.EventHelper.stop(event, true); - if (this.enabled) { - this.onDidAcceptEmitter.fire(); - } - break; - case KeyCode.Escape: - dom.EventHelper.stop(event, true); - this.hide(QuickInputHideReason.Gesture); - break; - case KeyCode.Tab: - if (!event.altKey && !event.ctrlKey && !event.metaKey) { - // detect only visible actions - const selectors = [ - '.quick-input-list .monaco-action-bar .always-visible', - '.quick-input-list-entry:hover .monaco-action-bar', - '.monaco-list-row.focused .monaco-action-bar' - ]; - - if (container.classList.contains('show-checkboxes')) { - selectors.push('input'); - } else { - selectors.push('input[type=text]'); - } - if (this.getUI().list.isDisplayed()) { - selectors.push('.monaco-list'); - } - // focus links if there are any - if (this.getUI().message) { - selectors.push('.quick-input-message a'); - } - - if (this.getUI().widget) { - if (dom.isAncestor(event.target, this.getUI().widget)) { - // let the widget control tab - break; - } - selectors.push('.quick-input-html-widget'); - } - const stops = container.querySelectorAll(selectors.join(', ')); - if (event.shiftKey && event.target === stops[0]) { - // Clear the focus from the list in order to allow - // screen readers to read operations in the input box. - dom.EventHelper.stop(event, true); - list.clearFocus(); - } else if (!event.shiftKey && dom.isAncestor(event.target, stops[stops.length - 1])) { - dom.EventHelper.stop(event, true); - stops[0].focus(); - } - } - break; - case KeyCode.Space: - if (event.ctrlKey) { - dom.EventHelper.stop(event, true); - this.getUI().list.toggleHover(); - } - break; - } - })); - - this.ui = { - container, - styleSheet, - leftActionBar, - titleBar, - title, - description1, - description2, - widget, - rightActionBar, - checkAll, - inputContainer, - filterContainer, - inputBox, - visibleCountContainer, - visibleCount, - countContainer, - count, - okContainer, - ok, - message, - customButtonContainer, - customButton, - list, - progressBar, - onDidAccept: this.onDidAcceptEmitter.event, - onDidCustom: this.onDidCustomEmitter.event, - onDidTriggerButton: this.onDidTriggerButtonEmitter.event, - ignoreFocusOut: false, - keyMods: this.keyMods, - show: controller => this.show(controller), - hide: () => this.hide(), - setVisibilities: visibilities => this.setVisibilities(visibilities), - setEnabled: enabled => this.setEnabled(enabled), - setContextKey: contextKey => this.options.setContextKey(contextKey), - linkOpenerDelegate: content => this.options.linkOpenerDelegate(content) - }; - this.updateStyles(); - return this.ui; - } - - pick>(picks: Promise[]> | QuickPickInput[], options: O = {}, token: CancellationToken = CancellationToken.None): Promise<(O extends { canPickMany: true } ? T[] : T) | undefined> { - type R = (O extends { canPickMany: true } ? T[] : T) | undefined; - return new Promise((doResolve, reject) => { - let resolve = (result: R) => { - resolve = doResolve; - options.onKeyMods?.(input.keyMods); - doResolve(result); - }; - if (token.isCancellationRequested) { - resolve(undefined); - return; - } - const input = this.createQuickPick(); - let activeItem: T | undefined; - const disposables = [ - input, - input.onDidAccept(() => { - if (input.canSelectMany) { - resolve(input.selectedItems.slice()); - input.hide(); - } else { - const result = input.activeItems[0]; - if (result) { - resolve(result); - input.hide(); - } - } - }), - input.onDidChangeActive(items => { - const focused = items[0]; - if (focused && options.onDidFocus) { - options.onDidFocus(focused); - } - }), - input.onDidChangeSelection(items => { - if (!input.canSelectMany) { - const result = items[0]; - if (result) { - resolve(result); - input.hide(); - } - } - }), - input.onDidTriggerItemButton(event => options.onDidTriggerItemButton && options.onDidTriggerItemButton({ - ...event, - removeItem: () => { - const index = input.items.indexOf(event.item); - if (index !== -1) { - const items = input.items.slice(); - const removed = items.splice(index, 1); - const activeItems = input.activeItems.filter(activeItem => activeItem !== removed[0]); - const keepScrollPositionBefore = input.keepScrollPosition; - input.keepScrollPosition = true; - input.items = items; - if (activeItems) { - input.activeItems = activeItems; - } - input.keepScrollPosition = keepScrollPositionBefore; - } - } - })), - input.onDidTriggerSeparatorButton(event => options.onDidTriggerSeparatorButton?.(event)), - input.onDidChangeValue(value => { - if (activeItem && !value && (input.activeItems.length !== 1 || input.activeItems[0] !== activeItem)) { - input.activeItems = [activeItem]; - } - }), - token.onCancellationRequested(() => { - input.hide(); - }), - input.onDidHide(() => { - dispose(disposables); - resolve(undefined); - }), - ]; - input.title = options.title; - input.canSelectMany = !!options.canPickMany; - input.placeholder = options.placeHolder; - input.ignoreFocusOut = !!options.ignoreFocusLost; - input.matchOnDescription = !!options.matchOnDescription; - input.matchOnDetail = !!options.matchOnDetail; - input.matchOnLabel = (options.matchOnLabel === undefined) || options.matchOnLabel; // default to true - input.autoFocusOnList = (options.autoFocusOnList === undefined) || options.autoFocusOnList; // default to true - input.quickNavigate = options.quickNavigate; - input.hideInput = !!options.hideInput; - input.contextKey = options.contextKey; - input.busy = true; - Promise.all([picks, options.activeItem]) - .then(([items, _activeItem]) => { - activeItem = _activeItem; - input.busy = false; - input.items = items; - if (input.canSelectMany) { - input.selectedItems = items.filter(item => item.type !== 'separator' && item.picked) as T[]; - } - if (activeItem) { - input.activeItems = [activeItem]; - } - }); - input.show(); - Promise.resolve(picks).then(undefined, err => { - reject(err); - input.hide(); - }); - }); - } - - private setValidationOnInput(input: IInputBox, validationResult: string | { - content: string; - severity: Severity; - } | null | undefined) { - if (validationResult && isString(validationResult)) { - input.severity = Severity.Error; - input.validationMessage = validationResult; - } else if (validationResult && !isString(validationResult)) { - input.severity = validationResult.severity; - input.validationMessage = validationResult.content; - } else { - input.severity = Severity.Ignore; - input.validationMessage = undefined; - } - } - - input(options: IInputOptions = {}, token: CancellationToken = CancellationToken.None): Promise { - return new Promise((resolve) => { - if (token.isCancellationRequested) { - resolve(undefined); - return; - } - const input = this.createInputBox(); - const validateInput = options.validateInput || (() => >Promise.resolve(undefined)); - const onDidValueChange = Event.debounce(input.onDidChangeValue, (last, cur) => cur, 100); - let validationValue = options.value || ''; - let validation = Promise.resolve(validateInput(validationValue)); - const disposables = [ - input, - onDidValueChange(value => { - if (value !== validationValue) { - validation = Promise.resolve(validateInput(value)); - validationValue = value; - } - validation.then(result => { - if (value === validationValue) { - this.setValidationOnInput(input, result); - } - }); - }), - input.onDidAccept(() => { - const value = input.value; - if (value !== validationValue) { - validation = Promise.resolve(validateInput(value)); - validationValue = value; - } - validation.then(result => { - if (!result || (!isString(result) && result.severity !== Severity.Error)) { - resolve(value); - input.hide(); - } else if (value === validationValue) { - this.setValidationOnInput(input, result); - } - }); - }), - token.onCancellationRequested(() => { - input.hide(); - }), - input.onDidHide(() => { - dispose(disposables); - resolve(undefined); - }), - ]; - - input.title = options.title; - input.value = options.value || ''; - input.valueSelection = options.valueSelection; - input.prompt = options.prompt; - input.placeholder = options.placeHolder; - input.password = !!options.password; - input.ignoreFocusOut = !!options.ignoreFocusLost; - input.show(); - }); - } - - backButton = backButton; - - createQuickPick(): IQuickPick { - const ui = this.getUI(); - return new QuickPick(ui); - } - - createInputBox(): IInputBox { - const ui = this.getUI(); - return new InputBox(ui); - } - - private show(controller: QuickInput) { - const ui = this.getUI(); - this.onShowEmitter.fire(); - const oldController = this.controller; - this.controller = controller; - oldController?.didHide(); - - this.setEnabled(true); - ui.leftActionBar.clear(); - ui.title.textContent = ''; - ui.description1.textContent = ''; - ui.description2.textContent = ''; - dom.reset(ui.widget); - ui.rightActionBar.clear(); - ui.checkAll.checked = false; - // ui.inputBox.value = ''; Avoid triggering an event. - ui.inputBox.placeholder = ''; - ui.inputBox.password = false; - ui.inputBox.showDecoration(Severity.Ignore); - ui.visibleCount.setCount(0); - ui.count.setCount(0); - dom.reset(ui.message); - ui.progressBar.stop(); - ui.list.setElements([]); - ui.list.matchOnDescription = false; - ui.list.matchOnDetail = false; - ui.list.matchOnLabel = true; - ui.list.sortByLabel = true; - ui.ignoreFocusOut = false; - ui.inputBox.toggles = undefined; - - const backKeybindingLabel = this.options.backKeybindingLabel(); - backButton.tooltip = backKeybindingLabel ? localize('quickInput.backWithKeybinding', "Back ({0})", backKeybindingLabel) : localize('quickInput.back', "Back"); - - ui.container.style.display = ''; - this.updateLayout(); - ui.inputBox.setFocus(); - } - - private setVisibilities(visibilities: Visibilities) { - const ui = this.getUI(); - ui.title.style.display = visibilities.title ? '' : 'none'; - ui.description1.style.display = visibilities.description && (visibilities.inputBox || visibilities.checkAll) ? '' : 'none'; - ui.description2.style.display = visibilities.description && !(visibilities.inputBox || visibilities.checkAll) ? '' : 'none'; - ui.checkAll.style.display = visibilities.checkAll ? '' : 'none'; - ui.inputContainer.style.display = visibilities.inputBox ? '' : 'none'; - ui.filterContainer.style.display = visibilities.inputBox ? '' : 'none'; - ui.visibleCountContainer.style.display = visibilities.visibleCount ? '' : 'none'; - ui.countContainer.style.display = visibilities.count ? '' : 'none'; - ui.okContainer.style.display = visibilities.ok ? '' : 'none'; - ui.customButtonContainer.style.display = visibilities.customButton ? '' : 'none'; - ui.message.style.display = visibilities.message ? '' : 'none'; - ui.progressBar.getContainer().style.display = visibilities.progressBar ? '' : 'none'; - ui.list.display(!!visibilities.list); - ui.container.classList.toggle('show-checkboxes', !!visibilities.checkBox); - ui.container.classList.toggle('hidden-input', !visibilities.inputBox && !visibilities.description); - this.updateLayout(); // TODO - } - - private setEnabled(enabled: boolean) { - if (enabled !== this.enabled) { - this.enabled = enabled; - for (const item of this.getUI().leftActionBar.viewItems) { - (item as ActionViewItem).action.enabled = enabled; - } - for (const item of this.getUI().rightActionBar.viewItems) { - (item as ActionViewItem).action.enabled = enabled; - } - this.getUI().checkAll.disabled = !enabled; - this.getUI().inputBox.enabled = enabled; - this.getUI().ok.enabled = enabled; - this.getUI().list.enabled = enabled; - } - } - - hide(reason?: QuickInputHideReason) { - const controller = this.controller; - if (!controller) { +export class QuickWidget extends QuickInput implements IQuickWidget { + protected override update() { + if (!this.visible) { return; } - const focusChanged = !dom.isAncestor(document.activeElement, this.ui?.container ?? null); - this.controller = null; - this.onHideEmitter.fire(); - this.getUI().container.style.display = 'none'; - if (!focusChanged) { - let currentElement = this.previousFocusElement; - while (currentElement && !currentElement.offsetParent) { - currentElement = currentElement.parentElement ?? undefined; - } - if (currentElement?.offsetParent) { - currentElement.focus(); - this.previousFocusElement = undefined; - } else { - this.options.returnFocus(); - } - } - controller.didHide(reason); - } - - focus() { - if (this.isDisplayed()) { - const ui = this.getUI(); - if (ui.inputBox.enabled) { - ui.inputBox.setFocus(); - } else { - ui.list.domFocus(); - } - } - } - - toggle() { - if (this.isDisplayed() && this.controller instanceof QuickPick && this.controller.canSelectMany) { - this.getUI().list.toggleCheckbox(); - } - } - - navigate(next: boolean, quickNavigate?: IQuickNavigateConfiguration) { - if (this.isDisplayed() && this.getUI().list.isDisplayed()) { - this.getUI().list.focus(next ? QuickInputListFocus.Next : QuickInputListFocus.Previous); - if (quickNavigate && this.controller instanceof QuickPick) { - this.controller.quickNavigate = quickNavigate; - } - } - } - - async accept(keyMods: IKeyMods = { alt: false, ctrlCmd: false }) { - // When accepting the item programmatically, it is important that - // we update `keyMods` either from the provided set or unset it - // because the accept did not happen from mouse or keyboard - // interaction on the list itself - this.keyMods.alt = keyMods.alt; - this.keyMods.ctrlCmd = keyMods.ctrlCmd; - - this.onDidAcceptEmitter.fire(); - } - - async back() { - this.onDidTriggerButtonEmitter.fire(this.backButton); - } - - async cancel() { - this.hide(); - } - - layout(dimension: dom.IDimension, titleBarOffset: number): void { - this.dimension = dimension; - this.titleBarOffset = titleBarOffset; - this.updateLayout(); - } - - private updateLayout() { - if (this.ui && this.isDisplayed()) { - this.ui.container.style.top = `${this.titleBarOffset}px`; - - const style = this.ui.container.style; - const width = Math.min(this.dimension!.width * 0.62 /* golden cut */, QuickInputController.MAX_WIDTH); - style.width = width + 'px'; - style.marginLeft = '-' + (width / 2) + 'px'; - - this.ui.inputBox.layout(); - this.ui.list.layout(this.dimension && this.dimension.height * 0.4); - } - } - - applyStyles(styles: IQuickInputStyles) { - this.styles = styles; - this.updateStyles(); - } - - private updateStyles() { - if (this.ui) { - const { - quickInputTitleBackground, - quickInputBackground, - quickInputForeground, - widgetBorder, - widgetShadow, - } = this.styles.widget; - this.ui.titleBar.style.backgroundColor = quickInputTitleBackground ?? ''; - this.ui.container.style.backgroundColor = quickInputBackground ?? ''; - this.ui.container.style.color = quickInputForeground ?? ''; - this.ui.container.style.border = widgetBorder ? `1px solid ${widgetBorder}` : ''; - this.ui.container.style.boxShadow = widgetShadow ? `0 0 8px 2px ${widgetShadow}` : ''; - this.ui.list.style(this.styles.list); - - const content: string[] = []; - if (this.styles.pickerGroup.pickerGroupBorder) { - content.push(`.quick-input-list .quick-input-list-entry { border-top-color: ${this.styles.pickerGroup.pickerGroupBorder}; }`); - } - if (this.styles.pickerGroup.pickerGroupForeground) { - content.push(`.quick-input-list .quick-input-list-separator { color: ${this.styles.pickerGroup.pickerGroupForeground}; }`); - } - if (this.styles.pickerGroup.pickerGroupForeground) { - content.push(`.quick-input-list .quick-input-list-separator-as-item { color: ${this.styles.pickerGroup.pickerGroupForeground}; }`); - } - - if ( - this.styles.keybindingLabel.keybindingLabelBackground || - this.styles.keybindingLabel.keybindingLabelBorder || - this.styles.keybindingLabel.keybindingLabelBottomBorder || - this.styles.keybindingLabel.keybindingLabelShadow || - this.styles.keybindingLabel.keybindingLabelForeground - ) { - content.push('.quick-input-list .monaco-keybinding > .monaco-keybinding-key {'); - if (this.styles.keybindingLabel.keybindingLabelBackground) { - content.push(`background-color: ${this.styles.keybindingLabel.keybindingLabelBackground};`); - } - if (this.styles.keybindingLabel.keybindingLabelBorder) { - // Order matters here. `border-color` must come before `border-bottom-color`. - content.push(`border-color: ${this.styles.keybindingLabel.keybindingLabelBorder};`); - } - if (this.styles.keybindingLabel.keybindingLabelBottomBorder) { - content.push(`border-bottom-color: ${this.styles.keybindingLabel.keybindingLabelBottomBorder};`); - } - if (this.styles.keybindingLabel.keybindingLabelShadow) { - content.push(`box-shadow: inset 0 -1px 0 ${this.styles.keybindingLabel.keybindingLabelShadow};`); - } - if (this.styles.keybindingLabel.keybindingLabelForeground) { - content.push(`color: ${this.styles.keybindingLabel.keybindingLabelForeground};`); - } - content.push('}'); - } - - const newStyles = content.join('\n'); - if (newStyles !== this.ui.styleSheet.textContent) { - this.ui.styleSheet.textContent = newStyles; - } - } - } + const visibilities: Visibilities = { + title: !!this.title || !!this.step || !!this.buttons.length, + description: !!this.description || !!this.step, + progressBar: true + }; - private isDisplayed() { - return this.ui && this.ui.container.style.display !== 'none'; + this.ui.setVisibilities(visibilities); + super.update(); } } - -export interface IQuickInputControllerHost extends ILayoutService { } diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts new file mode 100644 index 0000000000000..0328a6a6ded26 --- /dev/null +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -0,0 +1,726 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from 'vs/base/browser/dom'; +import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; +import { ActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; +import { Button } from 'vs/base/browser/ui/button/button'; +import { CountBadge } from 'vs/base/browser/ui/countBadge/countBadge'; +import { ProgressBar } from 'vs/base/browser/ui/progressbar/progressbar'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { Emitter, Event } from 'vs/base/common/event'; +import { KeyCode } from 'vs/base/common/keyCodes'; +import { Disposable, dispose } from 'vs/base/common/lifecycle'; +import Severity from 'vs/base/common/severity'; +import { isString } from 'vs/base/common/types'; +import { localize } from 'vs/nls'; +import { IInputBox, IInputOptions, IKeyMods, IPickOptions, IQuickInput, IQuickInputButton, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, IQuickWidget, QuickInputHideReason, QuickPickInput } from 'vs/platform/quickinput/common/quickInput'; +import { QuickInputBox } from 'vs/platform/quickinput/browser/quickInputBox'; +import { QuickInputList, QuickInputListFocus } from 'vs/platform/quickinput/browser/quickInputList'; +import { QuickInputUI, Writeable, IQuickInputStyles, IQuickInputOptions, QuickPick, backButton, InputBox, Visibilities, QuickWidget } from 'vs/platform/quickinput/browser/quickInput'; +import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; + +const $ = dom.$; + +export class QuickInputController extends Disposable { + private static readonly MAX_WIDTH = 600; // Max total width of quick input widget + + private idPrefix: string; + private ui: QuickInputUI | undefined; + private dimension?: dom.IDimension; + private titleBarOffset?: number; + private enabled = true; + private readonly onDidAcceptEmitter = this._register(new Emitter()); + private readonly onDidCustomEmitter = this._register(new Emitter()); + private readonly onDidTriggerButtonEmitter = this._register(new Emitter()); + private keyMods: Writeable = { ctrlCmd: false, alt: false }; + + private controller: IQuickInput | null = null; + + private parentElement: HTMLElement; + private styles: IQuickInputStyles; + + private onShowEmitter = this._register(new Emitter()); + readonly onShow = this.onShowEmitter.event; + + private onHideEmitter = this._register(new Emitter()); + readonly onHide = this.onHideEmitter.event; + + private previousFocusElement?: HTMLElement; + + constructor(private options: IQuickInputOptions) { + super(); + this.idPrefix = options.idPrefix; + this.parentElement = options.container; + this.styles = options.styles; + this.registerKeyModsListeners(); + } + + private registerKeyModsListeners() { + const listener = (e: KeyboardEvent | MouseEvent) => { + this.keyMods.ctrlCmd = e.ctrlKey || e.metaKey; + this.keyMods.alt = e.altKey; + }; + this._register(dom.addDisposableListener(window, dom.EventType.KEY_DOWN, listener, true)); + this._register(dom.addDisposableListener(window, dom.EventType.KEY_UP, listener, true)); + this._register(dom.addDisposableListener(window, dom.EventType.MOUSE_DOWN, listener, true)); + } + + private getUI() { + if (this.ui) { + return this.ui; + } + + const container = dom.append(this.parentElement, $('.quick-input-widget.show-file-icons')); + container.tabIndex = -1; + container.style.display = 'none'; + + const styleSheet = dom.createStyleSheet(container); + + const titleBar = dom.append(container, $('.quick-input-titlebar')); + + const leftActionBar = this._register(new ActionBar(titleBar)); + leftActionBar.domNode.classList.add('quick-input-left-action-bar'); + + const title = dom.append(titleBar, $('.quick-input-title')); + + const rightActionBar = this._register(new ActionBar(titleBar)); + rightActionBar.domNode.classList.add('quick-input-right-action-bar'); + + const headerContainer = dom.append(container, $('.quick-input-header')); + + const checkAll = dom.append(headerContainer, $('input.quick-input-check-all')); + checkAll.type = 'checkbox'; + checkAll.setAttribute('aria-label', localize('quickInput.checkAll', "Toggle all checkboxes")); + this._register(dom.addStandardDisposableListener(checkAll, dom.EventType.CHANGE, e => { + const checked = checkAll.checked; + list.setAllVisibleChecked(checked); + })); + this._register(dom.addDisposableListener(checkAll, dom.EventType.CLICK, e => { + if (e.x || e.y) { // Avoid 'click' triggered by 'space'... + inputBox.setFocus(); + } + })); + + const description2 = dom.append(headerContainer, $('.quick-input-description')); + const inputContainer = dom.append(headerContainer, $('.quick-input-and-message')); + const filterContainer = dom.append(inputContainer, $('.quick-input-filter')); + + const inputBox = this._register(new QuickInputBox(filterContainer, this.styles.inputBox, this.styles.toggle)); + inputBox.setAttribute('aria-describedby', `${this.idPrefix}message`); + + const visibleCountContainer = dom.append(filterContainer, $('.quick-input-visible-count')); + visibleCountContainer.setAttribute('aria-live', 'polite'); + visibleCountContainer.setAttribute('aria-atomic', 'true'); + const visibleCount = new CountBadge(visibleCountContainer, { countFormat: localize({ key: 'quickInput.visibleCount', comment: ['This tells the user how many items are shown in a list of items to select from. The items can be anything. Currently not visible, but read by screen readers.'] }, "{0} Results") }, this.styles.countBadge); + + const countContainer = dom.append(filterContainer, $('.quick-input-count')); + countContainer.setAttribute('aria-live', 'polite'); + const count = new CountBadge(countContainer, { countFormat: localize({ key: 'quickInput.countSelected', comment: ['This tells the user how many items are selected in a list of items to select from. The items can be anything.'] }, "{0} Selected") }, this.styles.countBadge); + + const okContainer = dom.append(headerContainer, $('.quick-input-action')); + const ok = new Button(okContainer, this.styles.button); + ok.label = localize('ok', "OK"); + this._register(ok.onDidClick(e => { + this.onDidAcceptEmitter.fire(); + })); + + const customButtonContainer = dom.append(headerContainer, $('.quick-input-action')); + const customButton = new Button(customButtonContainer, this.styles.button); + customButton.label = localize('custom', "Custom"); + this._register(customButton.onDidClick(e => { + this.onDidCustomEmitter.fire(); + })); + + const message = dom.append(inputContainer, $(`#${this.idPrefix}message.quick-input-message`)); + + const progressBar = new ProgressBar(container, this.styles.progressBar); + progressBar.getContainer().classList.add('quick-input-progress'); + + const widget = dom.append(container, $('.quick-input-html-widget')); + widget.tabIndex = -1; + + const description1 = dom.append(container, $('.quick-input-description')); + + const listId = this.idPrefix + 'list'; + const list = this._register(new QuickInputList(container, listId, this.options)); + inputBox.setAttribute('aria-controls', listId); + this._register(list.onDidChangeFocus(() => { + inputBox.setAttribute('aria-activedescendant', list.getActiveDescendant() ?? ''); + })); + this._register(list.onChangedAllVisibleChecked(checked => { + checkAll.checked = checked; + })); + this._register(list.onChangedVisibleCount(c => { + visibleCount.setCount(c); + })); + this._register(list.onChangedCheckedCount(c => { + count.setCount(c); + })); + this._register(list.onLeave(() => { + // Defer to avoid the input field reacting to the triggering key. + setTimeout(() => { + inputBox.setFocus(); + if (this.controller instanceof QuickPick && this.controller.canSelectMany) { + list.clearFocus(); + } + }, 0); + })); + + const focusTracker = dom.trackFocus(container); + this._register(focusTracker); + this._register(dom.addDisposableListener(container, dom.EventType.FOCUS, e => { + // Ignore focus events within container + if (dom.isAncestor(e.relatedTarget as HTMLElement, container)) { + return; + } + this.previousFocusElement = e.relatedTarget instanceof HTMLElement ? e.relatedTarget : undefined; + }, true)); + this._register(focusTracker.onDidBlur(() => { + if (!this.getUI().ignoreFocusOut && !this.options.ignoreFocusOut()) { + this.hide(QuickInputHideReason.Blur); + } + this.previousFocusElement = undefined; + })); + this._register(dom.addDisposableListener(container, dom.EventType.FOCUS, (e: FocusEvent) => { + inputBox.setFocus(); + })); + // TODO: Turn into commands instead of handling KEY_DOWN + this._register(dom.addStandardDisposableListener(container, dom.EventType.KEY_DOWN, (event) => { + if (dom.isAncestor(event.target, widget)) { + return; // Ignore event if target is inside widget to allow the widget to handle the event. + } + switch (event.keyCode) { + case KeyCode.Enter: + dom.EventHelper.stop(event, true); + if (this.enabled) { + this.onDidAcceptEmitter.fire(); + } + break; + case KeyCode.Escape: + dom.EventHelper.stop(event, true); + this.hide(QuickInputHideReason.Gesture); + break; + case KeyCode.Tab: + if (!event.altKey && !event.ctrlKey && !event.metaKey) { + // detect only visible actions + const selectors = [ + '.quick-input-list .monaco-action-bar .always-visible', + '.quick-input-list-entry:hover .monaco-action-bar', + '.monaco-list-row.focused .monaco-action-bar' + ]; + + if (container.classList.contains('show-checkboxes')) { + selectors.push('input'); + } else { + selectors.push('input[type=text]'); + } + if (this.getUI().list.isDisplayed()) { + selectors.push('.monaco-list'); + } + // focus links if there are any + if (this.getUI().message) { + selectors.push('.quick-input-message a'); + } + + if (this.getUI().widget) { + if (dom.isAncestor(event.target, this.getUI().widget)) { + // let the widget control tab + break; + } + selectors.push('.quick-input-html-widget'); + } + const stops = container.querySelectorAll(selectors.join(', ')); + if (event.shiftKey && event.target === stops[0]) { + // Clear the focus from the list in order to allow + // screen readers to read operations in the input box. + dom.EventHelper.stop(event, true); + list.clearFocus(); + } else if (!event.shiftKey && dom.isAncestor(event.target, stops[stops.length - 1])) { + dom.EventHelper.stop(event, true); + stops[0].focus(); + } + } + break; + case KeyCode.Space: + if (event.ctrlKey) { + dom.EventHelper.stop(event, true); + this.getUI().list.toggleHover(); + } + break; + } + })); + + this.ui = { + container, + styleSheet, + leftActionBar, + titleBar, + title, + description1, + description2, + widget, + rightActionBar, + checkAll, + inputContainer, + filterContainer, + inputBox, + visibleCountContainer, + visibleCount, + countContainer, + count, + okContainer, + ok, + message, + customButtonContainer, + customButton, + list, + progressBar, + onDidAccept: this.onDidAcceptEmitter.event, + onDidCustom: this.onDidCustomEmitter.event, + onDidTriggerButton: this.onDidTriggerButtonEmitter.event, + ignoreFocusOut: false, + keyMods: this.keyMods, + show: controller => this.show(controller), + hide: () => this.hide(), + setVisibilities: visibilities => this.setVisibilities(visibilities), + setEnabled: enabled => this.setEnabled(enabled), + setContextKey: contextKey => this.options.setContextKey(contextKey), + linkOpenerDelegate: content => this.options.linkOpenerDelegate(content) + }; + this.updateStyles(); + return this.ui; + } + + pick>(picks: Promise[]> | QuickPickInput[], options: O = {}, token: CancellationToken = CancellationToken.None): Promise<(O extends { canPickMany: true } ? T[] : T) | undefined> { + type R = (O extends { canPickMany: true } ? T[] : T) | undefined; + return new Promise((doResolve, reject) => { + let resolve = (result: R) => { + resolve = doResolve; + options.onKeyMods?.(input.keyMods); + doResolve(result); + }; + if (token.isCancellationRequested) { + resolve(undefined); + return; + } + const input = this.createQuickPick(); + let activeItem: T | undefined; + const disposables = [ + input, + input.onDidAccept(() => { + if (input.canSelectMany) { + resolve(input.selectedItems.slice()); + input.hide(); + } else { + const result = input.activeItems[0]; + if (result) { + resolve(result); + input.hide(); + } + } + }), + input.onDidChangeActive(items => { + const focused = items[0]; + if (focused && options.onDidFocus) { + options.onDidFocus(focused); + } + }), + input.onDidChangeSelection(items => { + if (!input.canSelectMany) { + const result = items[0]; + if (result) { + resolve(result); + input.hide(); + } + } + }), + input.onDidTriggerItemButton(event => options.onDidTriggerItemButton && options.onDidTriggerItemButton({ + ...event, + removeItem: () => { + const index = input.items.indexOf(event.item); + if (index !== -1) { + const items = input.items.slice(); + const removed = items.splice(index, 1); + const activeItems = input.activeItems.filter(activeItem => activeItem !== removed[0]); + const keepScrollPositionBefore = input.keepScrollPosition; + input.keepScrollPosition = true; + input.items = items; + if (activeItems) { + input.activeItems = activeItems; + } + input.keepScrollPosition = keepScrollPositionBefore; + } + } + })), + input.onDidTriggerSeparatorButton(event => options.onDidTriggerSeparatorButton?.(event)), + input.onDidChangeValue(value => { + if (activeItem && !value && (input.activeItems.length !== 1 || input.activeItems[0] !== activeItem)) { + input.activeItems = [activeItem]; + } + }), + token.onCancellationRequested(() => { + input.hide(); + }), + input.onDidHide(() => { + dispose(disposables); + resolve(undefined); + }), + ]; + input.title = options.title; + input.canSelectMany = !!options.canPickMany; + input.placeholder = options.placeHolder; + input.ignoreFocusOut = !!options.ignoreFocusLost; + input.matchOnDescription = !!options.matchOnDescription; + input.matchOnDetail = !!options.matchOnDetail; + input.matchOnLabel = (options.matchOnLabel === undefined) || options.matchOnLabel; // default to true + input.autoFocusOnList = (options.autoFocusOnList === undefined) || options.autoFocusOnList; // default to true + input.quickNavigate = options.quickNavigate; + input.hideInput = !!options.hideInput; + input.contextKey = options.contextKey; + input.busy = true; + Promise.all([picks, options.activeItem]) + .then(([items, _activeItem]) => { + activeItem = _activeItem; + input.busy = false; + input.items = items; + if (input.canSelectMany) { + input.selectedItems = items.filter(item => item.type !== 'separator' && item.picked) as T[]; + } + if (activeItem) { + input.activeItems = [activeItem]; + } + }); + input.show(); + Promise.resolve(picks).then(undefined, err => { + reject(err); + input.hide(); + }); + }); + } + + private setValidationOnInput(input: IInputBox, validationResult: string | { + content: string; + severity: Severity; + } | null | undefined) { + if (validationResult && isString(validationResult)) { + input.severity = Severity.Error; + input.validationMessage = validationResult; + } else if (validationResult && !isString(validationResult)) { + input.severity = validationResult.severity; + input.validationMessage = validationResult.content; + } else { + input.severity = Severity.Ignore; + input.validationMessage = undefined; + } + } + + input(options: IInputOptions = {}, token: CancellationToken = CancellationToken.None): Promise { + return new Promise((resolve) => { + if (token.isCancellationRequested) { + resolve(undefined); + return; + } + const input = this.createInputBox(); + const validateInput = options.validateInput || (() => >Promise.resolve(undefined)); + const onDidValueChange = Event.debounce(input.onDidChangeValue, (last, cur) => cur, 100); + let validationValue = options.value || ''; + let validation = Promise.resolve(validateInput(validationValue)); + const disposables = [ + input, + onDidValueChange(value => { + if (value !== validationValue) { + validation = Promise.resolve(validateInput(value)); + validationValue = value; + } + validation.then(result => { + if (value === validationValue) { + this.setValidationOnInput(input, result); + } + }); + }), + input.onDidAccept(() => { + const value = input.value; + if (value !== validationValue) { + validation = Promise.resolve(validateInput(value)); + validationValue = value; + } + validation.then(result => { + if (!result || (!isString(result) && result.severity !== Severity.Error)) { + resolve(value); + input.hide(); + } else if (value === validationValue) { + this.setValidationOnInput(input, result); + } + }); + }), + token.onCancellationRequested(() => { + input.hide(); + }), + input.onDidHide(() => { + dispose(disposables); + resolve(undefined); + }), + ]; + + input.title = options.title; + input.value = options.value || ''; + input.valueSelection = options.valueSelection; + input.prompt = options.prompt; + input.placeholder = options.placeHolder; + input.password = !!options.password; + input.ignoreFocusOut = !!options.ignoreFocusLost; + input.show(); + }); + } + + backButton = backButton; + + createQuickPick(): IQuickPick { + const ui = this.getUI(); + return new QuickPick(ui); + } + + createInputBox(): IInputBox { + const ui = this.getUI(); + return new InputBox(ui); + } + + createQuickWidget(): IQuickWidget { + const ui = this.getUI(); + return new QuickWidget(ui); + } + + private show(controller: IQuickInput) { + const ui = this.getUI(); + this.onShowEmitter.fire(); + const oldController = this.controller; + this.controller = controller; + oldController?.didHide(); + + this.setEnabled(true); + ui.leftActionBar.clear(); + ui.title.textContent = ''; + ui.description1.textContent = ''; + ui.description2.textContent = ''; + dom.reset(ui.widget); + ui.rightActionBar.clear(); + ui.checkAll.checked = false; + // ui.inputBox.value = ''; Avoid triggering an event. + ui.inputBox.placeholder = ''; + ui.inputBox.password = false; + ui.inputBox.showDecoration(Severity.Ignore); + ui.visibleCount.setCount(0); + ui.count.setCount(0); + dom.reset(ui.message); + ui.progressBar.stop(); + ui.list.setElements([]); + ui.list.matchOnDescription = false; + ui.list.matchOnDetail = false; + ui.list.matchOnLabel = true; + ui.list.sortByLabel = true; + ui.ignoreFocusOut = false; + ui.inputBox.toggles = undefined; + + const backKeybindingLabel = this.options.backKeybindingLabel(); + backButton.tooltip = backKeybindingLabel ? localize('quickInput.backWithKeybinding', "Back ({0})", backKeybindingLabel) : localize('quickInput.back', "Back"); + + ui.container.style.display = ''; + this.updateLayout(); + ui.inputBox.setFocus(); + } + + private setVisibilities(visibilities: Visibilities) { + const ui = this.getUI(); + ui.title.style.display = visibilities.title ? '' : 'none'; + ui.description1.style.display = visibilities.description && (visibilities.inputBox || visibilities.checkAll) ? '' : 'none'; + ui.description2.style.display = visibilities.description && !(visibilities.inputBox || visibilities.checkAll) ? '' : 'none'; + ui.checkAll.style.display = visibilities.checkAll ? '' : 'none'; + ui.inputContainer.style.display = visibilities.inputBox ? '' : 'none'; + ui.filterContainer.style.display = visibilities.inputBox ? '' : 'none'; + ui.visibleCountContainer.style.display = visibilities.visibleCount ? '' : 'none'; + ui.countContainer.style.display = visibilities.count ? '' : 'none'; + ui.okContainer.style.display = visibilities.ok ? '' : 'none'; + ui.customButtonContainer.style.display = visibilities.customButton ? '' : 'none'; + ui.message.style.display = visibilities.message ? '' : 'none'; + ui.progressBar.getContainer().style.display = visibilities.progressBar ? '' : 'none'; + ui.list.display(!!visibilities.list); + ui.container.classList.toggle('show-checkboxes', !!visibilities.checkBox); + ui.container.classList.toggle('hidden-input', !visibilities.inputBox && !visibilities.description); + this.updateLayout(); // TODO + } + + private setEnabled(enabled: boolean) { + if (enabled !== this.enabled) { + this.enabled = enabled; + for (const item of this.getUI().leftActionBar.viewItems) { + (item as ActionViewItem).action.enabled = enabled; + } + for (const item of this.getUI().rightActionBar.viewItems) { + (item as ActionViewItem).action.enabled = enabled; + } + this.getUI().checkAll.disabled = !enabled; + this.getUI().inputBox.enabled = enabled; + this.getUI().ok.enabled = enabled; + this.getUI().list.enabled = enabled; + } + } + + hide(reason?: QuickInputHideReason) { + const controller = this.controller; + if (!controller) { + return; + } + + const focusChanged = !dom.isAncestor(document.activeElement, this.ui?.container ?? null); + this.controller = null; + this.onHideEmitter.fire(); + this.getUI().container.style.display = 'none'; + if (!focusChanged) { + let currentElement = this.previousFocusElement; + while (currentElement && !currentElement.offsetParent) { + currentElement = currentElement.parentElement ?? undefined; + } + if (currentElement?.offsetParent) { + currentElement.focus(); + this.previousFocusElement = undefined; + } else { + this.options.returnFocus(); + } + } + controller.didHide(reason); + } + + focus() { + if (this.isDisplayed()) { + const ui = this.getUI(); + if (ui.inputBox.enabled) { + ui.inputBox.setFocus(); + } else { + ui.list.domFocus(); + } + } + } + + toggle() { + if (this.isDisplayed() && this.controller instanceof QuickPick && this.controller.canSelectMany) { + this.getUI().list.toggleCheckbox(); + } + } + + navigate(next: boolean, quickNavigate?: IQuickNavigateConfiguration) { + if (this.isDisplayed() && this.getUI().list.isDisplayed()) { + this.getUI().list.focus(next ? QuickInputListFocus.Next : QuickInputListFocus.Previous); + if (quickNavigate && this.controller instanceof QuickPick) { + this.controller.quickNavigate = quickNavigate; + } + } + } + + async accept(keyMods: IKeyMods = { alt: false, ctrlCmd: false }) { + // When accepting the item programmatically, it is important that + // we update `keyMods` either from the provided set or unset it + // because the accept did not happen from mouse or keyboard + // interaction on the list itself + this.keyMods.alt = keyMods.alt; + this.keyMods.ctrlCmd = keyMods.ctrlCmd; + + this.onDidAcceptEmitter.fire(); + } + + async back() { + this.onDidTriggerButtonEmitter.fire(this.backButton); + } + + async cancel() { + this.hide(); + } + + layout(dimension: dom.IDimension, titleBarOffset: number): void { + this.dimension = dimension; + this.titleBarOffset = titleBarOffset; + this.updateLayout(); + } + + private updateLayout() { + if (this.ui && this.isDisplayed()) { + this.ui.container.style.top = `${this.titleBarOffset}px`; + + const style = this.ui.container.style; + const width = Math.min(this.dimension!.width * 0.62 /* golden cut */, QuickInputController.MAX_WIDTH); + style.width = width + 'px'; + style.marginLeft = '-' + (width / 2) + 'px'; + + this.ui.inputBox.layout(); + this.ui.list.layout(this.dimension && this.dimension.height * 0.4); + } + } + + applyStyles(styles: IQuickInputStyles) { + this.styles = styles; + this.updateStyles(); + } + + private updateStyles() { + if (this.ui) { + const { + quickInputTitleBackground, quickInputBackground, quickInputForeground, widgetBorder, widgetShadow, + } = this.styles.widget; + this.ui.titleBar.style.backgroundColor = quickInputTitleBackground ?? ''; + this.ui.container.style.backgroundColor = quickInputBackground ?? ''; + this.ui.container.style.color = quickInputForeground ?? ''; + this.ui.container.style.border = widgetBorder ? `1px solid ${widgetBorder}` : ''; + this.ui.container.style.boxShadow = widgetShadow ? `0 0 8px 2px ${widgetShadow}` : ''; + this.ui.list.style(this.styles.list); + + const content: string[] = []; + if (this.styles.pickerGroup.pickerGroupBorder) { + content.push(`.quick-input-list .quick-input-list-entry { border-top-color: ${this.styles.pickerGroup.pickerGroupBorder}; }`); + } + if (this.styles.pickerGroup.pickerGroupForeground) { + content.push(`.quick-input-list .quick-input-list-separator { color: ${this.styles.pickerGroup.pickerGroupForeground}; }`); + } + if (this.styles.pickerGroup.pickerGroupForeground) { + content.push(`.quick-input-list .quick-input-list-separator-as-item { color: ${this.styles.pickerGroup.pickerGroupForeground}; }`); + } + + if (this.styles.keybindingLabel.keybindingLabelBackground || + this.styles.keybindingLabel.keybindingLabelBorder || + this.styles.keybindingLabel.keybindingLabelBottomBorder || + this.styles.keybindingLabel.keybindingLabelShadow || + this.styles.keybindingLabel.keybindingLabelForeground) { + content.push('.quick-input-list .monaco-keybinding > .monaco-keybinding-key {'); + if (this.styles.keybindingLabel.keybindingLabelBackground) { + content.push(`background-color: ${this.styles.keybindingLabel.keybindingLabelBackground};`); + } + if (this.styles.keybindingLabel.keybindingLabelBorder) { + // Order matters here. `border-color` must come before `border-bottom-color`. + content.push(`border-color: ${this.styles.keybindingLabel.keybindingLabelBorder};`); + } + if (this.styles.keybindingLabel.keybindingLabelBottomBorder) { + content.push(`border-bottom-color: ${this.styles.keybindingLabel.keybindingLabelBottomBorder};`); + } + if (this.styles.keybindingLabel.keybindingLabelShadow) { + content.push(`box-shadow: inset 0 -1px 0 ${this.styles.keybindingLabel.keybindingLabelShadow};`); + } + if (this.styles.keybindingLabel.keybindingLabelForeground) { + content.push(`color: ${this.styles.keybindingLabel.keybindingLabelForeground};`); + } + content.push('}'); + } + + const newStyles = content.join('\n'); + if (newStyles !== this.ui.styleSheet.textContent) { + this.ui.styleSheet.textContent = newStyles; + } + } + } + + private isDisplayed() { + return this.ui && this.ui.container.style.display !== 'none'; + } +} +export interface IQuickInputControllerHost extends ILayoutService { } + diff --git a/src/vs/platform/quickinput/browser/quickInputService.ts b/src/vs/platform/quickinput/browser/quickInputService.ts index e2f1fd70fdb4e..f797cd31f05bd 100644 --- a/src/vs/platform/quickinput/browser/quickInputService.ts +++ b/src/vs/platform/quickinput/browser/quickInputService.ts @@ -14,12 +14,12 @@ import { IWorkbenchListOptions, WorkbenchList } from 'vs/platform/list/browser/l import { IOpenerService } from 'vs/platform/opener/common/opener'; import { QuickAccessController } from 'vs/platform/quickinput/browser/quickAccess'; import { IQuickAccessController } from 'vs/platform/quickinput/common/quickAccess'; -import { IInputBox, IInputOptions, IKeyMods, IPickOptions, IQuickInputButton, IQuickInputService, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, QuickPickInput } from 'vs/platform/quickinput/common/quickInput'; +import { IInputBox, IInputOptions, IKeyMods, IPickOptions, IQuickInputButton, IQuickInputService, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, IQuickWidget, QuickPickInput } from 'vs/platform/quickinput/common/quickInput'; import { defaultButtonStyles, defaultCountBadgeStyles, defaultInputBoxStyles, defaultKeybindingLabelStyles, defaultProgressBarStyles, defaultToggleStyles, getListStyles } from 'vs/platform/theme/browser/defaultStyles'; import { activeContrastBorder, asCssVariable, pickerGroupBorder, pickerGroupForeground, quickInputBackground, quickInputForeground, quickInputListFocusBackground, quickInputListFocusForeground, quickInputListFocusIconForeground, quickInputTitleBackground, widgetBorder, widgetShadow } from 'vs/platform/theme/common/colorRegistry'; import { IThemeService, Themable } from 'vs/platform/theme/common/themeService'; -import { QuickInputController, IQuickInputControllerHost, IQuickInputOptions, IQuickInputStyles } from './quickInput'; - +import { IQuickInputOptions, IQuickInputStyles } from './quickInput'; +import { QuickInputController, IQuickInputControllerHost } from 'vs/platform/quickinput/browser/quickInputController'; export class QuickInputService extends Themable implements IQuickInputService { @@ -162,6 +162,10 @@ export class QuickInputService extends Themable implements IQuickInputService { return this.controller.createInputBox(); } + createQuickWidget(): IQuickWidget { + return this.controller.createQuickWidget(); + } + focus() { this.controller.focus(); } diff --git a/src/vs/platform/quickinput/common/quickInput.ts b/src/vs/platform/quickinput/common/quickInput.ts index 8106d17087080..4bec55d912df1 100644 --- a/src/vs/platform/quickinput/common/quickInput.ts +++ b/src/vs/platform/quickinput/common/quickInput.ts @@ -214,7 +214,8 @@ export interface IQuickInput extends IDisposable { description: string | undefined; /** - * Should be an HTMLElement (TODO: move this entire file into browser) + * Should be an HTMLElement. + * @deprecated Use an IQuickWidget instead */ widget: any | undefined; @@ -222,6 +223,10 @@ export interface IQuickInput extends IDisposable { totalSteps: number | undefined; + buttons: ReadonlyArray; + + readonly onDidTriggerButton: Event; + enabled: boolean; contextKey: string | undefined; @@ -233,6 +238,15 @@ export interface IQuickInput extends IDisposable { show(): void; hide(): void; + + didHide(reason?: QuickInputHideReason): void; +} + +export interface IQuickWidget extends IQuickInput { + /** + * Should be an HTMLElement (TODO: move this entire file into browser) + */ + widget: any | undefined; } export interface IQuickPickWillAcceptEvent { @@ -297,10 +311,6 @@ export interface IQuickPick extends IQuickInput { customHover: string | undefined; - buttons: ReadonlyArray; - - readonly onDidTriggerButton: Event; - readonly onDidTriggerItemButton: Event>; readonly onDidTriggerSeparatorButton: Event; @@ -413,16 +423,6 @@ export interface IInputBox extends IQuickInput { */ readonly onDidAccept: Event; - /** - * Buttons to show in addition to user input submission. - */ - buttons: ReadonlyArray; - - /** - * Event called when a button is selected. - */ - readonly onDidTriggerButton: Event; - /** * Text show below the input box. */ @@ -554,10 +554,15 @@ export interface IQuickInputService { createQuickPick(): IQuickPick; /** - * Provides raw access to the quick input controller. + * Provides raw access to the input box controller. */ createInputBox(): IInputBox; + /** + * Provides raw access to the quick widget controller. + */ + createQuickWidget(): IQuickWidget; + /** * Moves focus into quick input. */ diff --git a/src/vs/platform/quickinput/test/browser/quickinput.test.ts b/src/vs/platform/quickinput/test/browser/quickinput.test.ts index 6824b9b1d2c78..b50d6523c68ea 100644 --- a/src/vs/platform/quickinput/test/browser/quickinput.test.ts +++ b/src/vs/platform/quickinput/test/browser/quickinput.test.ts @@ -13,7 +13,7 @@ import { raceTimeout } from 'vs/base/common/async'; import { unthemedCountStyles } from 'vs/base/browser/ui/countBadge/countBadge'; import { unthemedKeybindingLabelOptions } from 'vs/base/browser/ui/keybindingLabel/keybindingLabel'; import { unthemedProgressBarOptions } from 'vs/base/browser/ui/progressbar/progressbar'; -import { QuickInputController } from 'vs/platform/quickinput/browser/quickInput'; +import { QuickInputController } from 'vs/platform/quickinput/browser/quickInputController'; import { IQuickPick, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; import { ColorScheme } from 'vs/platform/theme/common/theme'; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatQuickInputActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatQuickInputActions.ts index bd249edb1ccc4..c644cc34fbea1 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatQuickInputActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatQuickInputActions.ts @@ -16,7 +16,7 @@ import { ContextKeyExpr, IContextKeyService, IScopedContextKeyService } from 'vs import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; -import { IQuickInputService, IQuickPick, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; +import { IQuickInputService, IQuickWidget } from 'vs/platform/quickinput/common/quickInput'; import { editorBackground, editorForeground, inputBackground } from 'vs/platform/theme/common/colorRegistry'; import { CHAT_CATEGORY } from 'vs/workbench/contrib/chat/browser/actions/chatActions'; import { IChatWidgetService } from 'vs/workbench/contrib/chat/browser/chat'; @@ -78,7 +78,7 @@ class QuickChatGlobalAction extends Action2 { export function getQuickChatActionForProvider(id: string, label: string) { return class AskQuickChatAction extends Action2 { _currentTimer: any | undefined; - _input: IQuickPick | undefined; + _input: IQuickWidget | undefined; _currentChat: QuickChat | undefined; constructor() { @@ -116,10 +116,8 @@ export function getQuickChatActionForProvider(id: string, label: string) { //#region Setup quick pick - this._input = quickInputService.createQuickPick(); + this._input = quickInputService.createQuickWidget(); disposableStore.add(this._input); - this._input.hideInput = true; - const containerSession = dom.$('.interactive-session'); this._input.widget = containerSession; diff --git a/src/vs/workbench/services/quickinput/browser/quickInputService.ts b/src/vs/workbench/services/quickinput/browser/quickInputService.ts index e47d590694e18..14d68592f6096 100644 --- a/src/vs/workbench/services/quickinput/browser/quickInputService.ts +++ b/src/vs/workbench/services/quickinput/browser/quickInputService.ts @@ -9,7 +9,7 @@ import { IThemeService } from 'vs/platform/theme/common/themeService'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; -import { QuickInputController } from 'vs/platform/quickinput/browser/quickInput'; +import { QuickInputController } from 'vs/platform/quickinput/browser/quickInputController'; import { QuickInputService as BaseQuickInputService } from 'vs/platform/quickinput/browser/quickInputService'; import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index 706028e8ddfef..0fffe75c46f53 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -95,7 +95,7 @@ import { CodeEditorService } from 'vs/workbench/services/editor/browser/codeEdit import { EditorPart } from 'vs/workbench/browser/parts/editor/editorPart'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { IDiffEditor } from 'vs/editor/common/editorCommon'; -import { IInputBox, IInputOptions, IPickOptions, IQuickInputButton, IQuickInputService, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, QuickPickInput } from 'vs/platform/quickinput/common/quickInput'; +import { IInputBox, IInputOptions, IPickOptions, IQuickInputButton, IQuickInputService, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, IQuickWidget, QuickPickInput } from 'vs/platform/quickinput/common/quickInput'; import { QuickInputService } from 'vs/workbench/services/quickinput/browser/quickInputService'; import { IListService } from 'vs/platform/list/browser/listService'; import { win32, posix } from 'vs/base/common/path'; @@ -1936,6 +1936,7 @@ export class TestQuickInputService implements IQuickInputService { createQuickPick(): IQuickPick { throw new Error('not implemented.'); } createInputBox(): IInputBox { throw new Error('not implemented.'); } + createQuickWidget(): IQuickWidget { throw new Error('Method not implemented.'); } focus(): void { throw new Error('not implemented.'); } toggle(): void { throw new Error('not implemented.'); } navigate(next: boolean, quickNavigate?: IQuickNavigateConfiguration): void { throw new Error('not implemented.'); }