Skip to content

Commit fc18e59

Browse files
authored
Allow to dictate by voice into the text editor (fix microsoft#205263) (microsoft#205264)
1 parent 95a3805 commit fc18e59

File tree

5 files changed

+321
-0
lines changed

5 files changed

+321
-0
lines changed

build/lib/stylelint/vscode-known-variables.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,8 @@
796796
"--vscode-inline-chat-expanded",
797797
"--vscode-inline-chat-quick-voice-height",
798798
"--vscode-inline-chat-quick-voice-width",
799+
"--vscode-editor-dictation-widget-height",
800+
"--vscode-editor-dictation-widget-width",
799801
"--vscode-interactive-session-foreground",
800802
"--vscode-interactive-result-editor-background-color",
801803
"--vscode-repl-font-family",

src/vs/workbench/contrib/codeEditor/browser/codeEditor.contribution.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ import './toggleWordWrap';
2323
import './emptyTextEditorHint/emptyTextEditorHint';
2424
import './workbenchReferenceSearch';
2525
import './editorLineNumberMenu';
26+
import './dictation/editorDictation';
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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+
6+
.monaco-editor .editor-dictation-widget {
7+
background-color: var(--vscode-editor-background);
8+
padding: 2px;
9+
border-radius: 8px;
10+
display: flex;
11+
align-items: center;
12+
box-shadow: 0 4px 8px var(--vscode-widget-shadow);
13+
z-index: 1000;
14+
min-height: var(--vscode-editor-dictation-widget-height);
15+
line-height: var(--vscode-editor-dictation-widget-height);
16+
max-width: var(--vscode-editor-dictation-widget-width);
17+
}
18+
19+
.monaco-editor .editor-dictation-widget .codicon.codicon-mic-filled {
20+
display: flex;
21+
align-items: center;
22+
width: 16px;
23+
height: 16px;
24+
}
25+
26+
.monaco-editor .editor-dictation-widget.recording .codicon.codicon-mic-filled {
27+
color: var(--vscode-activityBarBadge-background);
28+
animation: editor-dictation-animation 1s infinite;
29+
}
30+
31+
@keyframes editor-dictation-animation {
32+
0% {
33+
color: var(--vscode-editorCursor-background);
34+
}
35+
36+
50% {
37+
color: var(--vscode-activityBarBadge-background);
38+
}
39+
40+
100% {
41+
color: var(--vscode-editorCursor-background);
42+
}
43+
}
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
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+
6+
import 'vs/css!./editorDictation';
7+
import { localize2 } from 'vs/nls';
8+
import { IDimension, h, reset } from 'vs/base/browser/dom';
9+
import { CancellationTokenSource } from 'vs/base/common/cancellation';
10+
import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
11+
import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from 'vs/editor/browser/editorBrowser';
12+
import { IEditorContribution } from 'vs/editor/common/editorCommon';
13+
import { ContextKeyExpr, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
14+
import { HasSpeechProvider, ISpeechService, SpeechToTextStatus } from 'vs/workbench/contrib/speech/common/speechService';
15+
import { renderIcon } from 'vs/base/browser/ui/iconLabel/iconLabels';
16+
import { Codicon } from 'vs/base/common/codicons';
17+
import { EditorOption } from 'vs/editor/common/config/editorOptions';
18+
import { EditorAction2, EditorContributionInstantiation, registerEditorContribution } from 'vs/editor/browser/editorExtensions';
19+
import { EditorContextKeys } from 'vs/editor/common/editorContextKeys';
20+
import { KeyCode } from 'vs/base/common/keyCodes';
21+
import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
22+
import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
23+
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
24+
import { EditOperation } from 'vs/editor/common/core/editOperation';
25+
import { Selection } from 'vs/editor/common/core/selection';
26+
import { Position } from 'vs/editor/common/core/position';
27+
import { Range } from 'vs/editor/common/core/range';
28+
import { registerAction2 } from 'vs/platform/actions/common/actions';
29+
import { assertIsDefined } from 'vs/base/common/types';
30+
31+
const EDITOR_DICTATION_IN_PROGRESS = new RawContextKey<boolean>('editorDictation.inProgress', false);
32+
const VOICE_CATEGORY = localize2('voiceCategory', "Voice");
33+
34+
export class EditorDictationStartAction extends EditorAction2 {
35+
36+
constructor() {
37+
super({
38+
id: 'workbench.action.editorDictation.start',
39+
title: localize2('startDictation', "Start Dictation in Editor"),
40+
category: VOICE_CATEGORY,
41+
precondition: ContextKeyExpr.and(HasSpeechProvider, EDITOR_DICTATION_IN_PROGRESS.toNegated(), EditorContextKeys.readOnly.toNegated()),
42+
f1: true
43+
});
44+
}
45+
46+
override runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor): void {
47+
const keybindingService = accessor.get(IKeybindingService);
48+
49+
const holdMode = keybindingService.enableKeybindingHoldMode(this.desc.id);
50+
if (holdMode) {
51+
let shouldCallStop = false;
52+
53+
const handle = setTimeout(() => {
54+
shouldCallStop = true;
55+
}, 500);
56+
57+
holdMode.finally(() => {
58+
clearTimeout(handle);
59+
60+
if (shouldCallStop) {
61+
EditorDictation.get(editor)?.stop();
62+
}
63+
});
64+
}
65+
66+
EditorDictation.get(editor)?.start();
67+
}
68+
}
69+
70+
export class EditorDictationStopAction extends EditorAction2 {
71+
72+
constructor() {
73+
super({
74+
id: 'workbench.action.editorDictation.stop',
75+
title: localize2('stopDictation', "Stop Dictation in Editor"),
76+
category: VOICE_CATEGORY,
77+
precondition: EDITOR_DICTATION_IN_PROGRESS,
78+
f1: true,
79+
keybinding: {
80+
primary: KeyCode.Escape,
81+
weight: KeybindingWeight.WorkbenchContrib + 100
82+
}
83+
});
84+
}
85+
86+
override runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor): void {
87+
EditorDictation.get(editor)?.stop();
88+
}
89+
}
90+
91+
export class DictationWidget extends Disposable implements IContentWidget {
92+
93+
readonly suppressMouseDown = true;
94+
readonly allowEditorOverflow = true;
95+
96+
private readonly domNode = document.createElement('div');
97+
private readonly elements = h('.editor-dictation-widget@main', [h('span@mic')]);
98+
99+
constructor(private readonly editor: ICodeEditor) {
100+
super();
101+
102+
this.domNode.appendChild(this.elements.root);
103+
this.domNode.style.zIndex = '1000';
104+
105+
reset(this.elements.mic, renderIcon(Codicon.micFilled));
106+
}
107+
108+
getId(): string {
109+
return 'editorDictation';
110+
}
111+
112+
getDomNode(): HTMLElement {
113+
return this.domNode;
114+
}
115+
116+
getPosition(): IContentWidgetPosition | null {
117+
if (!this.editor.hasModel()) {
118+
return null;
119+
}
120+
121+
const selection = this.editor.getSelection();
122+
123+
return {
124+
position: selection.getPosition(),
125+
preference: [
126+
selection.getPosition().equals(selection.getStartPosition()) ? ContentWidgetPositionPreference.ABOVE : ContentWidgetPositionPreference.BELOW,
127+
ContentWidgetPositionPreference.EXACT
128+
]
129+
};
130+
}
131+
132+
beforeRender(): IDimension | null {
133+
const lineHeight = this.editor.getOption(EditorOption.lineHeight);
134+
const width = this.editor.getLayoutInfo().contentWidth * 0.7;
135+
136+
this.elements.main.style.setProperty('--vscode-editor-dictation-widget-height', `${lineHeight}px`);
137+
this.elements.main.style.setProperty('--vscode-editor-dictation-widget-width', `${width}px`);
138+
139+
return null;
140+
}
141+
142+
show() {
143+
this.editor.addContentWidget(this);
144+
}
145+
146+
layout(): void {
147+
this.editor.layoutContentWidget(this);
148+
}
149+
150+
active(): void {
151+
this.elements.main.classList.add('recording');
152+
}
153+
154+
hide() {
155+
this.elements.main.classList.remove('recording');
156+
this.editor.removeContentWidget(this);
157+
}
158+
}
159+
160+
export class EditorDictation extends Disposable implements IEditorContribution {
161+
162+
static readonly ID = 'editorDictation';
163+
164+
static get(editor: ICodeEditor): EditorDictation | null {
165+
return editor.getContribution<EditorDictation>(EditorDictation.ID);
166+
}
167+
168+
private readonly widget = this._register(new DictationWidget(this.editor));
169+
private readonly editorDictationInProgress = EDITOR_DICTATION_IN_PROGRESS.bindTo(this.contextKeyService);
170+
171+
private sessionDisposables = this._register(new MutableDisposable());
172+
173+
constructor(
174+
private readonly editor: ICodeEditor,
175+
@ISpeechService private readonly speechService: ISpeechService,
176+
@IContextKeyService private readonly contextKeyService: IContextKeyService
177+
) {
178+
super();
179+
}
180+
181+
start() {
182+
const disposables = new DisposableStore();
183+
this.sessionDisposables.value = disposables;
184+
185+
this.widget.show();
186+
disposables.add(toDisposable(() => this.widget.hide()));
187+
188+
this.editorDictationInProgress.set(true);
189+
disposables.add(toDisposable(() => this.editorDictationInProgress.reset()));
190+
191+
const collection = this.editor.createDecorationsCollection();
192+
disposables.add(toDisposable(() => collection.clear()));
193+
194+
let previewStart: Position | undefined = undefined;
195+
196+
let lastReplaceTextLength = 0;
197+
const replaceText = (text: string, isPreview: boolean) => {
198+
if (!previewStart) {
199+
previewStart = assertIsDefined(this.editor.getPosition());
200+
}
201+
202+
this.editor.executeEdits(EditorDictation.ID, [
203+
EditOperation.replace(Range.fromPositions(previewStart, previewStart.with(undefined, previewStart.column + lastReplaceTextLength)), text)
204+
], [
205+
Selection.fromPositions(new Position(previewStart.lineNumber, previewStart.column + text.length))
206+
]);
207+
208+
if (isPreview) {
209+
collection.set([
210+
{
211+
range: Range.fromPositions(previewStart, previewStart.with(undefined, previewStart.column + text.length)),
212+
options: {
213+
description: 'editor-dictation-preview',
214+
inlineClassName: 'ghost-text-decoration-preview'
215+
}
216+
}
217+
]);
218+
} else {
219+
collection.clear();
220+
}
221+
222+
lastReplaceTextLength = text.length;
223+
if (!isPreview) {
224+
previewStart = undefined;
225+
lastReplaceTextLength = 0;
226+
}
227+
228+
this.widget.layout();
229+
};
230+
231+
const cts = new CancellationTokenSource();
232+
disposables.add(toDisposable(() => cts.dispose(true)));
233+
234+
const session = disposables.add(this.speechService.createSpeechToTextSession(cts.token));
235+
disposables.add(session.onDidChange(e => {
236+
if (cts.token.isCancellationRequested) {
237+
return;
238+
}
239+
240+
switch (e.status) {
241+
case SpeechToTextStatus.Started:
242+
this.widget.active();
243+
break;
244+
case SpeechToTextStatus.Stopped:
245+
disposables.dispose();
246+
break;
247+
case SpeechToTextStatus.Recognizing: {
248+
if (!e.text) {
249+
return;
250+
}
251+
252+
replaceText(e.text, true);
253+
break;
254+
}
255+
case SpeechToTextStatus.Recognized: {
256+
if (!e.text) {
257+
return;
258+
}
259+
260+
replaceText(`${e.text} `, false);
261+
break;
262+
}
263+
}
264+
}));
265+
}
266+
267+
stop(): void {
268+
this.sessionDisposables.clear();
269+
}
270+
}
271+
272+
registerEditorContribution(EditorDictation.ID, EditorDictation, EditorContributionInstantiation.Lazy);
273+
registerAction2(EditorDictationStartAction);
274+
registerAction2(EditorDictationStopAction);

src/vs/workbench/contrib/inlineChat/electron-sandbox/inlineChatQuickVoice.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ export class InlineChatQuickVoice implements IEditorContribution {
267267

268268
const done = (abort: boolean) => {
269269
cts.dispose(true);
270+
session.dispose();
270271
listener.dispose();
271272
this._widget.hide();
272273
this._ctxQuickChatInProgress.reset();

0 commit comments

Comments
 (0)