Allow to dictate by voice into the text editor (fix #205263) (#205264)

This commit is contained in:
Benjamin Pasero 2024-02-15 13:05:23 +01:00 committed by GitHub
parent 95a3805aa7
commit fc18e59421
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 321 additions and 0 deletions

View file

@ -796,6 +796,8 @@
"--vscode-inline-chat-expanded",
"--vscode-inline-chat-quick-voice-height",
"--vscode-inline-chat-quick-voice-width",
"--vscode-editor-dictation-widget-height",
"--vscode-editor-dictation-widget-width",
"--vscode-interactive-session-foreground",
"--vscode-interactive-result-editor-background-color",
"--vscode-repl-font-family",

View file

@ -23,3 +23,4 @@ import './toggleWordWrap';
import './emptyTextEditorHint/emptyTextEditorHint';
import './workbenchReferenceSearch';
import './editorLineNumberMenu';
import './dictation/editorDictation';

View file

@ -0,0 +1,43 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
.monaco-editor .editor-dictation-widget {
background-color: var(--vscode-editor-background);
padding: 2px;
border-radius: 8px;
display: flex;
align-items: center;
box-shadow: 0 4px 8px var(--vscode-widget-shadow);
z-index: 1000;
min-height: var(--vscode-editor-dictation-widget-height);
line-height: var(--vscode-editor-dictation-widget-height);
max-width: var(--vscode-editor-dictation-widget-width);
}
.monaco-editor .editor-dictation-widget .codicon.codicon-mic-filled {
display: flex;
align-items: center;
width: 16px;
height: 16px;
}
.monaco-editor .editor-dictation-widget.recording .codicon.codicon-mic-filled {
color: var(--vscode-activityBarBadge-background);
animation: editor-dictation-animation 1s infinite;
}
@keyframes editor-dictation-animation {
0% {
color: var(--vscode-editorCursor-background);
}
50% {
color: var(--vscode-activityBarBadge-background);
}
100% {
color: var(--vscode-editorCursor-background);
}
}

View file

@ -0,0 +1,274 @@
/*---------------------------------------------------------------------------------------------
* 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!./editorDictation';
import { localize2 } from 'vs/nls';
import { IDimension, h, reset } from 'vs/base/browser/dom';
import { CancellationTokenSource } from 'vs/base/common/cancellation';
import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from 'vs/editor/browser/editorBrowser';
import { IEditorContribution } from 'vs/editor/common/editorCommon';
import { ContextKeyExpr, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
import { HasSpeechProvider, ISpeechService, SpeechToTextStatus } from 'vs/workbench/contrib/speech/common/speechService';
import { renderIcon } from 'vs/base/browser/ui/iconLabel/iconLabels';
import { Codicon } from 'vs/base/common/codicons';
import { EditorOption } from 'vs/editor/common/config/editorOptions';
import { EditorAction2, EditorContributionInstantiation, registerEditorContribution } from 'vs/editor/browser/editorExtensions';
import { EditorContextKeys } from 'vs/editor/common/editorContextKeys';
import { KeyCode } from 'vs/base/common/keyCodes';
import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
import { EditOperation } from 'vs/editor/common/core/editOperation';
import { Selection } from 'vs/editor/common/core/selection';
import { Position } from 'vs/editor/common/core/position';
import { Range } from 'vs/editor/common/core/range';
import { registerAction2 } from 'vs/platform/actions/common/actions';
import { assertIsDefined } from 'vs/base/common/types';
const EDITOR_DICTATION_IN_PROGRESS = new RawContextKey<boolean>('editorDictation.inProgress', false);
const VOICE_CATEGORY = localize2('voiceCategory', "Voice");
export class EditorDictationStartAction extends EditorAction2 {
constructor() {
super({
id: 'workbench.action.editorDictation.start',
title: localize2('startDictation', "Start Dictation in Editor"),
category: VOICE_CATEGORY,
precondition: ContextKeyExpr.and(HasSpeechProvider, EDITOR_DICTATION_IN_PROGRESS.toNegated(), EditorContextKeys.readOnly.toNegated()),
f1: true
});
}
override runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor): void {
const keybindingService = accessor.get(IKeybindingService);
const holdMode = keybindingService.enableKeybindingHoldMode(this.desc.id);
if (holdMode) {
let shouldCallStop = false;
const handle = setTimeout(() => {
shouldCallStop = true;
}, 500);
holdMode.finally(() => {
clearTimeout(handle);
if (shouldCallStop) {
EditorDictation.get(editor)?.stop();
}
});
}
EditorDictation.get(editor)?.start();
}
}
export class EditorDictationStopAction extends EditorAction2 {
constructor() {
super({
id: 'workbench.action.editorDictation.stop',
title: localize2('stopDictation', "Stop Dictation in Editor"),
category: VOICE_CATEGORY,
precondition: EDITOR_DICTATION_IN_PROGRESS,
f1: true,
keybinding: {
primary: KeyCode.Escape,
weight: KeybindingWeight.WorkbenchContrib + 100
}
});
}
override runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor): void {
EditorDictation.get(editor)?.stop();
}
}
export class DictationWidget extends Disposable implements IContentWidget {
readonly suppressMouseDown = true;
readonly allowEditorOverflow = true;
private readonly domNode = document.createElement('div');
private readonly elements = h('.editor-dictation-widget@main', [h('span@mic')]);
constructor(private readonly editor: ICodeEditor) {
super();
this.domNode.appendChild(this.elements.root);
this.domNode.style.zIndex = '1000';
reset(this.elements.mic, renderIcon(Codicon.micFilled));
}
getId(): string {
return 'editorDictation';
}
getDomNode(): HTMLElement {
return this.domNode;
}
getPosition(): IContentWidgetPosition | null {
if (!this.editor.hasModel()) {
return null;
}
const selection = this.editor.getSelection();
return {
position: selection.getPosition(),
preference: [
selection.getPosition().equals(selection.getStartPosition()) ? ContentWidgetPositionPreference.ABOVE : ContentWidgetPositionPreference.BELOW,
ContentWidgetPositionPreference.EXACT
]
};
}
beforeRender(): IDimension | null {
const lineHeight = this.editor.getOption(EditorOption.lineHeight);
const width = this.editor.getLayoutInfo().contentWidth * 0.7;
this.elements.main.style.setProperty('--vscode-editor-dictation-widget-height', `${lineHeight}px`);
this.elements.main.style.setProperty('--vscode-editor-dictation-widget-width', `${width}px`);
return null;
}
show() {
this.editor.addContentWidget(this);
}
layout(): void {
this.editor.layoutContentWidget(this);
}
active(): void {
this.elements.main.classList.add('recording');
}
hide() {
this.elements.main.classList.remove('recording');
this.editor.removeContentWidget(this);
}
}
export class EditorDictation extends Disposable implements IEditorContribution {
static readonly ID = 'editorDictation';
static get(editor: ICodeEditor): EditorDictation | null {
return editor.getContribution<EditorDictation>(EditorDictation.ID);
}
private readonly widget = this._register(new DictationWidget(this.editor));
private readonly editorDictationInProgress = EDITOR_DICTATION_IN_PROGRESS.bindTo(this.contextKeyService);
private sessionDisposables = this._register(new MutableDisposable());
constructor(
private readonly editor: ICodeEditor,
@ISpeechService private readonly speechService: ISpeechService,
@IContextKeyService private readonly contextKeyService: IContextKeyService
) {
super();
}
start() {
const disposables = new DisposableStore();
this.sessionDisposables.value = disposables;
this.widget.show();
disposables.add(toDisposable(() => this.widget.hide()));
this.editorDictationInProgress.set(true);
disposables.add(toDisposable(() => this.editorDictationInProgress.reset()));
const collection = this.editor.createDecorationsCollection();
disposables.add(toDisposable(() => collection.clear()));
let previewStart: Position | undefined = undefined;
let lastReplaceTextLength = 0;
const replaceText = (text: string, isPreview: boolean) => {
if (!previewStart) {
previewStart = assertIsDefined(this.editor.getPosition());
}
this.editor.executeEdits(EditorDictation.ID, [
EditOperation.replace(Range.fromPositions(previewStart, previewStart.with(undefined, previewStart.column + lastReplaceTextLength)), text)
], [
Selection.fromPositions(new Position(previewStart.lineNumber, previewStart.column + text.length))
]);
if (isPreview) {
collection.set([
{
range: Range.fromPositions(previewStart, previewStart.with(undefined, previewStart.column + text.length)),
options: {
description: 'editor-dictation-preview',
inlineClassName: 'ghost-text-decoration-preview'
}
}
]);
} else {
collection.clear();
}
lastReplaceTextLength = text.length;
if (!isPreview) {
previewStart = undefined;
lastReplaceTextLength = 0;
}
this.widget.layout();
};
const cts = new CancellationTokenSource();
disposables.add(toDisposable(() => cts.dispose(true)));
const session = disposables.add(this.speechService.createSpeechToTextSession(cts.token));
disposables.add(session.onDidChange(e => {
if (cts.token.isCancellationRequested) {
return;
}
switch (e.status) {
case SpeechToTextStatus.Started:
this.widget.active();
break;
case SpeechToTextStatus.Stopped:
disposables.dispose();
break;
case SpeechToTextStatus.Recognizing: {
if (!e.text) {
return;
}
replaceText(e.text, true);
break;
}
case SpeechToTextStatus.Recognized: {
if (!e.text) {
return;
}
replaceText(`${e.text} `, false);
break;
}
}
}));
}
stop(): void {
this.sessionDisposables.clear();
}
}
registerEditorContribution(EditorDictation.ID, EditorDictation, EditorContributionInstantiation.Lazy);
registerAction2(EditorDictationStartAction);
registerAction2(EditorDictationStopAction);

View file

@ -267,6 +267,7 @@ export class InlineChatQuickVoice implements IEditorContribution {
const done = (abort: boolean) => {
cts.dispose(true);
session.dispose();
listener.dispose();
this._widget.hide();
this._ctxQuickChatInProgress.reset();