icon selection widget (#193518)

* icon selection widget

* fix scrolling
This commit is contained in:
Sandeep Somavarapu
2023-09-20 00:27:48 +02:00
committed by GitHub
parent a18ef5588e
commit 17726b2b0c
5 changed files with 347 additions and 1 deletions

View File

@@ -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);
}

View File

@@ -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<ThemeIcon>());
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];
}
}

View File

@@ -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),

View File

@@ -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<boolean>('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]);
}
}
});

View File

@@ -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