Skip to content

Commit 3fbbb5b

Browse files
authored
Clean up actionWidget (microsoft#166408)
- Reduce number of exports - Adding private and removing unnused params - Split out `actionList.ts` to own file
1 parent 5452c6a commit 3fbbb5b

File tree

5 files changed

+338
-306
lines changed

5 files changed

+338
-306
lines changed

src/vs/editor/contrib/codeAction/browser/codeActionMenuItems.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55

66
import 'vs/base/browser/ui/codicons/codiconStyles'; // The codicon symbol styles are defined here and must be loaded
77
import { Codicon } from 'vs/base/common/codicons';
8-
import { ActionListItemKind, IListMenuItem } from 'vs/platform/actionWidget/browser/actionWidget';
98
import { CodeActionItem, CodeActionKind } from 'vs/editor/contrib/codeAction/common/types';
109
import 'vs/editor/contrib/symbolIcons/browser/symbolIcons'; // The codicon symbol colors are defined here and must be loaded to get colors
1110
import { localize } from 'vs/nls';
11+
import { ActionListItemKind, IListMenuItem } from 'vs/platform/actionWidget/browser/actionList';
1212

1313
export interface ActionGroup {
1414
readonly kind: CodeActionKind;
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
import * as dom from 'vs/base/browser/dom';
6+
import { KeybindingLabel } from 'vs/base/browser/ui/keybindingLabel/keybindingLabel';
7+
import { IListEvent, IListMouseEvent, IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list';
8+
import { List } from 'vs/base/browser/ui/list/listWidget';
9+
import { Codicon } from 'vs/base/common/codicons';
10+
import { Disposable } from 'vs/base/common/lifecycle';
11+
import { OS } from 'vs/base/common/platform';
12+
import 'vs/css!./actionWidget';
13+
import { localize } from 'vs/nls';
14+
import { IActionItem, IActionKeybindingResolver } from 'vs/platform/actionWidget/common/actionWidget';
15+
import { IContextViewService } from 'vs/platform/contextview/browser/contextView';
16+
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
17+
18+
export const acceptSelectedActionCommand = 'acceptSelectedCodeAction';
19+
export const previewSelectedActionCommand = 'previewSelectedCodeAction';
20+
21+
export interface IRenderDelegate {
22+
onHide(didCancel?: boolean): void;
23+
onSelect(action: IActionItem, preview?: boolean): Promise<any>;
24+
}
25+
26+
export interface IListMenuItem<T extends IActionItem> {
27+
item?: T;
28+
kind: ActionListItemKind;
29+
group?: { kind?: any; icon?: { codicon: Codicon; color?: string }; title: string };
30+
disabled?: boolean;
31+
label?: string;
32+
}
33+
34+
interface IActionMenuTemplateData {
35+
readonly container: HTMLElement;
36+
readonly icon: HTMLElement;
37+
readonly text: HTMLElement;
38+
readonly keybinding: KeybindingLabel;
39+
}
40+
41+
export const enum ActionListItemKind {
42+
Action = 'action',
43+
Header = 'header'
44+
}
45+
46+
interface IHeaderTemplateData {
47+
readonly container: HTMLElement;
48+
readonly text: HTMLElement;
49+
}
50+
51+
class HeaderRenderer<T extends IListMenuItem<IActionItem>> implements IListRenderer<T, IHeaderTemplateData> {
52+
53+
get templateId(): string { return ActionListItemKind.Header; }
54+
55+
renderTemplate(container: HTMLElement): IHeaderTemplateData {
56+
container.classList.add('group-header');
57+
58+
const text = document.createElement('span');
59+
container.append(text);
60+
61+
return { container, text };
62+
}
63+
64+
renderElement(element: IListMenuItem<IActionItem>, _index: number, templateData: IHeaderTemplateData): void {
65+
if (!element.group) {
66+
return;
67+
}
68+
templateData.text.textContent = element.group?.title;
69+
}
70+
71+
disposeTemplate(_templateData: IHeaderTemplateData): void {
72+
// noop
73+
}
74+
}
75+
76+
class ActionItemRenderer<T extends IListMenuItem<IActionItem>> implements IListRenderer<T, IActionMenuTemplateData> {
77+
78+
get templateId(): string { return 'action'; }
79+
80+
constructor(
81+
private readonly _keybindingResolver: IActionKeybindingResolver | undefined,
82+
@IKeybindingService private readonly _keybindingService: IKeybindingService
83+
) { }
84+
85+
renderTemplate(container: HTMLElement): IActionMenuTemplateData {
86+
container.classList.add(this.templateId);
87+
88+
const icon = document.createElement('div');
89+
icon.className = 'icon';
90+
container.append(icon);
91+
92+
const text = document.createElement('span');
93+
text.className = 'title';
94+
container.append(text);
95+
96+
const keybinding = new KeybindingLabel(container, OS);
97+
98+
return { container, icon, text, keybinding };
99+
}
100+
101+
renderElement(element: T, _index: number, data: IActionMenuTemplateData): void {
102+
if (element.group?.icon) {
103+
data.icon.className = element.group.icon.codicon.classNames;
104+
data.icon.style.color = element.group.icon.color ?? '';
105+
} else {
106+
data.icon.className = Codicon.lightBulb.classNames;
107+
data.icon.style.color = 'var(--vscode-editorLightBulb-foreground)';
108+
}
109+
if (!element.item || !element.label) {
110+
return;
111+
}
112+
data.text.textContent = stripNewlines(element.label);
113+
const binding = this._keybindingResolver?.getResolver()(element.item);
114+
if (binding) {
115+
data.keybinding.set(binding);
116+
}
117+
118+
if (!binding) {
119+
dom.hide(data.keybinding.element);
120+
} else {
121+
dom.show(data.keybinding.element);
122+
}
123+
124+
const actionTitle = this._keybindingService.lookupKeybinding(acceptSelectedActionCommand)?.getLabel();
125+
const previewTitle = this._keybindingService.lookupKeybinding(previewSelectedActionCommand)?.getLabel();
126+
data.container.classList.toggle('option-disabled', element.disabled);
127+
if (element.disabled) {
128+
data.container.title = element.label;
129+
} else if (actionTitle && previewTitle) {
130+
data.container.title = localize({ key: 'label', comment: ['placeholders are keybindings, e.g "F2 to Apply, Shift+F2 to Preview"'] }, "{0} to Apply, {1} to Preview", actionTitle, previewTitle);
131+
} else {
132+
data.container.title = '';
133+
}
134+
}
135+
136+
disposeTemplate(_templateData: IActionMenuTemplateData): void {
137+
// noop
138+
}
139+
}
140+
141+
export class ActionList<T extends IActionItem> extends Disposable {
142+
143+
readonly domNode: HTMLElement;
144+
private readonly _list: List<IListMenuItem<IActionItem>>;
145+
146+
private readonly _actionLineHeight = 24;
147+
private readonly _headerLineHeight = 26;
148+
149+
private readonly _allMenuItems: IListMenuItem<IActionItem>[];
150+
151+
private focusCondition(element: IListMenuItem<IActionItem>): boolean {
152+
return !element.disabled && element.kind === ActionListItemKind.Action;
153+
}
154+
155+
constructor(
156+
user: string,
157+
items: readonly T[],
158+
showHeaders: boolean,
159+
private readonly _delegate: IRenderDelegate,
160+
resolver: IActionKeybindingResolver | undefined,
161+
toMenuItems: (inputActions: readonly T[], showHeaders: boolean) => IListMenuItem<T>[],
162+
@IContextViewService private readonly _contextViewService: IContextViewService,
163+
@IKeybindingService private readonly _keybindingService: IKeybindingService
164+
) {
165+
super();
166+
167+
this.domNode = document.createElement('div');
168+
this.domNode.classList.add('actionList');
169+
const virtualDelegate: IListVirtualDelegate<IListMenuItem<IActionItem>> = {
170+
getHeight: element => element.kind === 'header' ? this._headerLineHeight : this._actionLineHeight,
171+
getTemplateId: element => element.kind
172+
};
173+
this._list = new List(user, this.domNode, virtualDelegate, [new ActionItemRenderer<IListMenuItem<IActionItem>>(resolver, this._keybindingService), new HeaderRenderer()], {
174+
keyboardSupport: true,
175+
accessibilityProvider: {
176+
getAriaLabel: element => {
177+
if (element.kind === 'action') {
178+
let label = element.label ? stripNewlines(element?.label) : '';
179+
if (element.disabled) {
180+
label = localize({ key: 'customQuickFixWidget.labels', comment: [`Action widget labels for accessibility.`] }, "{0}, Disabled Reason: {1}", label, element.disabled);
181+
}
182+
return label;
183+
}
184+
return null;
185+
},
186+
getWidgetAriaLabel: () => localize({ key: 'customQuickFixWidget', comment: [`An action widget option`] }, "Action Widget"),
187+
getRole: () => 'option',
188+
getWidgetRole: () => user
189+
},
190+
});
191+
192+
this._register(this._list.onMouseClick(e => this.onListClick(e)));
193+
this._register(this._list.onMouseOver(e => this.onListHover(e)));
194+
this._register(this._list.onDidChangeFocus(() => this._list.domFocus()));
195+
this._register(this._list.onDidChangeSelection(e => this.onListSelection(e)));
196+
197+
this._allMenuItems = toMenuItems(items, showHeaders);
198+
this._list.splice(0, this._list.length, this._allMenuItems);
199+
this.focusNext();
200+
}
201+
202+
hide(didCancel?: boolean): void {
203+
this._delegate.onHide(didCancel);
204+
this._contextViewService.hideContextView();
205+
}
206+
207+
layout(minWidth: number): number {
208+
// Updating list height, depending on how many separators and headers there are.
209+
const numHeaders = this._allMenuItems.filter(item => item.kind === 'header').length;
210+
const height = this._allMenuItems.length * this._actionLineHeight;
211+
const heightWithHeaders = height + numHeaders * this._headerLineHeight - numHeaders * this._actionLineHeight;
212+
this._list.layout(heightWithHeaders);
213+
214+
// For finding width dynamically (not using resize observer)
215+
const itemWidths: number[] = this._allMenuItems.map((_, index): number => {
216+
const element = document.getElementById(this._list.getElementID(index));
217+
if (element) {
218+
element.style.width = 'auto';
219+
const width = element.getBoundingClientRect().width;
220+
element.style.width = '';
221+
return width;
222+
}
223+
return 0;
224+
});
225+
226+
// resize observer - can be used in the future since list widget supports dynamic height but not width
227+
const width = Math.max(...itemWidths, minWidth);
228+
this._list.layout(heightWithHeaders, width);
229+
230+
this.domNode.style.height = `${heightWithHeaders}px`;
231+
232+
this._list.domFocus();
233+
return width;
234+
}
235+
236+
focusPrevious() {
237+
this._list.focusPrevious(1, true, undefined, this.focusCondition);
238+
}
239+
240+
focusNext() {
241+
this._list.focusNext(1, true, undefined, this.focusCondition);
242+
}
243+
244+
acceptSelected(preview?: boolean) {
245+
const focused = this._list.getFocus();
246+
if (focused.length === 0) {
247+
return;
248+
}
249+
250+
const focusIndex = focused[0];
251+
const element = this._list.element(focusIndex);
252+
if (!this.focusCondition(element)) {
253+
return;
254+
}
255+
256+
const event = new UIEvent(preview ? 'previewSelectedCodeAction' : 'acceptSelectedCodeAction');
257+
this._list.setSelection([focusIndex], event);
258+
}
259+
260+
private onListSelection(e: IListEvent<IListMenuItem<IActionItem>>): void {
261+
if (!e.elements.length) {
262+
return;
263+
}
264+
265+
const element = e.elements[0];
266+
if (element.item && this.focusCondition(element)) {
267+
this._delegate.onSelect(element.item, e.browserEvent?.type === 'previewSelectedEventType');
268+
} else {
269+
this._list.setSelection([]);
270+
}
271+
}
272+
273+
private onListHover(e: IListMouseEvent<IListMenuItem<IActionItem>>): void {
274+
this._list.setFocus(typeof e.index === 'number' ? [e.index] : []);
275+
}
276+
277+
private onListClick(e: IListMouseEvent<IListMenuItem<IActionItem>>): void {
278+
if (e.element && this.focusCondition(e.element)) {
279+
this._list.setFocus([]);
280+
}
281+
}
282+
}
283+
284+
function stripNewlines(str: string): string {
285+
return str.replace(/\r\n|\r|\n/g, ' ');
286+
}

0 commit comments

Comments
 (0)