From 17726b2b0cb6b8c259980a76cd0a711a8d9607f0 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 20 Sep 2023 00:27:48 +0200 Subject: [PATCH] icon selection widget (#193518) * icon selection widget * fix scrolling --- .../base/browser/ui/icons/iconSelectBox.css | 30 +++ src/vs/base/browser/ui/icons/iconSelectBox.ts | 217 ++++++++++++++++++ src/vs/base/common/codicons.ts | 2 +- src/vs/workbench/browser/iconSelectBox.ts | 98 ++++++++ src/vs/workbench/workbench.common.main.ts | 1 + 5 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 src/vs/base/browser/ui/icons/iconSelectBox.css create mode 100644 src/vs/base/browser/ui/icons/iconSelectBox.ts create mode 100644 src/vs/workbench/browser/iconSelectBox.ts diff --git a/src/vs/base/browser/ui/icons/iconSelectBox.css b/src/vs/base/browser/ui/icons/iconSelectBox.css new file mode 100644 index 00000000000..2a1ed82f10e --- /dev/null +++ b/src/vs/base/browser/ui/icons/iconSelectBox.css @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.icon-select-box>.icon-select-box-container { + height: 100%; +} + +.icon-select-box .icon-select-icons-container { + display: flex; + flex-wrap: wrap; + box-sizing: border-box; + height: 100%; +} + +.icon-select-box .icon-select-icons-container > .icon-container { + display: inline-flex; + cursor: pointer; + font-size: 20px; + align-items: center; + justify-content: center; + border-radius: 5px; +} + +.icon-select-box .icon-select-icons-container > .icon-container.focused { + outline: 1px dashed var(--vscode-toolbar-hoverOutline); + outline-offset: -1px; + background-color: var(--vscode-toolbar-hoverBackground); +} diff --git a/src/vs/base/browser/ui/icons/iconSelectBox.ts b/src/vs/base/browser/ui/icons/iconSelectBox.ts new file mode 100644 index 00000000000..529da9eb154 --- /dev/null +++ b/src/vs/base/browser/ui/icons/iconSelectBox.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./iconSelectBox'; +import * as dom from 'vs/base/browser/dom'; +import { IInputBoxStyles, InputBox } from 'vs/base/browser/ui/inputbox/inputBox'; +import { DomScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; +import { Emitter } from 'vs/base/common/event'; +import { IDisposable, DisposableStore, Disposable, MutableDisposable } from 'vs/base/common/lifecycle'; +import { ThemeIcon } from 'vs/base/common/themables'; +import { localize } from 'vs/nls'; +import { IMatch } from 'vs/base/common/filters'; + +export interface IIconSelectBoxOptions { + readonly icons: ThemeIcon[]; + readonly inputBoxStyles: IInputBoxStyles; +} + +export class IconSelectBox extends Disposable { + + readonly domNode: HTMLElement; + + private _onDidSelect = this._register(new Emitter()); + readonly onDidSelect = this._onDidSelect.event; + + private renderedIcons: [ThemeIcon, HTMLElement][] = []; + + private focusedItemIndex: number = 0; + private numberOfElementsPerRow: number = 1; + + private inputBox: InputBox | undefined; + private scrollableElement: DomScrollableElement | undefined; + private readonly iconContainerWidth = 36; + private readonly iconContainerHeight = 32; + + constructor( + private readonly options: IIconSelectBoxOptions, + ) { + super(); + this.domNode = dom.$('.icon-select-box'); + this._register(this.create()); + } + + private create(): IDisposable { + const disposables = new DisposableStore(); + + const iconSelectBoxContainer = dom.append(this.domNode, dom.$('.icon-select-box-container')); + iconSelectBoxContainer.style.margin = '10px 15px'; + + const iconSelectInputContainer = dom.append(iconSelectBoxContainer, dom.$('.icon-select-input-container')); + iconSelectInputContainer.style.paddingBottom = '10px'; + this.inputBox = disposables.add(new InputBox(iconSelectInputContainer, undefined, { + placeholder: localize('iconSelect.placeholder', "Search icons"), + inputBoxStyles: this.options.inputBoxStyles, + })); + + const iconsContainer = dom.$('.icon-select-icons-container'); + iconsContainer.style.paddingRight = '10px'; + this.scrollableElement = disposables.add(new DomScrollableElement(iconsContainer, { useShadows: false })); + dom.append(iconSelectBoxContainer, this.scrollableElement.getDomNode()); + + const iconsDisposables = disposables.add(new MutableDisposable()); + iconsDisposables.value = this.renderIcons(this.options.icons, iconsContainer); + this.scrollableElement.scanDomNode(); + + disposables.add(this.inputBox.onDidChange(value => { + const icons = this.options.icons.filter(icon => { + return this.matchesContiguous(value, icon.id); + }); + iconsDisposables.value = this.renderIcons(icons, iconsContainer); + this.scrollableElement?.scanDomNode(); + })); + + return disposables; + } + + private renderIcons(icons: ThemeIcon[], container: HTMLElement): IDisposable { + const disposables = new DisposableStore(); + dom.clearNode(container); + const focusedIcon = this.renderedIcons[this.focusedItemIndex]?.[0]; + let focusedIconIndex = 0; + const renderedIcons: [ThemeIcon, HTMLElement][] = []; + for (let index = 0; index < icons.length; index++) { + const icon = icons[index]; + const iconContainer = dom.append(container, dom.$('.icon-container')); + iconContainer.style.width = `${this.iconContainerWidth}px`; + iconContainer.style.height = `${this.iconContainerHeight}px`; + iconContainer.tabIndex = -1; + iconContainer.role = 'button'; + iconContainer.title = icon.id; + dom.append(iconContainer, dom.$(ThemeIcon.asCSSSelector(icon))); + renderedIcons.push([icon, iconContainer]); + + disposables.add(dom.addDisposableListener(iconContainer, dom.EventType.CLICK, (e: MouseEvent) => { + e.stopPropagation(); + this.setSelection(index); + })); + + disposables.add(dom.addDisposableListener(iconContainer, dom.EventType.MOUSE_OVER, (e: MouseEvent) => { + this.focusIcon(index); + })); + + if (icon === focusedIcon) { + focusedIconIndex = index; + } + } + + this.renderedIcons.splice(0, this.renderedIcons.length, ...renderedIcons); + this.focusIcon(focusedIconIndex); + + return disposables; + } + + private focusIcon(index: number): void { + const existing = this.renderedIcons[this.focusedItemIndex]; + if (existing) { + existing[1].classList.remove('focused'); + } + + this.focusedItemIndex = index; + const icon = this.renderedIcons[index]?.[1]; + if (icon) { + icon.classList.add('focused'); + } + + this.reveal(index); + } + + private reveal(index: number): void { + if (!this.scrollableElement) { + return; + } + if (index < 0 || index >= this.renderedIcons.length) { + return; + } + const icon = this.renderedIcons[index][1]; + if (!icon) { + return; + } + const { height } = this.scrollableElement.getScrollDimensions(); + const { scrollTop } = this.scrollableElement.getScrollPosition(); + if (icon.offsetTop + this.iconContainerHeight > scrollTop + height) { + this.scrollableElement.setScrollPosition({ scrollTop: icon.offsetTop + this.iconContainerHeight - height }); + } else if (icon.offsetTop < scrollTop) { + this.scrollableElement.setScrollPosition({ scrollTop: icon.offsetTop }); + } + } + + private matchesContiguous(word: string, wordToMatchAgainst: string): IMatch[] | null { + const matchIndex = wordToMatchAgainst.toLowerCase().indexOf(word.toLowerCase()); + if (matchIndex !== -1) { + return [{ start: matchIndex, end: matchIndex + word.length }]; + } + return null; + } + + layout(dimension: dom.Dimension): void { + this.domNode.style.width = `${dimension.width}px`; + this.domNode.style.height = `${dimension.height}px`; + if (this.scrollableElement) { + this.scrollableElement.getDomNode().style.height = `${dimension.height - 46}px`; + this.scrollableElement.scanDomNode(); + } + + const iconsContainerWidth = dimension.width - 40; + this.numberOfElementsPerRow = Math.floor(iconsContainerWidth / this.iconContainerWidth); + if (this.numberOfElementsPerRow === 0) { + throw new Error('Insufficient width'); + } + + const extraSpace = iconsContainerWidth % this.iconContainerWidth; + const margin = Math.floor(extraSpace / this.numberOfElementsPerRow); + for (const [, icon] of this.renderedIcons) { + icon.style.marginRight = `${margin}px`; + } + } + + getFocus(): number[] { + return [this.focusedItemIndex]; + } + + setSelection(index: number): void { + if (index < 0 || index >= this.renderedIcons.length) { + throw new Error(`Invalid index ${index}`); + } + this.focusIcon(index); + this._onDidSelect.fire(this.renderedIcons[index][0]); + } + + focus(): void { + this.inputBox?.focus(); + this.focusIcon(0); + } + + focusNext(): void { + this.focusIcon((this.focusedItemIndex + 1) % this.renderedIcons.length); + } + + focusPrevious(): void { + this.focusIcon((this.focusedItemIndex - 1 + this.renderedIcons.length) % this.renderedIcons.length); + } + + focusNextRow(): void { + this.focusIcon((this.focusedItemIndex + this.numberOfElementsPerRow) % this.renderedIcons.length); + } + + focusPreviousRow(): void { + this.focusIcon((this.focusedItemIndex - this.numberOfElementsPerRow + this.renderedIcons.length) % this.renderedIcons.length); + } + + getFocusedIcon(): ThemeIcon { + return this.renderedIcons[this.focusedItemIndex][0]; + } + +} diff --git a/src/vs/base/common/codicons.ts b/src/vs/base/common/codicons.ts index 0c5fd5a01e8..f45ae37d78f 100644 --- a/src/vs/base/common/codicons.ts +++ b/src/vs/base/common/codicons.ts @@ -324,7 +324,7 @@ export const Codicon = { note: register('note', 0xeb26), octoface: register('octoface', 0xeb27), openPreview: register('open-preview', 0xeb28), - package_: register('package', 0xeb29), + package: register('package', 0xeb29), paintcan: register('paintcan', 0xeb2a), pin: register('pin', 0xeb2b), play: register('play', 0xeb2c), diff --git a/src/vs/workbench/browser/iconSelectBox.ts b/src/vs/workbench/browser/iconSelectBox.ts new file mode 100644 index 00000000000..0e38a01d30f --- /dev/null +++ b/src/vs/workbench/browser/iconSelectBox.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IIconSelectBoxOptions, IconSelectBox } from 'vs/base/browser/ui/icons/iconSelectBox'; +import { KeyCode } from 'vs/base/common/keyCodes'; +import { IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { KeybindingsRegistry, KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; + +export const WorkbenchIconSelectBoxFocusContextKey = new RawContextKey('iconSelectBoxFocus', true); + +export class WorkbenchIconSelectBox extends IconSelectBox { + + private static focusedWidget: WorkbenchIconSelectBox | undefined; + static getFocusedWidget(): WorkbenchIconSelectBox | undefined { + return WorkbenchIconSelectBox.focusedWidget; + } + + constructor( + options: IIconSelectBoxOptions, + @IContextKeyService contextKeyService: IContextKeyService + ) { + super(options); + WorkbenchIconSelectBoxFocusContextKey.bindTo(this._register(contextKeyService.createScoped(this.domNode))); + } + + override focus(): void { + super.focus(); + WorkbenchIconSelectBox.focusedWidget = this; + } + +} + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'iconSelectBox.focusUp', + weight: KeybindingWeight.WorkbenchContrib, + when: WorkbenchIconSelectBoxFocusContextKey, + primary: KeyCode.UpArrow, + handler: (accessor, arg2) => { + const selectBox = WorkbenchIconSelectBox.getFocusedWidget(); + if (selectBox) { + selectBox.focusPreviousRow(); + } + } +}); + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'iconSelectBox.focusDown', + weight: KeybindingWeight.WorkbenchContrib, + when: WorkbenchIconSelectBoxFocusContextKey, + primary: KeyCode.DownArrow, + handler: (accessor, arg2) => { + const selectBox = WorkbenchIconSelectBox.getFocusedWidget(); + if (selectBox) { + selectBox.focusNextRow(); + } + } +}); + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'iconSelectBox.focusNext', + weight: KeybindingWeight.WorkbenchContrib, + when: WorkbenchIconSelectBoxFocusContextKey, + primary: KeyCode.RightArrow, + handler: (accessor, arg2) => { + const selectBox = WorkbenchIconSelectBox.getFocusedWidget(); + if (selectBox) { + selectBox.focusNext(); + } + } +}); + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'iconSelectBox.focusPrevious', + weight: KeybindingWeight.WorkbenchContrib, + when: WorkbenchIconSelectBoxFocusContextKey, + primary: KeyCode.LeftArrow, + handler: (accessor, arg2) => { + const selectBox = WorkbenchIconSelectBox.getFocusedWidget(); + if (selectBox) { + selectBox.focusPrevious(); + } + } +}); + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'iconSelectBox.selectFocused', + weight: KeybindingWeight.WorkbenchContrib, + when: WorkbenchIconSelectBoxFocusContextKey, + primary: KeyCode.Enter, + handler: (accessor, arg2) => { + const selectBox = WorkbenchIconSelectBox.getFocusedWidget(); + if (selectBox) { + selectBox.setSelection(selectBox.getFocus()[0]); + } + } +}); diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 1f6253a29b6..b4ad16270af 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -47,6 +47,7 @@ import 'vs/workbench/browser/parts/paneCompositePart'; import 'vs/workbench/browser/parts/banner/bannerPart'; import 'vs/workbench/browser/parts/statusbar/statusbarPart'; import 'vs/workbench/browser/parts/views/viewsService'; +import 'vs/workbench/browser/iconSelectBox'; //#endregion