From ad2c8accacc3506173117eebd3caa997126a62f5 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Thu, 18 Apr 2024 18:00:04 +0200 Subject: [PATCH 01/10] Allow hiding Accounts icon when Activity Bar Position is Top (#210669) fixes #197306 --- .../browser/parts/globalCompositeBar.ts | 72 +++++++++++++------ .../browser/parts/titlebar/titlebarPart.ts | 18 +++-- 2 files changed, 65 insertions(+), 25 deletions(-) diff --git a/src/vs/workbench/browser/parts/globalCompositeBar.ts b/src/vs/workbench/browser/parts/globalCompositeBar.ts index 8301e27c6435d..5d203718ce4f7 100644 --- a/src/vs/workbench/browser/parts/globalCompositeBar.ts +++ b/src/vs/workbench/browser/parts/globalCompositeBar.ts @@ -89,7 +89,7 @@ export class GlobalCompositeBar extends Disposable { contextMenuAlignmentOptions, (actions: IAction[]) => { actions.unshift(...[ - toAction({ id: 'hideAccounts', label: localize('hideAccounts', "Hide Accounts"), run: () => this.storageService.store(AccountsActivityActionViewItem.ACCOUNTS_VISIBILITY_PREFERENCE_KEY, false, StorageScope.PROFILE, StorageTarget.USER) }), + toAction({ id: 'hideAccounts', label: localize('hideAccounts', "Hide Accounts"), run: () => setAccountsActionVisible(storageService, false) }), new Separator() ]); }); @@ -147,11 +147,11 @@ export class GlobalCompositeBar extends Disposable { } private get accountsVisibilityPreference(): boolean { - return this.storageService.getBoolean(AccountsActivityActionViewItem.ACCOUNTS_VISIBILITY_PREFERENCE_KEY, StorageScope.PROFILE, true); + return isAccountsActionVisible(this.storageService); } private set accountsVisibilityPreference(value: boolean) { - this.storageService.store(AccountsActivityActionViewItem.ACCOUNTS_VISIBILITY_PREFERENCE_KEY, value, StorageScope.PROFILE, StorageTarget.USER); + setAccountsActionVisible(this.storageService, value); } } @@ -226,6 +226,9 @@ abstract class AbstractGlobalActivityActionViewItem extends CompositeBarActionVi // The rest of the activity bar uses context menu event for the context menu, so we match this this._register(addDisposableListener(this.container, EventType.CONTEXT_MENU, async (e: MouseEvent) => { + // Let the item decide on the context menu instead of the toolbar + e.stopPropagation(); + const disposables = new DisposableStore(); const actions = await this.resolveContextMenuActions(disposables); @@ -631,20 +634,22 @@ export class SimpleAccountActivityActionViewItem extends AccountsActivityActionV @IConfigurationService configurationService: IConfigurationService, @IKeybindingService keybindingService: IKeybindingService, @ISecretStorageService secretStorageService: ISecretStorageService, + @IStorageService storageService: IStorageService, @ILogService logService: ILogService, @IActivityService activityService: IActivityService, @IInstantiationService instantiationService: IInstantiationService, @ICommandService commandService: ICommandService ) { - super(() => [], { - ...options, - colors: theme => ({ - badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND), - badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND), - }), - hoverOptions, - compact: true, - }, () => undefined, actions => actions, themeService, lifecycleService, hoverService, contextMenuService, menuService, contextKeyService, authenticationService, environmentService, productService, configurationService, keybindingService, secretStorageService, logService, activityService, instantiationService, commandService); + super(() => simpleActivityContextMenuActions(storageService, true), + { + ...options, + colors: theme => ({ + badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND), + badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND), + }), + hoverOptions, + compact: true, + }, () => undefined, actions => actions, themeService, lifecycleService, hoverService, contextMenuService, menuService, contextKeyService, authenticationService, environmentService, productService, configurationService, keybindingService, secretStorageService, logService, activityService, instantiationService, commandService); } } @@ -664,15 +669,40 @@ export class SimpleGlobalActivityActionViewItem extends GlobalActivityActionView @IKeybindingService keybindingService: IKeybindingService, @IInstantiationService instantiationService: IInstantiationService, @IActivityService activityService: IActivityService, + @IStorageService storageService: IStorageService ) { - super(() => [], { - ...options, - colors: theme => ({ - badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND), - badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND), - }), - hoverOptions, - compact: true, - }, () => undefined, userDataProfileService, themeService, hoverService, menuService, contextMenuService, contextKeyService, configurationService, environmentService, keybindingService, instantiationService, activityService); + super(() => simpleActivityContextMenuActions(storageService, false), + { + ...options, + colors: theme => ({ + badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND), + badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND), + }), + hoverOptions, + compact: true, + }, () => undefined, userDataProfileService, themeService, hoverService, menuService, contextMenuService, contextKeyService, configurationService, environmentService, keybindingService, instantiationService, activityService); } } + +function simpleActivityContextMenuActions(storageService: IStorageService, isAccount: boolean): IAction[] { + const currentElementContextMenuActions: IAction[] = []; + if (isAccount) { + currentElementContextMenuActions.push( + toAction({ id: 'hideAccounts', label: localize('hideAccounts', "Hide Accounts"), run: () => setAccountsActionVisible(storageService, false) }), + new Separator() + ); + } + return [ + ...currentElementContextMenuActions, + toAction({ id: 'toggle.hideAccounts', label: localize('accounts', "Accounts"), checked: isAccountsActionVisible(storageService), run: () => setAccountsActionVisible(storageService, !isAccountsActionVisible(storageService)) }), + toAction({ id: 'toggle.hideManage', label: localize('manage', "Manage"), checked: true, enabled: false, run: () => { throw new Error('"Manage" can not be hidden'); } }) + ]; +} + +export function isAccountsActionVisible(storageService: IStorageService): boolean { + return storageService.getBoolean(AccountsActivityActionViewItem.ACCOUNTS_VISIBILITY_PREFERENCE_KEY, StorageScope.PROFILE, true); +} + +function setAccountsActionVisible(storageService: IStorageService, visible: boolean) { + storageService.store(AccountsActivityActionViewItem.ACCOUNTS_VISIBILITY_PREFERENCE_KEY, visible, StorageScope.PROFILE, StorageTarget.USER); +} diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts index 17c912101b3e4..5c2ff6882c44b 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts @@ -23,7 +23,7 @@ import { EventType, EventHelper, Dimension, append, $, addDisposableListener, pr import { CustomMenubarControl } from 'vs/workbench/browser/parts/titlebar/menubarControl'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { Emitter, Event } from 'vs/base/common/event'; -import { IStorageService } from 'vs/platform/storage/common/storage'; +import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; import { Parts, IWorkbenchLayoutService, ActivityBarPosition, LayoutSettings, EditorActionsLocation, EditorTabsMode } from 'vs/workbench/services/layout/browser/layoutService'; import { createActionViewItem, createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { Action2, IMenu, IMenuService, MenuId, registerAction2 } from 'vs/platform/actions/common/actions'; @@ -36,7 +36,7 @@ import { CommandCenterControl } from 'vs/workbench/browser/parts/titlebar/comman import { Categories } from 'vs/platform/action/common/actionCommonCategories'; import { WorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; import { ACCOUNTS_ACTIVITY_ID, GLOBAL_ACTIVITY_ID } from 'vs/workbench/common/activity'; -import { SimpleAccountActivityActionViewItem, SimpleGlobalActivityActionViewItem } from 'vs/workbench/browser/parts/globalCompositeBar'; +import { AccountsActivityActionViewItem, isAccountsActionVisible, SimpleAccountActivityActionViewItem, SimpleGlobalActivityActionViewItem } from 'vs/workbench/browser/parts/globalCompositeBar'; import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; import { IEditorGroupsContainer, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { ActionRunner, IAction } from 'vs/base/common/actions'; @@ -242,6 +242,7 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { private layoutToolbarMenu: IMenu | undefined; private readonly editorToolbarMenuDisposables = this._register(new DisposableStore()); private readonly layoutToolbarMenuDisposables = this._register(new DisposableStore()); + private readonly activityToolbarDisposables = this._register(new DisposableStore()); private readonly hoverDelegate: IHoverDelegate; @@ -265,7 +266,7 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { @IBrowserWorkbenchEnvironmentService protected readonly environmentService: IBrowserWorkbenchEnvironmentService, @IInstantiationService protected readonly instantiationService: IInstantiationService, @IThemeService themeService: IThemeService, - @IStorageService storageService: IStorageService, + @IStorageService private readonly storageService: IStorageService, @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IHostService private readonly hostService: IHostService, @@ -609,7 +610,9 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { // --- Activity Actions if (this.activityActionsEnabled) { - actions.primary.push(ACCOUNTS_ACTIVITY_TILE_ACTION); + if (isAccountsActionVisible(this.storageService)) { + actions.primary.push(ACCOUNTS_ACTIVITY_TILE_ACTION); + } actions.primary.push(GLOBAL_ACTIVITY_TITLE_ACTION); } @@ -650,6 +653,13 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { } } + if (update.activityActions) { + this.activityToolbarDisposables.clear(); + if (this.activityActionsEnabled) { + this.activityToolbarDisposables.add(this.storageService.onDidChangeValue(StorageScope.PROFILE, AccountsActivityActionViewItem.ACCOUNTS_VISIBILITY_PREFERENCE_KEY, this._store)(() => updateToolBarActions())); + } + } + updateToolBarActions(); } From 48bbfa71617f859ad1f65d80d790bbfa67ab148e Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 18 Apr 2024 09:13:56 -0700 Subject: [PATCH 02/10] add accessibility help extension contributions pt (#210116) --- .../accessibility/common/accessibility.ts | 1 + .../api/browser/viewsExtensionPoint.ts | 4 +- .../workbench/browser/parts/views/treeView.ts | 6 +- .../workbench/browser/parts/views/viewPane.ts | 21 ++- .../browser/accessibility.contribution.ts | 5 +- .../accessibility/browser/accessibleView.ts | 167 ++++++++++++------ .../browser/accessibleViewContributions.ts | 82 ++++++++- .../browser/editorAccessibilityHelp.ts | 4 +- .../comments/browser/commentsAccessibility.ts | 4 +- .../browser/terminalAccessibilityHelp.ts | 4 +- .../terminalAccessibleBufferProvider.ts | 4 +- .../browser/userDataSyncConflictsView.ts | 4 +- .../accessibleViewInformationService.ts | 26 +++ .../userDataProfileImportExportService.ts | 4 +- 14 files changed, 258 insertions(+), 78 deletions(-) create mode 100644 src/vs/workbench/services/accessibility/common/accessibleViewInformationService.ts diff --git a/src/vs/platform/accessibility/common/accessibility.ts b/src/vs/platform/accessibility/common/accessibility.ts index d6a89b230f876..3022a90e867ee 100644 --- a/src/vs/platform/accessibility/common/accessibility.ts +++ b/src/vs/platform/accessibility/common/accessibility.ts @@ -48,3 +48,4 @@ export function isAccessibilityInformation(obj: any): obj is IAccessibilityInfor && (typeof obj.role === 'undefined' || typeof obj.role === 'string'); } +export const ACCESSIBLE_VIEW_SHOWN_STORAGE_PREFIX = 'ACCESSIBLE_VIEW_SHOWN_'; diff --git a/src/vs/workbench/api/browser/viewsExtensionPoint.ts b/src/vs/workbench/api/browser/viewsExtensionPoint.ts index d04edb1d823f8..5eac39e4c0d15 100644 --- a/src/vs/workbench/api/browser/viewsExtensionPoint.ts +++ b/src/vs/workbench/api/browser/viewsExtensionPoint.ts @@ -106,7 +106,7 @@ interface IUserFriendlyViewDescriptor { remoteName?: string | string[]; virtualWorkspace?: string; - accessibilityHelpContent: string; + accessibilityHelpContent?: string; } enum InitialVisibility { @@ -173,7 +173,7 @@ const viewDescriptor: IJSONSchema = { }, accessibilityHelpContent: { type: 'string', - description: localize('vscode.extension.contributes.view.accessibilityHelpContent', "When the accessibility help dialog is invoked in this view, this content will be presented to the user as a markdown string.") + markdownDescription: localize('vscode.extension.contributes.view.accessibilityHelpContent', "When the accessibility help dialog is invoked in this view, this content will be presented to the user as a markdown string. Keybindings will be resolved when provided in the format of (keybinding:commandId). If there is no keybinding, that will be indicated with a link to configure one.") } } }; diff --git a/src/vs/workbench/browser/parts/views/treeView.ts b/src/vs/workbench/browser/parts/views/treeView.ts index 41933a2f72250..ffcfab82ca163 100644 --- a/src/vs/workbench/browser/parts/views/treeView.ts +++ b/src/vs/workbench/browser/parts/views/treeView.ts @@ -75,6 +75,7 @@ import type { IUpdatableHoverTooltipMarkdownString } from 'vs/base/browser/ui/ho import { parseLinkedText } from 'vs/base/common/linkedText'; import { Button } from 'vs/base/browser/ui/button/button'; import { defaultButtonStyles } from 'vs/platform/theme/browser/defaultStyles'; +import { IAccessibleViewInformationService } from 'vs/workbench/services/accessibility/common/accessibleViewInformationService'; export class TreeViewPane extends ViewPane { @@ -94,9 +95,10 @@ export class TreeViewPane extends ViewPane { @IThemeService themeService: IThemeService, @ITelemetryService telemetryService: ITelemetryService, @INotificationService notificationService: INotificationService, - @IHoverService hoverService: IHoverService + @IHoverService hoverService: IHoverService, + @IAccessibleViewInformationService accessibleViewService: IAccessibleViewInformationService, ) { - super({ ...(options as IViewPaneOptions), titleMenuId: MenuId.ViewTitle, donotForwardArgs: false }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); + super({ ...(options as IViewPaneOptions), titleMenuId: MenuId.ViewTitle, donotForwardArgs: false }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService, accessibleViewService); const { treeView } = (Registry.as(Extensions.ViewsRegistry).getView(options.id)); this.treeView = treeView; this._register(this.treeView.onDidChangeActions(() => this.updateActions(), this)); diff --git a/src/vs/workbench/browser/parts/views/viewPane.ts b/src/vs/workbench/browser/parts/views/viewPane.ts index de4558ab75b44..6eae565005161 100644 --- a/src/vs/workbench/browser/parts/views/viewPane.ts +++ b/src/vs/workbench/browser/parts/views/viewPane.ts @@ -52,6 +52,7 @@ import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; import { IHoverService } from 'vs/platform/hover/browser/hover'; import { IListStyles } from 'vs/base/browser/ui/list/listWidget'; import { PANEL_BACKGROUND, PANEL_STICKY_SCROLL_BACKGROUND, PANEL_STICKY_SCROLL_BORDER, PANEL_STICKY_SCROLL_SHADOW, SIDE_BAR_BACKGROUND, SIDE_BAR_STICKY_SCROLL_BACKGROUND, SIDE_BAR_STICKY_SCROLL_BORDER, SIDE_BAR_STICKY_SCROLL_SHADOW } from 'vs/workbench/common/theme'; +import { IAccessibleViewInformationService } from 'vs/workbench/services/accessibility/common/accessibleViewInformationService'; export enum ViewPaneShowActions { /** Show the actions when the view is hovered. This is the default behavior. */ @@ -374,7 +375,8 @@ export abstract class ViewPane extends Pane implements IView { @IOpenerService protected openerService: IOpenerService, @IThemeService protected themeService: IThemeService, @ITelemetryService protected telemetryService: ITelemetryService, - @IHoverService protected readonly hoverService: IHoverService + @IHoverService protected readonly hoverService: IHoverService, + protected readonly accessibleViewService?: IAccessibleViewInformationService ) { super({ ...options, ...{ orientation: viewDescriptorService.getViewLocationById(options.id) === ViewContainerLocation.Panel ? Orientation.HORIZONTAL : Orientation.VERTICAL } }); @@ -545,7 +547,17 @@ export abstract class ViewPane extends Pane implements IView { } this.iconContainerHover = this._register(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), this.iconContainer, calculatedTitle)); - this.iconContainer.setAttribute('aria-label', calculatedTitle); + this.iconContainer.setAttribute('aria-label', this._getAriaLabel(calculatedTitle)); + } + + private _getAriaLabel(title: string): string { + const viewHasAccessibilityHelpContent = this.viewDescriptorService.getViewDescriptorById(this.id)?.accessibilityHelpContent; + const accessibleViewHasShownForView = this.accessibleViewService?.hasShownAccessibleView(this.id); + if (!viewHasAccessibilityHelpContent || accessibleViewHasShownForView) { + return title; + } + + return nls.localize('viewAccessibilityHelp', 'Use Alt+F1 for accessibility help {0}', title); } protected updateTitle(title: string): void { @@ -557,7 +569,7 @@ export abstract class ViewPane extends Pane implements IView { if (this.iconContainer) { this.iconContainerHover?.update(calculatedTitle); - this.iconContainer.setAttribute('aria-label', calculatedTitle); + this.iconContainer.setAttribute('aria-label', this._getAriaLabel(calculatedTitle)); } this._title = title; @@ -730,8 +742,9 @@ export abstract class FilterViewPane extends ViewPane { @IThemeService themeService: IThemeService, @ITelemetryService telemetryService: ITelemetryService, @IHoverService hoverService: IHoverService, + accessibleViewService?: IAccessibleViewInformationService ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService, accessibleViewService); this.filterWidget = this._register(instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService])).createInstance(FilterWidget, options.filterOptions)); } diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibility.contribution.ts b/src/vs/workbench/contrib/accessibility/browser/accessibility.contribution.ts index 4f8da78441cdc..ebd81e486052e 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibility.contribution.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibility.contribution.ts @@ -10,7 +10,7 @@ import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle import { Registry } from 'vs/platform/registry/common/platform'; import { IAccessibleViewService, AccessibleViewService } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; import { UnfocusedViewDimmingContribution } from 'vs/workbench/contrib/accessibility/browser/unfocusedViewDimmingContribution'; -import { CommentAccessibleViewContribution, HoverAccessibleViewContribution, InlineCompletionsAccessibleViewContribution, NotificationAccessibleViewContribution } from 'vs/workbench/contrib/accessibility/browser/accessibleViewContributions'; +import { ExtensionAccessibilityHelpDialogContribution, CommentAccessibleViewContribution, HoverAccessibleViewContribution, InlineCompletionsAccessibleViewContribution, NotificationAccessibleViewContribution } from 'vs/workbench/contrib/accessibility/browser/accessibleViewContributions'; import { AccessibilityStatus } from 'vs/workbench/contrib/accessibility/browser/accessibilityStatus'; import { EditorAccessibilityHelpContribution } from 'vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp'; import { SaveAccessibilitySignalContribution } from 'vs/workbench/contrib/accessibilitySignals/browser/saveAccessibilitySignal'; @@ -18,10 +18,12 @@ import { CommentsAccessibilityHelpContribution } from 'vs/workbench/contrib/comm import { DiffEditorActiveAnnouncementContribution } from 'vs/workbench/contrib/accessibilitySignals/browser/openDiffEditorAnnouncement'; import { SpeechAccessibilitySignalContribution } from 'vs/workbench/contrib/speech/browser/speechAccessibilitySignal'; import { registerAudioCueConfiguration } from 'vs/workbench/contrib/accessibility/browser/audioCueConfiguration'; +import { AccessibleViewInformationService, IAccessibleViewInformationService } from 'vs/workbench/services/accessibility/common/accessibleViewInformationService'; registerAccessibilityConfiguration(); registerAudioCueConfiguration(); registerSingleton(IAccessibleViewService, AccessibleViewService, InstantiationType.Delayed); +registerSingleton(IAccessibleViewInformationService, AccessibleViewInformationService, InstantiationType.Delayed); const workbenchRegistry = Registry.as(WorkbenchExtensions.Workbench); workbenchRegistry.registerWorkbenchContribution(EditorAccessibilityHelpContribution, LifecyclePhase.Eventually); @@ -34,6 +36,7 @@ workbenchRegistry.registerWorkbenchContribution(CommentAccessibleViewContributio workbenchRegistry.registerWorkbenchContribution(InlineCompletionsAccessibleViewContribution, LifecyclePhase.Eventually); registerWorkbenchContribution2(AccessibilityStatus.ID, AccessibilityStatus, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ExtensionAccessibilityHelpDialogContribution.ID, ExtensionAccessibilityHelpDialogContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(SaveAccessibilitySignalContribution.ID, SaveAccessibilitySignalContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(SpeechAccessibilitySignalContribution.ID, SpeechAccessibilitySignalContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(DiffEditorActiveAnnouncementContribution.ID, DiffEditorActiveAnnouncementContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts b/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts index 82a6e7f5c78b5..ffd923df3b3d4 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts @@ -25,7 +25,7 @@ import { IModelService } from 'vs/editor/common/services/model'; import { AccessibilityHelpNLS } from 'vs/editor/common/standaloneStrings'; import { CodeActionController } from 'vs/editor/contrib/codeAction/browser/codeActionController'; import { localize } from 'vs/nls'; -import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; +import { ACCESSIBLE_VIEW_SHOWN_STORAGE_PREFIX, IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; import { createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { WorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; import { IMenuService, MenuId } from 'vs/platform/actions/common/actions'; @@ -40,6 +40,7 @@ import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; import { IOpenerService } from 'vs/platform/opener/common/opener'; import { IPickerQuickAccessItem } from 'vs/platform/quickinput/browser/pickerQuickAccess'; import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; +import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { AccessibilityVerbositySettingId, AccessibilityWorkbenchSettingId, AccessibleViewProviderId, accessibilityHelpIsShown, accessibleViewContainsCodeBlocks, accessibleViewCurrentProviderId, accessibleViewGoToSymbolSupported, accessibleViewInCodeBlock, accessibleViewIsShown, accessibleViewOnLastLine, accessibleViewSupportsNavigation, accessibleViewVerbosityEnabled } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; import { AccessibilityCommandId } from 'vs/workbench/contrib/accessibility/common/accessibilityCommands'; import { IChatCodeBlockContextProviderService } from 'vs/workbench/contrib/chat/browser/chat'; @@ -50,20 +51,56 @@ const enum DIMENSIONS { MAX_WIDTH = 600 } -export interface IAccessibleContentProvider { +type ContentProvider = AdvancedContentProvider | ExtensionContentProvider; + +export class AdvancedContentProvider implements IAccessibleViewContentProvider { + + constructor( + public id: AccessibleViewProviderId, + public options: IAccessibleViewOptions, + public provideContent: () => string, + public onClose: () => void, + public verbositySettingKey: AccessibilityVerbositySettingId, + public actions?: IAction[], + public next?: () => void, + public previous?: () => void, + public onKeyDown?: (e: IKeyboardEvent) => void, + public getSymbols?: () => IAccessibleViewSymbol[], + public onDidRequestClearLastProvider?: Event, + ) { } +} + +export class ExtensionContentProvider implements IBasicContentProvider { + + constructor( + public readonly id: string, + public options: IAccessibleViewOptions, + public provideContent: () => string, + public onClose: () => void, + public next?: () => void, + public previous?: () => void, + public actions?: IAction[], + ) { } +} + +export interface IBasicContentProvider { + id: string; + options: IAccessibleViewOptions; + onClose(): void; + provideContent(): string; + actions?: IAction[]; + previous?(): void; + next?(): void; +} + +export interface IAccessibleViewContentProvider extends IBasicContentProvider { id: AccessibleViewProviderId; verbositySettingKey: AccessibilityVerbositySettingId; - options: IAccessibleViewOptions; /** * Note that a Codicon class should be provided for each action. * If not, a default will be used. */ - actions?: IAction[]; - provideContent(): string; - onClose(): void; onKeyDown?(e: IKeyboardEvent): void; - previous?(): void; - next?(): void; /** * When the language is markdown, this is provided by default. */ @@ -78,7 +115,7 @@ export const IAccessibleViewService = createDecorator('a export interface IAccessibleViewService { readonly _serviceBrand: undefined; - show(provider: IAccessibleContentProvider, position?: Position): void; + show(provider: ContentProvider, position?: Position): void; showLastProvider(id: AccessibleViewProviderId): void; showAccessibleViewHelp(): void; next(): void; @@ -157,10 +194,10 @@ export class AccessibleView extends Disposable { private _title: HTMLElement; private readonly _toolbar: WorkbenchToolBar; - private _currentProvider: IAccessibleContentProvider | undefined; + private _currentProvider: ContentProvider | undefined; private _currentContent: string | undefined; - private _lastProvider: IAccessibleContentProvider | undefined; + private _lastProvider: ContentProvider | undefined; constructor( @IOpenerService private readonly _openerService: IOpenerService, @@ -174,7 +211,8 @@ export class AccessibleView extends Disposable { @ILayoutService private readonly _layoutService: ILayoutService, @IMenuService private readonly _menuService: IMenuService, @ICommandService private readonly _commandService: ICommandService, - @IChatCodeBlockContextProviderService private readonly _codeBlockContextProviderService: IChatCodeBlockContextProviderService + @IChatCodeBlockContextProviderService private readonly _codeBlockContextProviderService: IChatCodeBlockContextProviderService, + @IStorageService private readonly _storageService: IStorageService ) { super(); @@ -231,7 +269,7 @@ export class AccessibleView extends Disposable { } })); this._register(this._configurationService.onDidChangeConfiguration(e => { - if (this._currentProvider && e.affectsConfiguration(this._currentProvider.verbositySettingKey)) { + if (this._currentProvider instanceof AdvancedContentProvider && e.affectsConfiguration(this._currentProvider.verbositySettingKey)) { if (this._accessiblityHelpIsShown.get()) { this.show(this._currentProvider); } @@ -316,7 +354,7 @@ export class AccessibleView extends Disposable { this.show(this._lastProvider); } - show(provider?: IAccessibleContentProvider, symbol?: IAccessibleViewSymbol, showAccessibleViewHelp?: boolean, position?: Position): void { + show(provider?: ContentProvider, symbol?: IAccessibleViewSymbol, showAccessibleViewHelp?: boolean, position?: Position): void { provider = provider ?? this._currentProvider; if (!provider) { return; @@ -348,8 +386,8 @@ export class AccessibleView extends Disposable { if (symbol && this._currentProvider) { this.showSymbol(this._currentProvider, symbol); } - if (provider.onDidRequestClearLastProvider) { - this._register(provider.onDidRequestClearLastProvider((id) => { + if (provider instanceof AdvancedContentProvider && provider.onDidRequestClearLastProvider) { + this._register(provider.onDidRequestClearLastProvider((id: string) => { if (this._lastProvider?.options.id === id) { this._lastProvider = undefined; } @@ -362,20 +400,24 @@ export class AccessibleView extends Disposable { if (provider.id === AccessibleViewProviderId.Chat) { this._register(this._codeBlockContextProviderService.registerProvider({ getCodeBlockContext: () => this.getCodeBlockContext() }, 'accessibleView')); } + if (provider instanceof ExtensionContentProvider) { + this._storageService.store(`${ACCESSIBLE_VIEW_SHOWN_STORAGE_PREFIX}${provider.id}`, true, StorageScope.APPLICATION, StorageTarget.USER); + } } previous(): void { - if (!this._currentProvider) { - return; - } - this._currentProvider.previous?.(); + this._currentProvider?.previous?.(); } next(): void { + this._currentProvider?.next?.(); + } + + private _verbosityEnabled(): boolean { if (!this._currentProvider) { - return; + return false; } - this._currentProvider.next?.(); + return this._currentProvider instanceof AdvancedContentProvider ? this._configurationService.getValue(this._currentProvider.verbositySettingKey) === true : this._storageService.getBoolean(`${ACCESSIBLE_VIEW_SHOWN_STORAGE_PREFIX}${this._currentProvider.id}`, StorageScope.APPLICATION, false); } goToSymbol(): void { @@ -415,14 +457,15 @@ export class AccessibleView extends Disposable { } getSymbols(): IAccessibleViewSymbol[] | undefined { - if (!this._currentProvider || !this._currentContent) { + const provider = this._currentProvider instanceof AdvancedContentProvider ? this._currentProvider : undefined; + if (!this._currentContent || !provider) { return; } - const symbols: IAccessibleViewSymbol[] = this._currentProvider.getSymbols?.() || []; + const symbols: IAccessibleViewSymbol[] = provider.getSymbols?.() || []; if (symbols?.length) { return symbols; } - if (this._currentProvider.options.language && this._currentProvider.options.language !== 'markdown') { + if (provider.options.language && provider.options.language !== 'markdown') { // Symbols haven't been provided and we cannot parse this language return; } @@ -463,7 +506,7 @@ export class AccessibleView extends Disposable { } } - showSymbol(provider: IAccessibleContentProvider, symbol: IAccessibleViewSymbol): void { + showSymbol(provider: ContentProvider, symbol: IAccessibleViewSymbol): void { if (!this._currentContent) { return; } @@ -490,14 +533,14 @@ export class AccessibleView extends Disposable { } disableHint(): void { - if (!this._currentProvider) { + if (!(this._currentProvider instanceof AdvancedContentProvider)) { return; } this._configurationService.updateValue(this._currentProvider?.verbositySettingKey, false); alert(localize('disableAccessibilityHelp', '{0} accessibility verbosity is now disabled', this._currentProvider.verbositySettingKey)); } - private _updateContextKeys(provider: IAccessibleContentProvider, shown: boolean): void { + private _updateContextKeys(provider: ContentProvider, shown: boolean): void { if (provider.options.type === AccessibleViewType.Help) { this._accessiblityHelpIsShown.set(shown); this._accessibleViewIsShown.reset(); @@ -505,23 +548,18 @@ export class AccessibleView extends Disposable { this._accessibleViewIsShown.set(shown); this._accessiblityHelpIsShown.reset(); } - if (provider.next && provider.previous) { - this._accessibleViewSupportsNavigation.set(true); - } else { - this._accessibleViewSupportsNavigation.reset(); - } - const verbosityEnabled: boolean = this._configurationService.getValue(provider.verbositySettingKey); - this._accessibleViewVerbosityEnabled.set(verbosityEnabled); + this._accessibleViewSupportsNavigation.set(provider.next !== undefined || provider.previous !== undefined); + this._accessibleViewVerbosityEnabled.set(this._verbosityEnabled()); this._accessibleViewGoToSymbolSupported.set(this._goToSymbolsSupported() ? this.getSymbols()?.length! > 0 : false); } - private _render(provider: IAccessibleContentProvider, container: HTMLElement, showAccessibleViewHelp?: boolean): IDisposable { + private _render(provider: ContentProvider, container: HTMLElement, showAccessibleViewHelp?: boolean): IDisposable { this._currentProvider = provider; this._accessibleViewCurrentProviderId.set(provider.id); - const value = this._configurationService.getValue(provider.verbositySettingKey); + const verbose = this._verbosityEnabled(); const readMoreLink = provider.options.readMoreUrl ? localize("openDoc", "\n\nOpen a browser window with more information related to accessibility (H).") : ''; let disableHelpHint = ''; - if (provider.options.type === AccessibleViewType.Help && !!value) { + if (provider instanceof AdvancedContentProvider && provider.options.type === AccessibleViewType.Help && verbose) { disableHelpHint = this._getDisableVerbosityHint(provider.verbositySettingKey); } const accessibilitySupport = this._accessibilityService.isScreenReaderOptimized(); @@ -532,7 +570,7 @@ export class AccessibleView extends Disposable { ? AccessibilityHelpNLS.changeConfigToOnMac : AccessibilityHelpNLS.changeConfigToOnWinLinux ); - if (accessibilitySupport && provider.verbositySettingKey === AccessibilityVerbositySettingId.Editor) { + if (accessibilitySupport && provider instanceof AdvancedContentProvider && provider.verbositySettingKey === AccessibilityVerbositySettingId.Editor) { message = AccessibilityHelpNLS.auto_on; message += '\n'; } else if (!accessibilitySupport) { @@ -540,14 +578,13 @@ export class AccessibleView extends Disposable { message += '\n'; } } - const verbose = this._configurationService.getValue(provider.verbositySettingKey); const exitThisDialogHint = verbose && !provider.options.position ? localize('exit', '\n\nExit this dialog (Escape).') : ''; const newContent = message + provider.provideContent() + readMoreLink + disableHelpHint + exitThisDialogHint; this.calculateCodeBlocks(newContent); this._currentContent = newContent; this._updateContextKeys(provider, true); const widgetIsFocused = this._editorWidget.hasTextFocus() || this._editorWidget.hasWidgetFocus(); - this._getTextModel(URI.from({ path: `accessible-view-${provider.verbositySettingKey}`, scheme: 'accessible-view', fragment: this._currentContent })).then((model) => { + this._getTextModel(URI.from({ path: `accessible-view-${provider.id}`, scheme: 'accessible-view', fragment: this._currentContent })).then((model) => { if (!model) { return; } @@ -559,8 +596,7 @@ export class AccessibleView extends Disposable { model.setLanguage(provider.options.language ?? 'markdown'); container.appendChild(this._container); let actionsHint = ''; - const verbose = this._configurationService.getValue(provider.verbositySettingKey); - const hasActions = this._accessibleViewSupportsNavigation.get() || this._accessibleViewVerbosityEnabled.get() || this._accessibleViewGoToSymbolSupported.get() || this._currentProvider?.actions; + const hasActions = this._accessibleViewSupportsNavigation.get() || this._accessibleViewVerbosityEnabled.get() || this._accessibleViewGoToSymbolSupported.get() || provider.actions?.length; if (verbose && !showAccessibleViewHelp && hasActions) { actionsHint = provider.options.position ? localize('ariaAccessibleViewActionsBottom', 'Explore actions such as disabling this hint (Shift+Tab), use Escape to exit this dialog.') : localize('ariaAccessibleViewActions', 'Explore actions such as disabling this hint (Shift+Tab).'); } @@ -591,7 +627,7 @@ export class AccessibleView extends Disposable { } } }); - this._updateToolbar(provider.actions, provider.options.type); + this._updateToolbar(this._currentProvider.actions, provider.options.type); const hide = (e: KeyboardEvent | IKeyboardEvent): void => { provider.onClose(); @@ -614,7 +650,9 @@ export class AccessibleView extends Disposable { e.preventDefault(); e.stopPropagation(); } - provider.onKeyDown?.(e); + if (provider instanceof AdvancedContentProvider) { + provider.onKeyDown?.(e); + } })); disposableStore.add(addDisposableListener(this._toolbar.getElement(), EventType.KEY_DOWN, (e: KeyboardEvent) => { const keyboardEvent = new StandardKeyboardEvent(e); @@ -668,17 +706,34 @@ export class AccessibleView extends Disposable { if (!this._currentProvider) { return false; } - return this._currentProvider.options.type === AccessibleViewType.Help || this._currentProvider.options.language === 'markdown' || this._currentProvider.options.language === undefined || !!this._currentProvider.getSymbols?.(); + return this._currentProvider.options.type === AccessibleViewType.Help || this._currentProvider.options.language === 'markdown' || this._currentProvider.options.language === undefined || (this._currentProvider instanceof AdvancedContentProvider && !!this._currentProvider.getSymbols?.()); } - private _updateLastProvider(): IAccessibleContentProvider | undefined { - if (!this._currentProvider) { + private _updateLastProvider(): ContentProvider | undefined { + const provider = this._currentProvider; + if (!provider) { return; } - const lastProvider = Object.assign({}, this._currentProvider); - lastProvider.provideContent = this._currentProvider.provideContent.bind(lastProvider); - lastProvider.options = Object.assign({}, this._currentProvider.options); - lastProvider.verbositySettingKey = this._currentProvider.verbositySettingKey; + const lastProvider = provider instanceof AdvancedContentProvider ? new AdvancedContentProvider( + provider.id, + provider.options, + provider.provideContent.bind(provider), + provider.onClose, + provider.verbositySettingKey, + provider.actions, + provider.next, + provider.previous, + provider.onKeyDown, + provider.getSymbols, + ) : new ExtensionContentProvider( + provider.id, + provider.options, + provider.provideContent.bind(provider), + provider.onClose, + provider.next, + provider.previous, + provider.actions + ); return lastProvider; } @@ -688,7 +743,7 @@ export class AccessibleView extends Disposable { return; } - const accessibleViewHelpProvider: IAccessibleContentProvider = { + const accessibleViewHelpProvider = { id: lastProvider.id, provideContent: () => lastProvider.options.customHelp ? lastProvider?.options.customHelp() : this._getAccessibleViewHelpDialogContent(this._goToSymbolsSupported()), onClose: () => { @@ -697,7 +752,7 @@ export class AccessibleView extends Disposable { queueMicrotask(() => this.show(lastProvider)); }, options: { type: AccessibleViewType.Help }, - verbositySettingKey: lastProvider.verbositySettingKey + verbositySettingKey: lastProvider instanceof AdvancedContentProvider ? lastProvider.verbositySettingKey : undefined }; this._contextViewService.hideContextView(); // HACK: Delay to allow the context view to hide #186514 @@ -805,7 +860,7 @@ export class AccessibleViewService extends Disposable implements IAccessibleView super(); } - show(provider: IAccessibleContentProvider, position?: Position): void { + show(provider: ContentProvider, position?: Position): void { if (!this._accessibleView) { this._accessibleView = this._register(this._instantiationService.createInstance(AccessibleView)); } @@ -868,7 +923,7 @@ class AccessibleViewSymbolQuickPick { constructor(private _accessibleView: AccessibleView, @IQuickInputService private readonly _quickInputService: IQuickInputService) { } - show(provider: IAccessibleContentProvider): void { + show(provider: ContentProvider): void { const quickPick = this._quickInputService.createQuickPick(); quickPick.placeholder = localize('accessibleViewSymbolQuickPickPlaceholder', "Type to search symbols"); quickPick.title = localize('accessibleViewSymbolQuickPickTitle', "Go to Symbol Accessible View"); diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibleViewContributions.ts b/src/vs/workbench/contrib/accessibility/browser/accessibleViewContributions.ts index c9a03e03fb776..a3f644a36aaf2 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibleViewContributions.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibleViewContributions.ts @@ -8,7 +8,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableMap, DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; import { localize } from 'vs/nls'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; @@ -19,8 +19,8 @@ import { IContextViewService } from 'vs/platform/contextview/browser/contextView import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; import { getNotificationFromContext } from 'vs/workbench/browser/parts/notifications/notificationsCommands'; import { IListService, WorkbenchList } from 'vs/platform/list/browser/listService'; -import { NotificationFocusedContext } from 'vs/workbench/common/contextkeys'; -import { IAccessibleViewService, IAccessibleViewOptions, AccessibleViewType } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; +import { FocusedViewContext, NotificationFocusedContext } from 'vs/workbench/common/contextkeys'; +import { IAccessibleViewService, IAccessibleViewOptions, AccessibleViewType, ExtensionContentProvider } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; import { IHoverService } from 'vs/platform/hover/browser/hover'; import { alert } from 'vs/base/browser/ui/aria/aria'; import { AccessibilityHelpAction, AccessibleViewAction } from 'vs/workbench/contrib/accessibility/browser/accessibleViewActions'; @@ -32,12 +32,16 @@ import { InlineCompletionsController } from 'vs/editor/contrib/inlineCompletions import { InlineCompletionContextKeys } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionContextKeys'; import { ContextKeyExpr, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; +import { Extensions, IViewDescriptor, IViewsRegistry } from 'vs/workbench/common/views'; +import { Registry } from 'vs/platform/registry/common/platform'; import { COMMENTS_VIEW_ID, CommentsMenus } from 'vs/workbench/contrib/comments/browser/commentsTreeViewer'; import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; import { CommentsPanel, CONTEXT_KEY_HAS_COMMENTS } from 'vs/workbench/contrib/comments/browser/commentsView'; import { IMenuService } from 'vs/platform/actions/common/actions'; import { MarshalledId } from 'vs/base/common/marshallingIds'; import { HoverController } from 'vs/editor/contrib/hover/browser/hoverController'; +import { MarkdownString } from 'vs/base/common/htmlContent'; +import { URI } from 'vs/base/common/uri'; export function descriptionForCommand(commandId: string, msg: string, noKbMsg: string, keybindingService: IKeybindingService): string { const kb = keybindingService.lookupKeybinding(commandId); @@ -349,3 +353,75 @@ export class InlineCompletionsAccessibleViewContribution extends Disposable { } } +export class ExtensionAccessibilityHelpDialogContribution extends Disposable { + static ID = 'extensionAccessibilityHelpDialogContribution'; + private _viewHelpDialogMap = this._register(new DisposableMap()); + constructor(@IKeybindingService keybindingService: IKeybindingService) { + super(); + this._register(Registry.as(Extensions.ViewsRegistry).onViewsRegistered(e => { + for (const view of e) { + for (const viewDescriptor of view.views) { + if (viewDescriptor.accessibilityHelpContent) { + this._viewHelpDialogMap.set(viewDescriptor.id, registerAccessibilityHelpAction(keybindingService, viewDescriptor)); + } + } + } + })); + this._register(Registry.as(Extensions.ViewsRegistry).onViewsDeregistered(e => { + for (const viewDescriptor of e.views) { + if (viewDescriptor.accessibilityHelpContent) { + this._viewHelpDialogMap.get(viewDescriptor.id)?.dispose(); + } + } + })); + } +} + +function registerAccessibilityHelpAction(keybindingService: IKeybindingService, viewDescriptor: IViewDescriptor): IDisposable { + const disposableStore = new DisposableStore(); + const helpContent = resolveExtensionHelpContent(keybindingService, viewDescriptor.accessibilityHelpContent); + if (!helpContent) { + throw new Error('No help content for view'); + } + disposableStore.add(AccessibilityHelpAction.addImplementation(95, viewDescriptor.id, accessor => { + const accessibleViewService = accessor.get(IAccessibleViewService); + const viewsService = accessor.get(IViewsService); + accessibleViewService.show(new ExtensionContentProvider( + viewDescriptor.id, + { type: AccessibleViewType.Help }, + () => helpContent.value, + () => viewsService.openView(viewDescriptor.id, true) + )); + return true; + }, FocusedViewContext.isEqualTo(viewDescriptor.id))); + disposableStore.add(keybindingService.onDidUpdateKeybindings(() => { + disposableStore.clear(); + disposableStore.add(registerAccessibilityHelpAction(keybindingService, viewDescriptor)); + })); + return disposableStore; +} + +function resolveExtensionHelpContent(keybindingService: IKeybindingService, content?: MarkdownString): MarkdownString | undefined { + if (!content) { + return; + } + let resolvedContent = typeof content === 'string' ? content : content.value; + const matches = resolvedContent.matchAll(/\(keybinding:(?.*)\)/gm); + for (const match of [...matches]) { + const commandId = match?.groups?.commandId; + if (match?.length && commandId) { + const keybinding = keybindingService.lookupKeybinding(commandId)?.getAriaLabel(); + let kbLabel = keybinding; + if (!kbLabel) { + const args = URI.parse(`command:workbench.action.openGlobalKeybindings?${encodeURIComponent(JSON.stringify(commandId))}`); + kbLabel = ` [Configure a keybinding](${args})`; + } else { + kbLabel = ' (' + keybinding + ')'; + } + resolvedContent = resolvedContent.replace(match[0], kbLabel); + } + } + const result = new MarkdownString(resolvedContent); + result.isTrusted = true; + return result; +} diff --git a/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts b/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts index bbc00f6f7de3b..541b07a6bce5c 100644 --- a/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts @@ -16,7 +16,7 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { AccessibleViewProviderId, AccessibilityVerbositySettingId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; import { descriptionForCommand } from 'vs/workbench/contrib/accessibility/browser/accessibleViewContributions'; -import { IAccessibleViewService, IAccessibleContentProvider, IAccessibleViewOptions, AccessibleViewType } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; +import { IAccessibleViewService, IAccessibleViewContentProvider, IAccessibleViewOptions, AccessibleViewType } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; import { AccessibilityHelpAction } from 'vs/workbench/contrib/accessibility/browser/accessibleViewActions'; import { CONTEXT_CHAT_ENABLED } from 'vs/workbench/contrib/chat/common/chatContextKeys'; import { CommentAccessibilityHelpNLS } from 'vs/workbench/contrib/comments/browser/commentsAccessibility'; @@ -43,7 +43,7 @@ export class EditorAccessibilityHelpContribution extends Disposable { } } -class EditorAccessibilityHelpProvider implements IAccessibleContentProvider { +class EditorAccessibilityHelpProvider implements IAccessibleViewContentProvider { id = AccessibleViewProviderId.Editor; onClose() { this._editor.focus(); diff --git a/src/vs/workbench/contrib/comments/browser/commentsAccessibility.ts b/src/vs/workbench/contrib/comments/browser/commentsAccessibility.ts index 27ba7d7e66379..921d9be01b25a 100644 --- a/src/vs/workbench/contrib/comments/browser/commentsAccessibility.ts +++ b/src/vs/workbench/contrib/comments/browser/commentsAccessibility.ts @@ -6,7 +6,7 @@ import { Disposable } from 'vs/base/common/lifecycle'; import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { AccessibleViewType, IAccessibleContentProvider, IAccessibleViewOptions, IAccessibleViewService } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; +import { AccessibleViewType, IAccessibleViewContentProvider, IAccessibleViewOptions, IAccessibleViewService } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; import { AccessibilityHelpAction } from 'vs/workbench/contrib/accessibility/browser/accessibleViewActions'; import { ctxCommentEditorFocused } from 'vs/workbench/contrib/comments/browser/simpleCommentEditor'; import { CommentContextKeys } from 'vs/workbench/contrib/comments/common/commentContextKeys'; @@ -38,7 +38,7 @@ export namespace CommentAccessibilityHelpNLS { export const submitCommentNoKb = nls.localize('submitCommentNoKb', "- Submit Comment, accessible via tabbing, as it's currently not triggerable with a keybinding."); } -export class CommentsAccessibilityHelpProvider implements IAccessibleContentProvider { +export class CommentsAccessibilityHelpProvider implements IAccessibleViewContentProvider { id = AccessibleViewProviderId.Comments; verbositySettingKey: AccessibilityVerbositySettingId = AccessibilityVerbositySettingId.Comments; options: IAccessibleViewOptions = { type: AccessibleViewType.Help }; diff --git a/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibilityHelp.ts b/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibilityHelp.ts index 7c88dd17a33d2..4a3d33ff9ca91 100644 --- a/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibilityHelp.ts @@ -13,7 +13,7 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { ShellIntegrationStatus, TerminalSettingId, WindowsShellType } from 'vs/platform/terminal/common/terminal'; import { AccessibilityVerbositySettingId, AccessibleViewProviderId, accessibleViewCurrentProviderId, accessibleViewIsShown } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; -import { AccessibleViewType, IAccessibleContentProvider, IAccessibleViewOptions } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; +import { AccessibleViewType, IAccessibleViewContentProvider, IAccessibleViewOptions } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; import { AccessibilityCommandId } from 'vs/workbench/contrib/accessibility/common/accessibilityCommands'; import { ITerminalInstance, IXtermTerminal } from 'vs/workbench/contrib/terminal/browser/terminal'; import { TerminalCommandId } from 'vs/workbench/contrib/terminal/common/terminal'; @@ -25,7 +25,7 @@ export const enum ClassName { EditorTextArea = 'textarea' } -export class TerminalAccessibilityHelpProvider extends Disposable implements IAccessibleContentProvider { +export class TerminalAccessibilityHelpProvider extends Disposable implements IAccessibleViewContentProvider { id = AccessibleViewProviderId.TerminalHelp; private readonly _hasShellIntegration: boolean = false; onClose() { diff --git a/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibleBufferProvider.ts b/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibleBufferProvider.ts index 3fd35f912f612..399e846921fe9 100644 --- a/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibleBufferProvider.ts +++ b/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibleBufferProvider.ts @@ -12,11 +12,11 @@ import { TerminalCapability, ITerminalCommand } from 'vs/platform/terminal/commo import { ICurrentPartialCommand } from 'vs/platform/terminal/common/capabilities/commandDetection/terminalCommand'; import { TerminalSettingId } from 'vs/platform/terminal/common/terminal'; import { AccessibilityVerbositySettingId, AccessibleViewProviderId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; -import { AccessibleViewType, IAccessibleContentProvider, IAccessibleViewOptions, IAccessibleViewSymbol } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; +import { AccessibleViewType, IAccessibleViewContentProvider, IAccessibleViewOptions, IAccessibleViewSymbol } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; import { ITerminalInstance, ITerminalService } from 'vs/workbench/contrib/terminal/browser/terminal'; import { BufferContentTracker } from 'vs/workbench/contrib/terminalContrib/accessibility/browser/bufferContentTracker'; -export class TerminalAccessibleBufferProvider extends DisposableStore implements IAccessibleContentProvider { +export class TerminalAccessibleBufferProvider extends DisposableStore implements IAccessibleViewContentProvider { id = AccessibleViewProviderId.Terminal; options: IAccessibleViewOptions = { type: AccessibleViewType.View, language: 'terminal', id: AccessibleViewProviderId.Terminal }; verbositySettingKey = AccessibilityVerbositySettingId.Terminal; diff --git a/src/vs/workbench/contrib/userDataSync/browser/userDataSyncConflictsView.ts b/src/vs/workbench/contrib/userDataSync/browser/userDataSyncConflictsView.ts index 15bfa4d03eeff..37c0a56e082b6 100644 --- a/src/vs/workbench/contrib/userDataSync/browser/userDataSyncConflictsView.ts +++ b/src/vs/workbench/contrib/userDataSync/browser/userDataSyncConflictsView.ts @@ -27,6 +27,7 @@ import { Codicon } from 'vs/base/common/codicons'; import { IUserDataProfile, IUserDataProfilesService, reviveProfile } from 'vs/platform/userDataProfile/common/userDataProfile'; import { DEFAULT_EDITOR_ASSOCIATION } from 'vs/workbench/common/editor'; import { IHoverService } from 'vs/platform/hover/browser/hover'; +import { IAccessibleViewInformationService } from 'vs/workbench/services/accessibility/common/accessibleViewInformationService'; type UserDataSyncConflictResource = IUserDataSyncResource & IResourcePreview; @@ -50,8 +51,9 @@ export class UserDataSyncConflictsViewPane extends TreeViewPane implements IUser @IUserDataSyncWorkbenchService private readonly userDataSyncWorkbenchService: IUserDataSyncWorkbenchService, @IUserDataSyncEnablementService private readonly userDataSyncEnablementService: IUserDataSyncEnablementService, @IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService, + @IAccessibleViewInformationService accessibleViewVisibilityService: IAccessibleViewInformationService, ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, notificationService, hoverService); + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, notificationService, hoverService, accessibleViewVisibilityService); this._register(this.userDataSyncService.onDidChangeConflicts(() => this.treeView.refresh())); this.registerActions(); } diff --git a/src/vs/workbench/services/accessibility/common/accessibleViewInformationService.ts b/src/vs/workbench/services/accessibility/common/accessibleViewInformationService.ts new file mode 100644 index 0000000000000..c5491abee8c28 --- /dev/null +++ b/src/vs/workbench/services/accessibility/common/accessibleViewInformationService.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { ACCESSIBLE_VIEW_SHOWN_STORAGE_PREFIX } from 'vs/platform/accessibility/common/accessibility'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; + +export interface IAccessibleViewInformationService { + _serviceBrand: undefined; + hasShownAccessibleView(viewId: string): boolean; +} + +export const IAccessibleViewInformationService = createDecorator('accessibleViewInformationService'); + +export class AccessibleViewInformationService extends Disposable implements IAccessibleViewInformationService { + declare readonly _serviceBrand: undefined; + constructor(@IStorageService private readonly _storageService: IStorageService) { + super(); + } + hasShownAccessibleView(viewId: string): boolean { + return this._storageService.getBoolean(`${ACCESSIBLE_VIEW_SHOWN_STORAGE_PREFIX}${viewId}`, StorageScope.APPLICATION, false) === true; + } +} diff --git a/src/vs/workbench/services/userDataProfile/browser/userDataProfileImportExportService.ts b/src/vs/workbench/services/userDataProfile/browser/userDataProfileImportExportService.ts index 66c10d2415dc4..34e9694bd4277 100644 --- a/src/vs/workbench/services/userDataProfile/browser/userDataProfileImportExportService.ts +++ b/src/vs/workbench/services/userDataProfile/browser/userDataProfileImportExportService.ts @@ -80,6 +80,7 @@ import { WorkbenchIconSelectBox } from 'vs/workbench/services/userDataProfile/br import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { KeyCode } from 'vs/base/common/keyCodes'; import type { IHoverWidget } from 'vs/base/browser/ui/hover/hover'; +import { IAccessibleViewInformationService } from 'vs/workbench/services/accessibility/common/accessibleViewInformationService'; interface IUserDataProfileTemplate { readonly name: string; @@ -1126,8 +1127,9 @@ class UserDataProfilePreviewViewPane extends TreeViewPane { @ITelemetryService telemetryService: ITelemetryService, @INotificationService notificationService: INotificationService, @IHoverService hoverService: IHoverService, + @IAccessibleViewInformationService accessibleViewService: IAccessibleViewInformationService, ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, notificationService, hoverService); + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, notificationService, hoverService, accessibleViewService); } protected override renderTreeView(container: HTMLElement): void { From e7e6fb64dac7d0e31d52d4455d22cc4e55c2e94d Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 18 Apr 2024 09:44:45 -0700 Subject: [PATCH 03/10] use `<>` instead of `()` for keybinding syntax (#210676) --- src/vs/workbench/api/browser/viewsExtensionPoint.ts | 2 +- .../accessibility/browser/accessibleViewContributions.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/api/browser/viewsExtensionPoint.ts b/src/vs/workbench/api/browser/viewsExtensionPoint.ts index 5eac39e4c0d15..77ef2f7844f89 100644 --- a/src/vs/workbench/api/browser/viewsExtensionPoint.ts +++ b/src/vs/workbench/api/browser/viewsExtensionPoint.ts @@ -173,7 +173,7 @@ const viewDescriptor: IJSONSchema = { }, accessibilityHelpContent: { type: 'string', - markdownDescription: localize('vscode.extension.contributes.view.accessibilityHelpContent', "When the accessibility help dialog is invoked in this view, this content will be presented to the user as a markdown string. Keybindings will be resolved when provided in the format of (keybinding:commandId). If there is no keybinding, that will be indicated with a link to configure one.") + markdownDescription: localize('vscode.extension.contributes.view.accessibilityHelpContent', "When the accessibility help dialog is invoked in this view, this content will be presented to the user as a markdown string. Keybindings will be resolved when provided in the format of . If there is no keybinding, that will be indicated with a link to configure one.") } } }; diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibleViewContributions.ts b/src/vs/workbench/contrib/accessibility/browser/accessibleViewContributions.ts index a3f644a36aaf2..c01a8f828ea04 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibleViewContributions.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibleViewContributions.ts @@ -406,7 +406,7 @@ function resolveExtensionHelpContent(keybindingService: IKeybindingService, cont return; } let resolvedContent = typeof content === 'string' ? content : content.value; - const matches = resolvedContent.matchAll(/\(keybinding:(?.*)\)/gm); + const matches = resolvedContent.matchAll(/\.*)\>/gm); for (const match of [...matches]) { const commandId = match?.groups?.commandId; if (match?.length && commandId) { From cedd2f4286b5f2547c3a36cfa7899024997eee3c Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 18 Apr 2024 19:25:01 +0200 Subject: [PATCH 04/10] Fixes #204257. Refactors AccessibilitySignalService and renames LineFeatures to TextProperties. (#210679) * Fixes #204257. Refactors AccessibilitySignalService and renames LineFeatures to TextProperties. * Improves typings., --- src/vs/base/common/equals.ts | 83 +++++ .../standalone/browser/standaloneServices.ts | 12 +- .../browser/accessibilitySignalService.ts | 235 +++++++------- .../browser/accessibilityConfiguration.ts | 41 ++- .../accessibilitySignal.contribution.ts | 15 +- ...accessibilitySignalDebuggerContribution.ts | 1 - ...essibilitySignalLineFeatureContribution.ts | 273 ---------------- .../accessibilitySignals/browser/commands.ts | 4 +- .../editorTextPropertySignalsContribution.ts | 294 ++++++++++++++++++ .../reloadableWorkbenchContribution.ts | 46 +++ 10 files changed, 595 insertions(+), 409 deletions(-) delete mode 100644 src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignalLineFeatureContribution.ts create mode 100644 src/vs/workbench/contrib/accessibilitySignals/browser/editorTextPropertySignalsContribution.ts create mode 100644 src/vs/workbench/contrib/accessibilitySignals/browser/reloadableWorkbenchContribution.ts diff --git a/src/vs/base/common/equals.ts b/src/vs/base/common/equals.ts index 02f943d69647f..469ddc444c27e 100644 --- a/src/vs/base/common/equals.ts +++ b/src/vs/base/common/equals.ts @@ -36,3 +36,86 @@ export function equalsIfDefined(v1: T | undefined, v2: T | undefined, equals: } return equals(v1, v2); } + +/** + * Drills into arrays (items ordered) and objects (keys unordered) and uses strict equality on everything else. +*/ +export function structuralEquals(a: T, b: T): boolean { + if (a === b) { + return true; + } + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!structuralEquals(a[i], b[i])) { + return false; + } + } + return true; + } + + if (Object.getPrototypeOf(a) === Object.prototype && Object.getPrototypeOf(b) === Object.prototype) { + const aObj = a as Record; + const bObj = b as Record; + const keysA = Object.keys(aObj); + const keysB = Object.keys(bObj); + const keysBSet = new Set(keysB); + + if (keysA.length !== keysB.length) { + return false; + } + + for (const key of keysA) { + if (!keysBSet.has(key)) { + return false; + } + if (!structuralEquals(aObj[key], bObj[key])) { + return false; + } + } + + return true; + } + + return false; +} + +/** + * `getStructuralKey(a) === getStructuralKey(b) <=> structuralEquals(a, b)` + * (assuming that a and b are not cyclic structures and nothing extends globalThis Array). +*/ +export function getStructuralKey(t: unknown): string { + return JSON.stringify(toNormalizedJsonStructure(t)); +} + +let objectId = 0; +const objIds = new WeakMap(); + +function toNormalizedJsonStructure(t: unknown): unknown { + if (Array.isArray(t)) { + return t.map(toNormalizedJsonStructure); + } + + if (t && typeof t === 'object') { + if (Object.getPrototypeOf(t) === Object.prototype) { + const tObj = t as Record; + const res: Record = Object.create(null); + for (const key of Object.keys(tObj).sort()) { + res[key] = toNormalizedJsonStructure(tObj[key]); + } + return res; + } else { + let objId = objIds.get(t); + if (objId === undefined) { + objId = objectId++; + objIds.set(t, objId); + } + // Random string to prevent collisions + return objId + '----2b76a038c20c4bcc'; + } + } + return t; +} diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index 3365de7f3f4d4..cfbd136f5022b 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -15,7 +15,7 @@ import 'vs/editor/browser/services/hoverService/hoverService'; import * as strings from 'vs/base/common/strings'; import * as dom from 'vs/base/browser/dom'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; -import { Emitter, Event } from 'vs/base/common/event'; +import { Emitter, Event, IValueWithChangeEvent, ValueWithChangeEvent } from 'vs/base/common/event'; import { ResolvedKeybinding, KeyCodeChord, Keybinding, decodeKeybinding } from 'vs/base/common/keybindings'; import { IDisposable, IReference, ImmortalReference, toDisposable, DisposableStore, Disposable, combinedDisposable } from 'vs/base/common/lifecycle'; import { OS, isLinux, isMacintosh } from 'vs/base/common/platform'; @@ -88,7 +88,7 @@ import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; import { IStorageService, InMemoryStorageService } from 'vs/platform/storage/common/storage'; import { DefaultConfiguration } from 'vs/platform/configuration/common/configurations'; import { WorkspaceEdit } from 'vs/editor/common/languages'; -import { AccessibilitySignal, IAccessibilitySignalService, Sound } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; +import { AccessibilitySignal, AccessilityModality, IAccessibilitySignalService, Sound } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { LogService } from 'vs/platform/log/common/logService'; import { getEditorFeatures } from 'vs/editor/common/editorFeatures'; import { onUnexpectedError } from 'vs/base/common/errors'; @@ -1079,6 +1079,10 @@ class StandaloneAccessbilitySignalService implements IAccessibilitySignalService async playSignals(cues: AccessibilitySignal[]): Promise { } + getEnabledState(signal: AccessibilitySignal, userGesture: boolean, modality?: AccessilityModality | undefined): IValueWithChangeEvent { + return ValueWithChangeEvent.const(false); + } + isSoundEnabled(cue: AccessibilitySignal): boolean { return false; } @@ -1091,10 +1095,6 @@ class StandaloneAccessbilitySignalService implements IAccessibilitySignalService return Event.None; } - onAnnouncementEnabledChanged(cue: AccessibilitySignal): Event { - return Event.None; - } - async playSound(cue: Sound, allowManyInParallel?: boolean | undefined): Promise { } playSignalLoop(cue: AccessibilitySignal): IDisposable { diff --git a/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts b/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts index 4afdc13cce6ab..e1b92ad951e11 100644 --- a/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts +++ b/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts @@ -3,14 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CachedFunction } from 'vs/base/common/cache'; +import { getStructuralKey } from 'vs/base/common/equals'; +import { Event, IValueWithChangeEvent } from 'vs/base/common/event'; import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { FileAccess } from 'vs/base/common/network'; +import { derived, IObservable, observableFromEvent } from 'vs/base/common/observable'; +import { ValueWithChangeEventFromObservable } from 'vs/base/common/observableInternal/utils'; +import { localize } from 'vs/nls'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; -import { Event } from 'vs/base/common/event'; -import { localize } from 'vs/nls'; -import { observableFromEvent, derived } from 'vs/base/common/observable'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; export const IAccessibilitySignalService = createDecorator('accessibilitySignalService'); @@ -19,18 +22,34 @@ export interface IAccessibilitySignalService { readonly _serviceBrand: undefined; playSignal(signal: AccessibilitySignal, options?: IAccessbilitySignalOptions): Promise; playSignals(signals: (AccessibilitySignal | { signal: AccessibilitySignal; source: string })[]): Promise; + playSignalLoop(signal: AccessibilitySignal, milliseconds: number): IDisposable; + + getEnabledState(signal: AccessibilitySignal, userGesture: boolean, modality?: AccessilityModality | undefined): IValueWithChangeEvent; + + /** + * Avoid this method and prefer `.playSignal`! + * Only use it when you want to play the sound regardless of enablement, e.g. in the settings quick pick. + */ + playSound(signal: Sound, allowManyInParallel: boolean, token: typeof AcknowledgeDocCommentsToken): Promise; + + /** @deprecated Use getEnabledState(...).onChange */ isSoundEnabled(signal: AccessibilitySignal): boolean; + /** @deprecated Use getEnabledState(...).value */ isAnnouncementEnabled(signal: AccessibilitySignal): boolean; + /** @deprecated Use getEnabledState(...).onChange */ onSoundEnabledChanged(signal: AccessibilitySignal): Event; - onAnnouncementEnabledChanged(signal: AccessibilitySignal): Event; - - playSound(signal: Sound, allowManyInParallel?: boolean): Promise; - playSignalLoop(signal: AccessibilitySignal, milliseconds: number): IDisposable; } +/** Make sure you understand the doc comments of the method you want to call when using this token! */ +export const AcknowledgeDocCommentsToken = Symbol('AcknowledgeDocCommentsToken'); + +export type AccessilityModality = 'sound' | 'announcement'; + export interface IAccessbilitySignalOptions { allowManyInParallel?: boolean; + modality?: AccessilityModality; + /** * The source that triggered the signal (e.g. "diffEditor.cursorPositionChanged"). */ @@ -61,13 +80,19 @@ export class AccessibilitySignalService extends Disposable implements IAccessibi super(); } + public getEnabledState(signal: AccessibilitySignal, userGesture: boolean, modality?: AccessilityModality | undefined): IValueWithChangeEvent { + return new ValueWithChangeEventFromObservable(this._signalEnabledState.get({ signal, userGesture, modality })); + } + public async playSignal(signal: AccessibilitySignal, options: IAccessbilitySignalOptions = {}): Promise { + const shouldPlayAnnouncement = options.modality === 'announcement' || options.modality === undefined; const announcementMessage = signal.announcementMessage; - if (this.isAnnouncementEnabled(signal, options.userGesture) && announcementMessage) { + if (shouldPlayAnnouncement && this.isAnnouncementEnabled(signal, options.userGesture) && announcementMessage) { this.accessibilityService.status(announcementMessage); } - if (this.isSoundEnabled(signal, options.userGesture)) { + const shouldPlaySound = options.modality === 'sound' || options.modality === undefined; + if (shouldPlaySound && this.isSoundEnabled(signal, options.userGesture)) { this.sendSignalTelemetry(signal, options.source); await this.playSound(signal.sound.getSound(), options.allowManyInParallel); } @@ -173,83 +198,50 @@ export class AccessibilitySignalService extends Disposable implements IAccessibi return toDisposable(() => playing = false); } - private readonly obsoleteAccessibilitySignalsEnabled = observableFromEvent( - Event.filter(this.configurationService.onDidChangeConfiguration, (e) => - e.affectsConfiguration('accessibilitySignals.enabled') - ), - () => /** @description config: accessibilitySignals.enabled */ this.configurationService.getValue<'on' | 'off' | 'auto' | 'userGesture' | 'always' | 'never'>('accessibilitySignals.enabled') - ); + private readonly _signalConfigValue = new CachedFunction((signal: AccessibilitySignal) => observableConfigValue<{ + sound: 'on' | 'off' | 'auto' | 'userGesture' | 'always' | 'never'; + announcement: 'auto' | 'off' | 'userGesture' | 'always' | 'never'; + }>(signal.settingsKey, this.configurationService)); - private readonly isSoundEnabledCache = new Cache((event: { readonly signal: AccessibilitySignal; readonly userGesture?: boolean }) => { - const settingObservable = observableFromEvent( - Event.filter(this.configurationService.onDidChangeConfiguration, (e) => - e.affectsConfiguration(event.signal.legacySoundSettingsKey) || e.affectsConfiguration(event.signal.settingsKey) - ), - () => this.configurationService.getValue<'on' | 'off' | 'auto' | 'userGesture' | 'always' | 'never'>(event.signal.settingsKey + '.sound') - ); + private readonly _signalEnabledState = new CachedFunction({ getCacheKey: getStructuralKey }, (arg: { signal: AccessibilitySignal; userGesture: boolean; modality?: AccessilityModality | undefined }) => { return derived(reader => { /** @description sound enabled */ - const setting = settingObservable.read(reader); - if ( - setting === 'on' || - (setting === 'auto' && this.screenReaderAttached.read(reader)) - ) { - return true; - } else if (setting === 'always' || setting === 'userGesture' && event.userGesture) { - return true; - } + const setting = this._signalConfigValue.get(arg.signal).read(reader); - const obsoleteSetting = this.obsoleteAccessibilitySignalsEnabled.read(reader); - if ( - obsoleteSetting === 'on' || - (obsoleteSetting === 'auto' && this.screenReaderAttached.read(reader)) - ) { - return true; + if (arg.modality === 'sound' || arg.modality === undefined) { + if (!checkEnabledState(setting.sound, () => this.screenReaderAttached.read(reader), arg.userGesture)) { + return false; + } } - - return false; - }); - }, JSON.stringify); - - private readonly isAnnouncementEnabledCache = new Cache((event: { readonly signal: AccessibilitySignal; readonly userGesture?: boolean }) => { - const settingObservable = observableFromEvent( - Event.filter(this.configurationService.onDidChangeConfiguration, (e) => - e.affectsConfiguration(event.signal.legacyAnnouncementSettingsKey!) || e.affectsConfiguration(event.signal.settingsKey) - ), - () => event.signal.announcementMessage ? this.configurationService.getValue<'auto' | 'off' | 'userGesture' | 'always' | 'never'>(event.signal.settingsKey + '.announcement') : false - ); - return derived(reader => { - /** @description announcement enabled */ - const setting = settingObservable.read(reader); - if ( - !this.screenReaderAttached.read(reader) - ) { - return false; + if (arg.modality === 'announcement' || arg.modality === undefined) { + if (!checkEnabledState(setting.announcement, () => this.screenReaderAttached.read(reader), arg.userGesture)) { + return false; + } } - return setting === 'auto' || setting === 'always' || setting === 'userGesture' && event.userGesture; + return true; }); - }, JSON.stringify); + }); public isAnnouncementEnabled(signal: AccessibilitySignal, userGesture?: boolean): boolean { if (!signal.announcementMessage) { return false; } - return this.isAnnouncementEnabledCache.get({ signal, userGesture }).get() ?? false; + return this._signalEnabledState.get({ signal, userGesture: !!userGesture, modality: 'announcement' }).get(); } public isSoundEnabled(signal: AccessibilitySignal, userGesture?: boolean): boolean { - return this.isSoundEnabledCache.get({ signal, userGesture }).get() ?? false; + return this._signalEnabledState.get({ signal, userGesture: !!userGesture, modality: 'sound' }).get(); } public onSoundEnabledChanged(signal: AccessibilitySignal): Event { - return Event.fromObservableLight(this.isSoundEnabledCache.get({ signal })); - } - - public onAnnouncementEnabledChanged(signal: AccessibilitySignal): Event { - return Event.fromObservableLight(this.isAnnouncementEnabledCache.get({ signal })); + return this.getEnabledState(signal, false).onDidChange; } } +type EnabledState = 'on' | 'off' | 'auto' | 'userGesture' | 'always' | 'never'; +function checkEnabledState(state: EnabledState, getScreenReaderAttached: () => boolean, isTriggeredByUserGesture: boolean): boolean { + return state === 'on' || state === 'always' || (state === 'auto' && getScreenReaderAttached()) || state === 'userGesture' && isTriggeredByUserGesture; +} /** * Play the given audio url. @@ -273,23 +265,6 @@ function playAudio(url: string, volume: number): Promise { }); } -class Cache { - private readonly map = new Map(); - constructor(private readonly getValue: (value: TArg) => TValue, private readonly getKey: (value: TArg) => unknown) { - } - - public get(arg: TArg): TValue { - if (this.map.has(arg)) { - return this.map.get(arg)!; - } - - const value = this.getValue(arg); - const key = this.getKey(arg); - this.map.set(key, value); - return value; - } -} - /** * Corresponds to the audio files in ./media. */ @@ -364,6 +339,15 @@ export const enum AccessibilityAlertSettingId { export class AccessibilitySignal { + private constructor( + public readonly sound: SoundSource, + public readonly name: string, + public readonly legacySoundSettingsKey: string | undefined, + public readonly settingsKey: string, + public readonly legacyAnnouncementSettingsKey: string | undefined, + public readonly announcementMessage: string | undefined, + ) { } + private static _signals = new Set(); private static register(options: { name: string; @@ -374,13 +358,20 @@ export class AccessibilitySignal { */ randomOneOf: Sound[]; }; - legacySoundSettingsKey: string; + legacySoundSettingsKey?: string; settingsKey: string; legacyAnnouncementSettingsKey?: AccessibilityAlertSettingId; announcementMessage?: string; }): AccessibilitySignal { const soundSource = new SoundSource('randomOneOf' in options.sound ? options.sound.randomOneOf : [options.sound]); - const signal = new AccessibilitySignal(soundSource, options.name, options.legacySoundSettingsKey, options.settingsKey, options.legacyAnnouncementSettingsKey, options.announcementMessage); + const signal = new AccessibilitySignal( + soundSource, + options.name, + options.legacySoundSettingsKey, + options.settingsKey, + options.legacyAnnouncementSettingsKey, + options.announcementMessage, + ); AccessibilitySignal._signals.add(signal); return signal; } @@ -389,21 +380,35 @@ export class AccessibilitySignal { return [...this._signals]; } - public static readonly error = AccessibilitySignal.register({ + public static readonly errorAtPosition = AccessibilitySignal.register({ + name: localize('accessibilitySignals.positionHasError.name', 'Error at Position'), + sound: Sound.error, + announcementMessage: localize('accessibility.signals.positionHasError', 'Error'), + settingsKey: 'accessibility.signals.positionHasError', + }); + public static readonly warningAtPosition = AccessibilitySignal.register({ + name: localize('accessibilitySignals.positionHasWarning.name', 'Warning at Position'), + sound: Sound.warning, + announcementMessage: localize('accessibility.signals.positionHasWarning', 'Warning'), + settingsKey: 'accessibility.signals.positionHasWarning', + }); + + public static readonly errorOnLine = AccessibilitySignal.register({ name: localize('accessibilitySignals.lineHasError.name', 'Error on Line'), sound: Sound.error, legacySoundSettingsKey: 'audioCues.lineHasError', legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.Error, - announcementMessage: localize('accessibility.signals.lineHasError', 'Error'), - settingsKey: 'accessibility.signals.lineHasError' + announcementMessage: localize('accessibility.signals.lineHasError', 'Error on Line'), + settingsKey: 'accessibility.signals.lineHasError', }); - public static readonly warning = AccessibilitySignal.register({ + + public static readonly warningOnLine = AccessibilitySignal.register({ name: localize('accessibilitySignals.lineHasWarning.name', 'Warning on Line'), sound: Sound.warning, legacySoundSettingsKey: 'audioCues.lineHasWarning', legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.Warning, - announcementMessage: localize('accessibility.signals.lineHasWarning', 'Warning'), - settingsKey: 'accessibility.signals.lineHasWarning' + announcementMessage: localize('accessibility.signals.lineHasWarning', 'Warning on Line'), + settingsKey: 'accessibility.signals.lineHasWarning', }); public static readonly foldedArea = AccessibilitySignal.register({ name: localize('accessibilitySignals.lineHasFoldedArea.name', 'Folded Area on Line'), @@ -411,7 +416,7 @@ export class AccessibilitySignal { legacySoundSettingsKey: 'audioCues.lineHasFoldedArea', legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.FoldedArea, announcementMessage: localize('accessibility.signals.lineHasFoldedArea', 'Folded'), - settingsKey: 'accessibility.signals.lineHasFoldedArea' + settingsKey: 'accessibility.signals.lineHasFoldedArea', }); public static readonly break = AccessibilitySignal.register({ name: localize('accessibilitySignals.lineHasBreakpoint.name', 'Breakpoint on Line'), @@ -419,13 +424,13 @@ export class AccessibilitySignal { legacySoundSettingsKey: 'audioCues.lineHasBreakpoint', legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.Breakpoint, announcementMessage: localize('accessibility.signals.lineHasBreakpoint', 'Breakpoint'), - settingsKey: 'accessibility.signals.lineHasBreakpoint' + settingsKey: 'accessibility.signals.lineHasBreakpoint', }); public static readonly inlineSuggestion = AccessibilitySignal.register({ name: localize('accessibilitySignals.lineHasInlineSuggestion.name', 'Inline Suggestion on Line'), sound: Sound.quickFixes, legacySoundSettingsKey: 'audioCues.lineHasInlineSuggestion', - settingsKey: 'accessibility.signals.lineHasInlineSuggestion' + settingsKey: 'accessibility.signals.lineHasInlineSuggestion', }); public static readonly terminalQuickFix = AccessibilitySignal.register({ @@ -434,7 +439,7 @@ export class AccessibilitySignal { legacySoundSettingsKey: 'audioCues.terminalQuickFix', legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.TerminalQuickFix, announcementMessage: localize('accessibility.signals.terminalQuickFix', 'Quick Fix'), - settingsKey: 'accessibility.signals.terminalQuickFix' + settingsKey: 'accessibility.signals.terminalQuickFix', }); public static readonly onDebugBreak = AccessibilitySignal.register({ @@ -443,7 +448,7 @@ export class AccessibilitySignal { legacySoundSettingsKey: 'audioCues.onDebugBreak', legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.OnDebugBreak, announcementMessage: localize('accessibility.signals.onDebugBreak', 'Breakpoint'), - settingsKey: 'accessibility.signals.onDebugBreak' + settingsKey: 'accessibility.signals.onDebugBreak', }); public static readonly noInlayHints = AccessibilitySignal.register({ @@ -452,7 +457,7 @@ export class AccessibilitySignal { legacySoundSettingsKey: 'audioCues.noInlayHints', legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.NoInlayHints, announcementMessage: localize('accessibility.signals.noInlayHints', 'No Inlay Hints'), - settingsKey: 'accessibility.signals.noInlayHints' + settingsKey: 'accessibility.signals.noInlayHints', }); public static readonly taskCompleted = AccessibilitySignal.register({ @@ -461,7 +466,7 @@ export class AccessibilitySignal { legacySoundSettingsKey: 'audioCues.taskCompleted', legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.TaskCompleted, announcementMessage: localize('accessibility.signals.taskCompleted', 'Task Completed'), - settingsKey: 'accessibility.signals.taskCompleted' + settingsKey: 'accessibility.signals.taskCompleted', }); public static readonly taskFailed = AccessibilitySignal.register({ @@ -470,7 +475,7 @@ export class AccessibilitySignal { legacySoundSettingsKey: 'audioCues.taskFailed', legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.TaskFailed, announcementMessage: localize('accessibility.signals.taskFailed', 'Task Failed'), - settingsKey: 'accessibility.signals.taskFailed' + settingsKey: 'accessibility.signals.taskFailed', }); public static readonly terminalCommandFailed = AccessibilitySignal.register({ @@ -479,7 +484,7 @@ export class AccessibilitySignal { legacySoundSettingsKey: 'audioCues.terminalCommandFailed', legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.TerminalCommandFailed, announcementMessage: localize('accessibility.signals.terminalCommandFailed', 'Command Failed'), - settingsKey: 'accessibility.signals.terminalCommandFailed' + settingsKey: 'accessibility.signals.terminalCommandFailed', }); public static readonly terminalBell = AccessibilitySignal.register({ @@ -488,7 +493,7 @@ export class AccessibilitySignal { legacySoundSettingsKey: 'audioCues.terminalBell', legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.TerminalBell, announcementMessage: localize('accessibility.signals.terminalBell', 'Terminal Bell'), - settingsKey: 'accessibility.signals.terminalBell' + settingsKey: 'accessibility.signals.terminalBell', }); public static readonly notebookCellCompleted = AccessibilitySignal.register({ @@ -497,7 +502,7 @@ export class AccessibilitySignal { legacySoundSettingsKey: 'audioCues.notebookCellCompleted', legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.NotebookCellCompleted, announcementMessage: localize('accessibility.signals.notebookCellCompleted', 'Notebook Cell Completed'), - settingsKey: 'accessibility.signals.notebookCellCompleted' + settingsKey: 'accessibility.signals.notebookCellCompleted', }); public static readonly notebookCellFailed = AccessibilitySignal.register({ @@ -506,28 +511,28 @@ export class AccessibilitySignal { legacySoundSettingsKey: 'audioCues.notebookCellFailed', legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.NotebookCellFailed, announcementMessage: localize('accessibility.signals.notebookCellFailed', 'Notebook Cell Failed'), - settingsKey: 'accessibility.signals.notebookCellFailed' + settingsKey: 'accessibility.signals.notebookCellFailed', }); public static readonly diffLineInserted = AccessibilitySignal.register({ name: localize('accessibilitySignals.diffLineInserted', 'Diff Line Inserted'), sound: Sound.diffLineInserted, legacySoundSettingsKey: 'audioCues.diffLineInserted', - settingsKey: 'accessibility.signals.diffLineInserted' + settingsKey: 'accessibility.signals.diffLineInserted', }); public static readonly diffLineDeleted = AccessibilitySignal.register({ name: localize('accessibilitySignals.diffLineDeleted', 'Diff Line Deleted'), sound: Sound.diffLineDeleted, legacySoundSettingsKey: 'audioCues.diffLineDeleted', - settingsKey: 'accessibility.signals.diffLineDeleted' + settingsKey: 'accessibility.signals.diffLineDeleted', }); public static readonly diffLineModified = AccessibilitySignal.register({ name: localize('accessibilitySignals.diffLineModified', 'Diff Line Modified'), sound: Sound.diffLineModified, legacySoundSettingsKey: 'audioCues.diffLineModified', - settingsKey: 'accessibility.signals.diffLineModified' + settingsKey: 'accessibility.signals.diffLineModified', }); public static readonly chatRequestSent = AccessibilitySignal.register({ @@ -536,7 +541,7 @@ export class AccessibilitySignal { legacySoundSettingsKey: 'audioCues.chatRequestSent', legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.ChatRequestSent, announcementMessage: localize('accessibility.signals.chatRequestSent', 'Chat Request Sent'), - settingsKey: 'accessibility.signals.chatRequestSent' + settingsKey: 'accessibility.signals.chatRequestSent', }); public static readonly chatResponseReceived = AccessibilitySignal.register({ @@ -602,13 +607,15 @@ export class AccessibilitySignal { legacySoundSettingsKey: 'audioCues.voiceRecordingStopped', settingsKey: 'accessibility.signals.voiceRecordingStopped' }); +} - private constructor( - public readonly sound: SoundSource, - public readonly name: string, - public readonly legacySoundSettingsKey: string, - public readonly settingsKey: string, - public readonly legacyAnnouncementSettingsKey?: string, - public readonly announcementMessage?: string, - ) { } +export function observableConfigValue(key: string, configurationService: IConfigurationService): IObservable { + return observableFromEvent( + (handleChange) => configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(key)) { + handleChange(e); + } + }), + () => configurationService.getValue(key), + ); } diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts index 7c681d5bf7743..478dc21bf4ead 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts @@ -7,12 +7,13 @@ import { localize } from 'vs/nls'; import { ConfigurationScope, Extensions, IConfigurationNode, IConfigurationPropertySchema, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry'; import { Registry } from 'vs/platform/registry/common/platform'; import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; -import { workbenchConfigurationNodeBase, Extensions as WorkbenchExtensions, IConfigurationMigrationRegistry, ConfigurationKeyValuePairs } from 'vs/workbench/common/configuration'; +import { workbenchConfigurationNodeBase, Extensions as WorkbenchExtensions, IConfigurationMigrationRegistry, ConfigurationKeyValuePairs, ConfigurationMigration } from 'vs/workbench/common/configuration'; import { AccessibilityAlertSettingId, AccessibilitySignal } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { ISpeechService, SPEECH_LANGUAGES, SPEECH_LANGUAGE_CONFIG } from 'vs/workbench/contrib/speech/common/speechService'; import { Disposable } from 'vs/base/common/lifecycle'; import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; import { Event } from 'vs/base/common/event'; +import { isDefined } from 'vs/base/common/types'; export const accessibilityHelpIsShown = new RawContextKey('accessibilityHelpIsShown', false, true); export const accessibleViewIsShown = new RawContextKey('accessibleViewIsShown', false, true); @@ -370,6 +371,36 @@ const configuration: IConfigurationNode = { }, }, }, + 'accessibility.signals.positionHasError': { + ...signalFeatureBase, + 'description': localize('accessibility.signals.positionHasError', "Plays a signal when the active line has a warning."), + 'properties': { + 'sound': { + 'description': localize('accessibility.signals.positionHasError.sound', "Plays a sound when the active line has a warning."), + ...soundFeatureBase + }, + 'announcement': { + 'description': localize('accessibility.signals.positionHasError.announcement', "Indicates when the active line has a warning."), + ...announcementFeatureBase, + default: 'on' + }, + }, + }, + 'accessibility.signals.positionHasWarning': { + ...signalFeatureBase, + 'description': localize('accessibility.signals.positionHasWarning', "Plays a signal when the active line has a warning."), + 'properties': { + 'sound': { + 'description': localize('accessibility.signals.positionHasWarning.sound', "Plays a sound when the active line has a warning."), + ...soundFeatureBase + }, + 'announcement': { + 'description': localize('accessibility.signals.positionHasWarning.announcement', "Indicates when the active line has a warning."), + ...announcementFeatureBase, + default: 'on' + }, + }, + }, 'accessibility.signals.onDebugBreak': { ...signalFeatureBase, 'description': localize('accessibility.signals.onDebugBreak', "Plays a signal when the debugger stopped on a breakpoint."), @@ -803,7 +834,7 @@ Registry.as(WorkbenchExtensions.ConfigurationMi }]); Registry.as(WorkbenchExtensions.ConfigurationMigration) - .registerConfigurationMigrations(AccessibilitySignal.allAccessibilitySignals.map(item => ({ + .registerConfigurationMigrations(AccessibilitySignal.allAccessibilitySignals.map(item => item.legacySoundSettingsKey ? ({ key: item.legacySoundSettingsKey, migrateFn: (sound, accessor) => { const configurationKeyValuePairs: ConfigurationKeyValuePairs = []; @@ -819,14 +850,14 @@ Registry.as(WorkbenchExtensions.ConfigurationMi configurationKeyValuePairs.push([`${item.settingsKey}`, { value: announcement !== undefined ? { announcement, sound } : { sound } }]); return configurationKeyValuePairs; } - }))); + }) : undefined).filter(isDefined)); Registry.as(WorkbenchExtensions.ConfigurationMigration) - .registerConfigurationMigrations(AccessibilitySignal.allAccessibilitySignals.filter(i => !!i.legacyAnnouncementSettingsKey).map(item => ({ + .registerConfigurationMigrations(AccessibilitySignal.allAccessibilitySignals.filter(i => !!i.legacyAnnouncementSettingsKey && !!i.legacySoundSettingsKey).map(item => ({ key: item.legacyAnnouncementSettingsKey!, migrateFn: (announcement, accessor) => { const configurationKeyValuePairs: ConfigurationKeyValuePairs = []; - const sound = accessor(item.settingsKey)?.sound || accessor(item.legacySoundSettingsKey); + const sound = accessor(item.settingsKey)?.sound || accessor(item.legacySoundSettingsKey!); if (announcement !== undefined && typeof announcement !== 'string') { announcement = announcement ? 'auto' : 'off'; } diff --git a/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignal.contribution.ts b/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignal.contribution.ts index f0ce38c970e7b..cb298c79733ba 100644 --- a/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignal.contribution.ts +++ b/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignal.contribution.ts @@ -3,20 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ShowAccessibilityAnnouncementHelp, ShowSignalSoundHelp } from 'vs/workbench/contrib/accessibilitySignals/browser/commands'; +import { AccessibilitySignalService, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { registerAction2 } from 'vs/platform/actions/common/actions'; import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; -import { Registry } from 'vs/platform/registry/common/platform'; -import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions'; -import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; -import { IAccessibilitySignalService, AccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; +import { registerWorkbenchContribution2, WorkbenchPhase } from 'vs/workbench/common/contributions'; import { AccessibilitySignalLineDebuggerContribution } from 'vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignalDebuggerContribution'; -import { SignalLineFeatureContribution } from 'vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignalLineFeatureContribution'; +import { ShowAccessibilityAnnouncementHelp, ShowSignalSoundHelp } from 'vs/workbench/contrib/accessibilitySignals/browser/commands'; +import { EditorTextPropertySignalsContribution } from 'vs/workbench/contrib/accessibilitySignals/browser/editorTextPropertySignalsContribution'; +import { wrapInReloadableClass } from 'vs/workbench/contrib/accessibilitySignals/browser/reloadableWorkbenchContribution'; registerSingleton(IAccessibilitySignalService, AccessibilitySignalService, InstantiationType.Delayed); -Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(SignalLineFeatureContribution, LifecyclePhase.Restored); -Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(AccessibilitySignalLineDebuggerContribution, LifecyclePhase.Restored); +registerWorkbenchContribution2('EditorTextPropertySignalsContribution', wrapInReloadableClass(() => EditorTextPropertySignalsContribution), WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2('AccessibilitySignalLineDebuggerContribution', AccessibilitySignalLineDebuggerContribution, WorkbenchPhase.AfterRestored); registerAction2(ShowSignalSoundHelp); registerAction2(ShowAccessibilityAnnouncementHelp); diff --git a/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignalDebuggerContribution.ts b/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignalDebuggerContribution.ts index 45d5f2b312120..336832eb16cf3 100644 --- a/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignalDebuggerContribution.ts +++ b/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignalDebuggerContribution.ts @@ -63,6 +63,5 @@ export class AccessibilitySignalLineDebuggerContribution this.accessibilitySignalService.playSignal(AccessibilitySignal.onDebugBreak); } }); - } } diff --git a/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignalLineFeatureContribution.ts b/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignalLineFeatureContribution.ts deleted file mode 100644 index 7d46797c74984..0000000000000 --- a/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignalLineFeatureContribution.ts +++ /dev/null @@ -1,273 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CachedFunction } from 'vs/base/common/cache'; -import { Event } from 'vs/base/common/event'; -import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; -import { IObservable, IReader, autorun, autorunDelta, derived, derivedOpts, observableFromEvent, observableFromPromise, wasEventTriggeredRecently } from 'vs/base/common/observable'; -import { debouncedObservable2, observableSignalFromEvent } from 'vs/base/common/observableInternal/utils'; -import { ICodeEditor, isCodeEditor, isDiffEditor } from 'vs/editor/browser/editorBrowser'; -import { Position } from 'vs/editor/common/core/position'; -import { CursorChangeReason } from 'vs/editor/common/cursorEvents'; -import { ITextModel } from 'vs/editor/common/model'; -import { FoldingController } from 'vs/editor/contrib/folding/browser/folding'; -import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { IMarkerService, MarkerSeverity } from 'vs/platform/markers/common/markers'; -import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; -import { IDebugService } from 'vs/workbench/contrib/debug/common/debug'; -import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; - -export class SignalLineFeatureContribution - extends Disposable - implements IWorkbenchContribution { - private readonly store = this._register(new DisposableStore()); - private _previousLineNumber: number | undefined = undefined; - - private readonly features: LineFeature[] = [ - this.instantiationService.createInstance(MarkerLineFeature, AccessibilitySignal.error, MarkerSeverity.Error), - this.instantiationService.createInstance(MarkerLineFeature, AccessibilitySignal.warning, MarkerSeverity.Warning), - this.instantiationService.createInstance(FoldedAreaLineFeature), - this.instantiationService.createInstance(BreakpointLineFeature), - ]; - - private readonly isEnabledCache = new CachedFunction>((cue) => observableFromEvent( - Event.any( - this.accessibilitySignalService.onSoundEnabledChanged(cue), - this.accessibilitySignalService.onAnnouncementEnabledChanged(cue), - ), - () => this.accessibilitySignalService.isSoundEnabled(cue) || this.accessibilitySignalService.isAnnouncementEnabled(cue) - )); - - private readonly _someAccessibilitySignalIsEnabled = derived(this, - (reader) => this.features.some((feature) => - this.isEnabledCache.get(feature.signal).read(reader) - ) - ); - - private readonly _activeEditorObservable = observableFromEvent( - this.editorService.onDidActiveEditorChange, - (_) => { - const activeTextEditorControl = - this.editorService.activeTextEditorControl; - - const editor = isDiffEditor(activeTextEditorControl) - ? activeTextEditorControl.getOriginalEditor() - : isCodeEditor(activeTextEditorControl) - ? activeTextEditorControl - : undefined; - - return editor && editor.hasModel() ? { editor, model: editor.getModel() } : undefined; - } - ); - - constructor( - @IEditorService private readonly editorService: IEditorService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IAccessibilitySignalService private readonly accessibilitySignalService: IAccessibilitySignalService, - @IConfigurationService private readonly _configurationService: IConfigurationService - ) { - super(); - - - this._register( - autorun(reader => { - /** @description updateSignalsEnabled */ - this.store.clear(); - - if (!this._someAccessibilitySignalIsEnabled.read(reader)) { - return; - } - const activeEditor = this._activeEditorObservable.read(reader); - if (activeEditor) { - this.registerAccessibilitySignalsForEditor(activeEditor.editor, activeEditor.model, this.store); - } - }) - ); - } - - private registerAccessibilitySignalsForEditor( - editor: ICodeEditor, - editorModel: ITextModel, - store: DisposableStore - ): void { - const curPosition = observableFromEvent( - editor.onDidChangeCursorPosition, - (args) => { - /** @description editor.onDidChangeCursorPosition (caused by user) */ - if ( - args && - args.reason !== CursorChangeReason.Explicit && - args.reason !== CursorChangeReason.NotSet - ) { - // Ignore cursor changes caused by navigation (e.g. which happens when execution is paused). - return undefined; - } - return editor.getPosition(); - } - ); - const debouncedPosition = debouncedObservable2(curPosition, this._configurationService.getValue('accessibility.signals.debouncePositionChanges') ? 300 : 0); - const isTyping = wasEventTriggeredRecently( - e => editorModel.onDidChangeContent(e), - 1000, - store - ); - - const featureStates = this.features.map((feature) => { - const lineFeatureState = feature.createSource(editor, editorModel); - const isFeaturePresent = derivedOpts( - { debugName: `isPresentInLine:${feature.signal.name}` }, - (reader) => { - if (!this.isEnabledCache.get(feature.signal).read(reader)) { - return false; - } - const position = debouncedPosition.read(reader); - if (!position) { - return false; - } - const lineChanged = this._previousLineNumber !== position.lineNumber; - const isPresent = lineFeatureState.isPresent?.(position, reader) || (lineChanged && lineFeatureState.isPresentOnLine(position.lineNumber, reader)); - this._previousLineNumber = position.lineNumber; - return isPresent; - } - ); - return derivedOpts( - { debugName: `typingDebouncedFeatureState:\n${feature.signal.name}` }, - (reader) => - feature.debounceWhileTyping && isTyping.read(reader) - ? (debouncedPosition.read(reader), isFeaturePresent.get()) - : isFeaturePresent.read(reader) - ); - }); - - const state = derived( - (reader) => /** @description states */({ - lineNumber: debouncedPosition.read(reader), - featureStates: new Map( - this.features.map((feature, idx) => [ - feature, - featureStates[idx].read(reader), - ]) - ), - }) - ); - - store.add( - autorunDelta(state, ({ lastValue, newValue }) => { - /** @description Play Accessibility Signal */ - const newFeatures = this.features.filter( - feature => - newValue?.featureStates.get(feature) && - (!lastValue?.featureStates?.get(feature) || newValue.lineNumber !== lastValue.lineNumber) - ); - - this.accessibilitySignalService.playSignals(newFeatures.map(f => f.signal)); - }) - ); - } -} - -interface LineFeature { - readonly signal: AccessibilitySignal; - readonly debounceWhileTyping?: boolean; - createSource( - editor: ICodeEditor, - model: ITextModel - ): LineFeatureSource; -} - - -interface LineFeatureSource { - isPresentOnLine(lineNumber: number, reader: IReader): boolean; - isPresent?(position: Position, reader: IReader): boolean; -} - -class MarkerLineFeature implements LineFeature { - public readonly debounceWhileTyping = true; - constructor( - public readonly signal: AccessibilitySignal, - private readonly severity: MarkerSeverity, - @IMarkerService private readonly markerService: IMarkerService, - - ) { } - - createSource(editor: ICodeEditor, model: ITextModel): LineFeatureSource { - const obs = observableSignalFromEvent('onMarkerChanged', this.markerService.onMarkerChanged); - return { - isPresent: (position, reader) => { - obs.read(reader); - const hasMarker = this.markerService - .read({ resource: model.uri }) - .some( - (m) => - m.severity === this.severity && - m.startLineNumber <= position.lineNumber && - position.lineNumber <= m.endLineNumber && - m.startColumn <= position.column && - position.column <= m.endColumn - ); - return hasMarker; - }, - isPresentOnLine: (lineNumber, reader) => { - obs.read(reader); - const hasMarker = this.markerService - .read({ resource: model.uri }) - .some( - (m) => - m.severity === this.severity && - m.startLineNumber <= lineNumber && - lineNumber <= m.endLineNumber - ); - return hasMarker; - } - }; - } -} - -class FoldedAreaLineFeature implements LineFeature { - public readonly signal = AccessibilitySignal.foldedArea; - - createSource(editor: ICodeEditor, _model: ITextModel): LineFeatureSource { - const foldingController = FoldingController.get(editor); - if (!foldingController) { - return { isPresentOnLine: () => false }; - } - - const foldingModel = observableFromPromise(foldingController.getFoldingModel() ?? Promise.resolve(undefined)); - return { - isPresentOnLine(lineNumber: number, reader: IReader): boolean { - const m = foldingModel.read(reader); - const regionAtLine = m.value?.getRegionAtLine(lineNumber); - const hasFolding = !regionAtLine - ? false - : regionAtLine.isCollapsed && - regionAtLine.startLineNumber === lineNumber; - return hasFolding; - } - }; - } -} - -class BreakpointLineFeature implements LineFeature { - public readonly signal = AccessibilitySignal.break; - - constructor(@IDebugService private readonly debugService: IDebugService) { } - - createSource(editor: ICodeEditor, model: ITextModel): LineFeatureSource { - const signal = observableSignalFromEvent('onDidChangeBreakpoints', this.debugService.getModel().onDidChangeBreakpoints); - const debugService = this.debugService; - return { - isPresentOnLine(lineNumber: number, reader: IReader): boolean { - signal.read(reader); - const breakpoints = debugService - .getModel() - .getBreakpoints({ uri: model.uri, lineNumber }); - const hasBreakpoints = breakpoints.length > 0; - return hasBreakpoints; - } - }; - } -} diff --git a/src/vs/workbench/contrib/accessibilitySignals/browser/commands.ts b/src/vs/workbench/contrib/accessibilitySignals/browser/commands.ts index 23dc8cf04498d..a20ca9f9dc9d8 100644 --- a/src/vs/workbench/contrib/accessibilitySignals/browser/commands.ts +++ b/src/vs/workbench/contrib/accessibilitySignals/browser/commands.ts @@ -8,7 +8,7 @@ import { ThemeIcon } from 'vs/base/common/themables'; import { localize, localize2 } from 'vs/nls'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; import { Action2 } from 'vs/platform/actions/common/actions'; -import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; +import { AccessibilitySignal, AcknowledgeDocCommentsToken, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; @@ -72,7 +72,7 @@ export class ShowSignalSoundHelp extends Action2 { preferencesService.openUserSettings({ jsonEditor: true, revealSetting: { key: e.item.signal.settingsKey, edit: true } }); }); qp.onDidChangeActive(() => { - accessibilitySignalService.playSound(qp.activeItems[0].signal.sound.getSound(true), true); + accessibilitySignalService.playSound(qp.activeItems[0].signal.sound.getSound(true), true, AcknowledgeDocCommentsToken); }); qp.placeholder = localize('sounds.help.placeholder', 'Select a sound to play and configure'); qp.canSelectMany = true; diff --git a/src/vs/workbench/contrib/accessibilitySignals/browser/editorTextPropertySignalsContribution.ts b/src/vs/workbench/contrib/accessibilitySignals/browser/editorTextPropertySignalsContribution.ts new file mode 100644 index 0000000000000..7c58e2211450f --- /dev/null +++ b/src/vs/workbench/contrib/accessibilitySignals/browser/editorTextPropertySignalsContribution.ts @@ -0,0 +1,294 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { disposableTimeout } from 'vs/base/common/async'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { IReader, autorun, autorunWithStore, derived, observableFromEvent, observableFromPromise } from 'vs/base/common/observable'; +import { observableFromValueWithChangeEvent, observableSignalFromEvent, wasEventTriggeredRecently } from 'vs/base/common/observableInternal/utils'; +import { isDefined } from 'vs/base/common/types'; +import { ICodeEditor, isCodeEditor, isDiffEditor } from 'vs/editor/browser/editorBrowser'; +import { Position } from 'vs/editor/common/core/position'; +import { CursorChangeReason } from 'vs/editor/common/cursorEvents'; +import { ITextModel } from 'vs/editor/common/model'; +import { FoldingController } from 'vs/editor/contrib/folding/browser/folding'; +import { AccessibilitySignal, AccessilityModality, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IMarkerService, MarkerSeverity } from 'vs/platform/markers/common/markers'; +import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; +import { IDebugService } from 'vs/workbench/contrib/debug/common/debug'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; + +export class EditorTextPropertySignalsContribution extends Disposable implements IWorkbenchContribution { + private readonly _textProperties: TextProperty[] = [ + this._instantiationService.createInstance(MarkerTextProperty, AccessibilitySignal.errorAtPosition, AccessibilitySignal.errorOnLine, MarkerSeverity.Error), + this._instantiationService.createInstance(MarkerTextProperty, AccessibilitySignal.warningAtPosition, AccessibilitySignal.warningOnLine, MarkerSeverity.Warning), + this._instantiationService.createInstance(FoldedAreaTextProperty), + this._instantiationService.createInstance(BreakpointTextProperty), + ]; + + private readonly _someAccessibilitySignalIsEnabled = derived(this, reader => + this._textProperties + .flatMap(p => [p.lineSignal, p.positionSignal]) + .filter(isDefined) + .some(signal => observableFromValueWithChangeEvent(this, this._accessibilitySignalService.getEnabledState(signal, false)).read(reader)) + ); + + private readonly _activeEditorObservable = observableFromEvent( + this._editorService.onDidActiveEditorChange, + (_) => { + const activeTextEditorControl = this._editorService.activeTextEditorControl; + + const editor = isDiffEditor(activeTextEditorControl) + ? activeTextEditorControl.getOriginalEditor() + : isCodeEditor(activeTextEditorControl) + ? activeTextEditorControl + : undefined; + + return editor && editor.hasModel() ? { editor, model: editor.getModel() } : undefined; + } + ); + + constructor( + @IEditorService private readonly _editorService: IEditorService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, + ) { + super(); + + this._register(autorunWithStore((reader, store) => { + /** @description updateSignalsEnabled */ + if (!this._someAccessibilitySignalIsEnabled.read(reader)) { + return; + } + const activeEditor = this._activeEditorObservable.read(reader); + if (activeEditor) { + this._registerAccessibilitySignalsForEditor(activeEditor.editor, activeEditor.model, store); + } + })); + } + + private _registerAccessibilitySignalsForEditor(editor: ICodeEditor, editorModel: ITextModel, store: DisposableStore): void { + let lastLine = -1; + const ignoredLineSignalsForCurrentLine = new Set(); + + const timeouts = new DisposableStore(); + + const propertySources = this._textProperties.map(p => ({ source: p.createSource(editor, editorModel), property: p })); + + const didType = wasEventTriggeredRecently(editor.onDidChangeModelContent, 100, store); + + store.add(editor.onDidChangeCursorPosition(args => { + timeouts.clear(); + + if ( + args && + args.reason !== CursorChangeReason.Explicit && + args.reason !== CursorChangeReason.NotSet + ) { + // Ignore cursor changes caused by navigation (e.g. which happens when execution is paused). + ignoredLineSignalsForCurrentLine.clear(); + return; + } + + const trigger = (property: TextProperty, source: TextPropertySource, mode: 'line' | 'positional') => { + const signal = mode === 'line' ? property.lineSignal : property.positionSignal; + if ( + !signal + || !this._accessibilitySignalService.getEnabledState(signal, false).value + || !source.isPresent(position, mode, undefined) + ) { + return; + } + + for (const modality of ['sound', 'announcement'] as AccessilityModality[]) { + if (this._accessibilitySignalService.getEnabledState(signal, false, modality)) { + const delay = this._getDelay(signal, modality) + (didType.get() ? 1000 : 0); + + timeouts.add(disposableTimeout(() => { + if (source.isPresent(position, mode, undefined)) { + if (!(mode === 'line') || !ignoredLineSignalsForCurrentLine.has(property)) { + this._accessibilitySignalService.playSignal(signal, { modality }); + } + ignoredLineSignalsForCurrentLine.add(property); + } + }, delay)); + } + } + }; + + // React to cursor changes + const position = args.position; + const lineNumber = position.lineNumber; + if (lineNumber !== lastLine) { + ignoredLineSignalsForCurrentLine.clear(); + lastLine = lineNumber; + for (const p of propertySources) { + trigger(p.property, p.source, 'line'); + } + } + for (const p of propertySources) { + trigger(p.property, p.source, 'positional'); + } + + // React to property state changes for the current cursor position + for (const s of propertySources) { + if ( + ![s.property.lineSignal, s.property.positionSignal] + .some(s => s && this._accessibilitySignalService.getEnabledState(s, false).value) + ) { + return; + } + + let lastValueAtPosition: boolean | undefined = undefined; + let lastValueOnLine: boolean | undefined = undefined; + timeouts.add(autorun(reader => { + const newValueAtPosition = s.source.isPresentAtPosition(args.position, reader); + const newValueOnLine = s.source.isPresentOnLine(args.position.lineNumber, reader); + + if (lastValueAtPosition !== undefined && lastValueAtPosition !== undefined) { + if (!lastValueAtPosition && newValueAtPosition) { + trigger(s.property, s.source, 'positional'); + } + if (!lastValueOnLine && newValueOnLine) { + trigger(s.property, s.source, 'line'); + } + } + + lastValueAtPosition = newValueAtPosition; + lastValueOnLine = newValueOnLine; + })); + } + })); + } + + private _getDelay(signal: AccessibilitySignal, modality: AccessilityModality): number { + // TODO make these delays configurable! + if (signal === AccessibilitySignal.errorAtPosition || signal === AccessibilitySignal.warningAtPosition) { + if (modality === 'sound') { + return 100; + } else { + return 1000; + } + } + + if (modality === 'sound') { + return 400; + } else { + return 3000; + } + } +} + +interface TextProperty { + readonly positionSignal?: AccessibilitySignal; + readonly lineSignal?: AccessibilitySignal; + readonly debounceWhileTyping?: boolean; + createSource(editor: ICodeEditor, model: ITextModel): TextPropertySource; +} + +class TextPropertySource { + public static notPresent = new TextPropertySource({ isPresentAtPosition: () => false, isPresentOnLine: () => false }); + + public readonly isPresentOnLine: (lineNumber: number, reader: IReader | undefined) => boolean; + public readonly isPresentAtPosition: (position: Position, reader: IReader | undefined) => boolean; + + constructor(options: { + isPresentOnLine: (lineNumber: number, reader: IReader | undefined) => boolean; + isPresentAtPosition?: (position: Position, reader: IReader | undefined) => boolean; + }) { + this.isPresentOnLine = options.isPresentOnLine; + this.isPresentAtPosition = options.isPresentAtPosition ?? (() => false); + } + + public isPresent(position: Position, mode: 'line' | 'positional', reader: IReader | undefined): boolean { + return mode === 'line' ? this.isPresentOnLine(position.lineNumber, reader) : this.isPresentAtPosition(position, reader); + } +} + +class MarkerTextProperty implements TextProperty { + public readonly debounceWhileTyping = true; + constructor( + public readonly positionSignal: AccessibilitySignal, + public readonly lineSignal: AccessibilitySignal, + private readonly severity: MarkerSeverity, + @IMarkerService private readonly markerService: IMarkerService, + + ) { } + + createSource(editor: ICodeEditor, model: ITextModel): TextPropertySource { + const obs = observableSignalFromEvent('onMarkerChanged', this.markerService.onMarkerChanged); + return new TextPropertySource({ + isPresentAtPosition: (position, reader) => { + obs.read(reader); + const hasMarker = this.markerService + .read({ resource: model.uri }) + .some( + (m) => + m.severity === this.severity && + m.startLineNumber <= position.lineNumber && + position.lineNumber <= m.endLineNumber && + m.startColumn <= position.column && + position.column <= m.endColumn + ); + return hasMarker; + }, + isPresentOnLine: (lineNumber, reader) => { + obs.read(reader); + const hasMarker = this.markerService + .read({ resource: model.uri }) + .some( + (m) => + m.severity === this.severity && + m.startLineNumber <= lineNumber && + lineNumber <= m.endLineNumber + ); + return hasMarker; + } + }); + } +} + +class FoldedAreaTextProperty implements TextProperty { + public readonly lineSignal = AccessibilitySignal.foldedArea; + + createSource(editor: ICodeEditor, _model: ITextModel): TextPropertySource { + const foldingController = FoldingController.get(editor); + if (!foldingController) { return TextPropertySource.notPresent; } + + const foldingModel = observableFromPromise(foldingController.getFoldingModel() ?? Promise.resolve(undefined)); + return new TextPropertySource({ + isPresentOnLine(lineNumber, reader): boolean { + const m = foldingModel.read(reader); + const regionAtLine = m.value?.getRegionAtLine(lineNumber); + const hasFolding = !regionAtLine + ? false + : regionAtLine.isCollapsed && + regionAtLine.startLineNumber === lineNumber; + return hasFolding; + } + }); + } +} + +class BreakpointTextProperty implements TextProperty { + public readonly lineSignal = AccessibilitySignal.break; + + constructor(@IDebugService private readonly debugService: IDebugService) { } + + createSource(editor: ICodeEditor, model: ITextModel): TextPropertySource { + const signal = observableSignalFromEvent('onDidChangeBreakpoints', this.debugService.getModel().onDidChangeBreakpoints); + const debugService = this.debugService; + return new TextPropertySource({ + isPresentOnLine(lineNumber, reader): boolean { + signal.read(reader); + const breakpoints = debugService + .getModel() + .getBreakpoints({ uri: model.uri, lineNumber }); + const hasBreakpoints = breakpoints.length > 0; + return hasBreakpoints; + } + }); + } +} diff --git a/src/vs/workbench/contrib/accessibilitySignals/browser/reloadableWorkbenchContribution.ts b/src/vs/workbench/contrib/accessibilitySignals/browser/reloadableWorkbenchContribution.ts new file mode 100644 index 0000000000000..43fb32eed293a --- /dev/null +++ b/src/vs/workbench/contrib/accessibilitySignals/browser/reloadableWorkbenchContribution.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isHotReloadEnabled } from 'vs/base/common/hotReload'; +import { IDisposable } from 'vs/base/common/lifecycle'; +import { autorunWithStore } from 'vs/base/common/observable'; +import { readHotReloadableExport } from 'vs/editor/browser/widget/diffEditor/utils'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; + +/** + * Wrap a class in a reloadable wrapper. + * When the wrapper is created, the original class is created. + * When the original class changes, the instance is re-created. +*/ +export function wrapInReloadableClass(getClass: () => (new (...args: any[]) => any)): (new (...args: any[]) => any) { + if (!isHotReloadEnabled()) { + return getClass(); + } + + return class ReloadableWrapper extends BaseClass { + private _autorun: IDisposable | undefined = undefined; + + override init() { + this._autorun = autorunWithStore((reader, store) => { + const clazz = readHotReloadableExport(getClass(), reader); + store.add(this.instantiationService.createInstance(clazz)); + }); + } + + dispose(): void { + this._autorun?.dispose(); + } + }; +} + +class BaseClass { + constructor( + @IInstantiationService protected readonly instantiationService: IInstantiationService, + ) { + this.init(); + } + + init(): void { } +} From b44e8c6f57d7bd591db214976bf6d23e11b3c331 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 18 Apr 2024 19:44:57 +0200 Subject: [PATCH 05/10] minor code improvements (#210683) --- .../standalone/browser/standaloneServices.ts | 4 +- .../browser/accessibilitySignalService.ts | 47 ++++++++++--------- .../editorTextPropertySignalsContribution.ts | 6 +-- 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index cfbd136f5022b..10a329134bc3f 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -88,7 +88,7 @@ import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; import { IStorageService, InMemoryStorageService } from 'vs/platform/storage/common/storage'; import { DefaultConfiguration } from 'vs/platform/configuration/common/configurations'; import { WorkspaceEdit } from 'vs/editor/common/languages'; -import { AccessibilitySignal, AccessilityModality, IAccessibilitySignalService, Sound } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; +import { AccessibilitySignal, AccessibilityModality, IAccessibilitySignalService, Sound } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { LogService } from 'vs/platform/log/common/logService'; import { getEditorFeatures } from 'vs/editor/common/editorFeatures'; import { onUnexpectedError } from 'vs/base/common/errors'; @@ -1079,7 +1079,7 @@ class StandaloneAccessbilitySignalService implements IAccessibilitySignalService async playSignals(cues: AccessibilitySignal[]): Promise { } - getEnabledState(signal: AccessibilitySignal, userGesture: boolean, modality?: AccessilityModality | undefined): IValueWithChangeEvent { + getEnabledState(signal: AccessibilitySignal, userGesture: boolean, modality?: AccessibilityModality | undefined): IValueWithChangeEvent { return ValueWithChangeEvent.const(false); } diff --git a/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts b/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts index e1b92ad951e11..4a5efa9c83c39 100644 --- a/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts +++ b/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts @@ -24,7 +24,7 @@ export interface IAccessibilitySignalService { playSignals(signals: (AccessibilitySignal | { signal: AccessibilitySignal; source: string })[]): Promise; playSignalLoop(signal: AccessibilitySignal, milliseconds: number): IDisposable; - getEnabledState(signal: AccessibilitySignal, userGesture: boolean, modality?: AccessilityModality | undefined): IValueWithChangeEvent; + getEnabledState(signal: AccessibilitySignal, userGesture: boolean, modality?: AccessibilityModality | undefined): IValueWithChangeEvent; /** * Avoid this method and prefer `.playSignal`! @@ -43,12 +43,12 @@ export interface IAccessibilitySignalService { /** Make sure you understand the doc comments of the method you want to call when using this token! */ export const AcknowledgeDocCommentsToken = Symbol('AcknowledgeDocCommentsToken'); -export type AccessilityModality = 'sound' | 'announcement'; +export type AccessibilityModality = 'sound' | 'announcement'; export interface IAccessbilitySignalOptions { allowManyInParallel?: boolean; - modality?: AccessilityModality; + modality?: AccessibilityModality; /** * The source that triggered the signal (e.g. "diffEditor.cursorPositionChanged"). @@ -80,7 +80,7 @@ export class AccessibilitySignalService extends Disposable implements IAccessibi super(); } - public getEnabledState(signal: AccessibilitySignal, userGesture: boolean, modality?: AccessilityModality | undefined): IValueWithChangeEvent { + public getEnabledState(signal: AccessibilitySignal, userGesture: boolean, modality?: AccessibilityModality | undefined): IValueWithChangeEvent { return new ValueWithChangeEventFromObservable(this._signalEnabledState.get({ signal, userGesture, modality })); } @@ -199,28 +199,31 @@ export class AccessibilitySignalService extends Disposable implements IAccessibi } private readonly _signalConfigValue = new CachedFunction((signal: AccessibilitySignal) => observableConfigValue<{ - sound: 'on' | 'off' | 'auto' | 'userGesture' | 'always' | 'never'; - announcement: 'auto' | 'off' | 'userGesture' | 'always' | 'never'; + sound: EnabledState; + announcement: EnabledState; }>(signal.settingsKey, this.configurationService)); - private readonly _signalEnabledState = new CachedFunction({ getCacheKey: getStructuralKey }, (arg: { signal: AccessibilitySignal; userGesture: boolean; modality?: AccessilityModality | undefined }) => { - return derived(reader => { - /** @description sound enabled */ - const setting = this._signalConfigValue.get(arg.signal).read(reader); - - if (arg.modality === 'sound' || arg.modality === undefined) { - if (!checkEnabledState(setting.sound, () => this.screenReaderAttached.read(reader), arg.userGesture)) { - return false; + private readonly _signalEnabledState = new CachedFunction( + { getCacheKey: getStructuralKey }, + (arg: { signal: AccessibilitySignal; userGesture: boolean; modality?: AccessibilityModality | undefined }) => { + return derived(reader => { + /** @description sound enabled */ + const setting = this._signalConfigValue.get(arg.signal).read(reader); + + if (arg.modality === 'sound' || arg.modality === undefined) { + if (!checkEnabledState(setting.sound, () => this.screenReaderAttached.read(reader), arg.userGesture)) { + return false; + } } - } - if (arg.modality === 'announcement' || arg.modality === undefined) { - if (!checkEnabledState(setting.announcement, () => this.screenReaderAttached.read(reader), arg.userGesture)) { - return false; + if (arg.modality === 'announcement' || arg.modality === undefined) { + if (!checkEnabledState(setting.announcement, () => this.screenReaderAttached.read(reader), arg.userGesture)) { + return false; + } } - } - return true; - }); - }); + return true; + }); + } + ); public isAnnouncementEnabled(signal: AccessibilitySignal, userGesture?: boolean): boolean { if (!signal.announcementMessage) { diff --git a/src/vs/workbench/contrib/accessibilitySignals/browser/editorTextPropertySignalsContribution.ts b/src/vs/workbench/contrib/accessibilitySignals/browser/editorTextPropertySignalsContribution.ts index 7c58e2211450f..a163bd05f2810 100644 --- a/src/vs/workbench/contrib/accessibilitySignals/browser/editorTextPropertySignalsContribution.ts +++ b/src/vs/workbench/contrib/accessibilitySignals/browser/editorTextPropertySignalsContribution.ts @@ -13,7 +13,7 @@ import { Position } from 'vs/editor/common/core/position'; import { CursorChangeReason } from 'vs/editor/common/cursorEvents'; import { ITextModel } from 'vs/editor/common/model'; import { FoldingController } from 'vs/editor/contrib/folding/browser/folding'; -import { AccessibilitySignal, AccessilityModality, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; +import { AccessibilitySignal, AccessibilityModality, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IMarkerService, MarkerSeverity } from 'vs/platform/markers/common/markers'; import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; @@ -102,7 +102,7 @@ export class EditorTextPropertySignalsContribution extends Disposable implements return; } - for (const modality of ['sound', 'announcement'] as AccessilityModality[]) { + for (const modality of ['sound', 'announcement'] as AccessibilityModality[]) { if (this._accessibilitySignalService.getEnabledState(signal, false, modality)) { const delay = this._getDelay(signal, modality) + (didType.get() ? 1000 : 0); @@ -163,7 +163,7 @@ export class EditorTextPropertySignalsContribution extends Disposable implements })); } - private _getDelay(signal: AccessibilitySignal, modality: AccessilityModality): number { + private _getDelay(signal: AccessibilitySignal, modality: AccessibilityModality): number { // TODO make these delays configurable! if (signal === AccessibilitySignal.errorAtPosition || signal === AccessibilitySignal.warningAtPosition) { if (modality === 'sound') { From bc25233f181d6267fe4cac02864917cda7e6f332 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Thu, 18 Apr 2024 20:10:24 +0200 Subject: [PATCH 06/10] watcher - improve stats reporting (#210684) --- src/vs/platform/files/common/watcher.ts | 2 +- .../files/node/watcher/watcherStats.ts | 46 ++++++++++++------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/vs/platform/files/common/watcher.ts b/src/vs/platform/files/common/watcher.ts index 496f9c87f6f0a..aaa38858f9b04 100644 --- a/src/vs/platform/files/common/watcher.ts +++ b/src/vs/platform/files/common/watcher.ts @@ -470,7 +470,7 @@ export function requestFilterToString(filter: FileChangeFilter | undefined): str return ''; } - return filters.join(', '); + return `[${filters.join(', ')}]`; } return ''; diff --git a/src/vs/platform/files/node/watcher/watcherStats.ts b/src/vs/platform/files/node/watcher/watcherStats.ts index daa4b38982508..90e2f385ee54f 100644 --- a/src/vs/platform/files/node/watcher/watcherStats.ts +++ b/src/vs/platform/files/node/watcher/watcherStats.ts @@ -14,32 +14,41 @@ export function computeStats( ): string { const lines: string[] = []; - const recursiveRequests = sortByPathPrefix(requests.filter(request => request.recursive)); - const recursiveRequestsStatus = computeRequestStatus(recursiveRequests, recursiveWatcher); + const allRecursiveRequests = sortByPathPrefix(requests.filter(request => request.recursive)); + const nonSuspendedRecursiveRequests = allRecursiveRequests.filter(request => recursiveWatcher.isSuspended(request) === false); + const suspendedPollingRecursiveRequests = allRecursiveRequests.filter(request => recursiveWatcher.isSuspended(request) === 'polling'); + const suspendedNonPollingRecursiveRequests = allRecursiveRequests.filter(request => recursiveWatcher.isSuspended(request) === true); + + const recursiveRequestsStatus = computeRequestStatus(allRecursiveRequests, recursiveWatcher); const recursiveWatcherStatus = computeRecursiveWatchStatus(recursiveWatcher); - const nonRecursiveRequests = sortByPathPrefix(requests.filter(request => !request.recursive)); - const nonRecursiveRequestsStatus = computeRequestStatus(nonRecursiveRequests, nonRecursiveWatcher); + const allNonRecursiveRequests = sortByPathPrefix(requests.filter(request => !request.recursive)); + const nonSuspendedNonRecursiveRequests = allNonRecursiveRequests.filter(request => nonRecursiveWatcher.isSuspended(request) === false); + const suspendedPollingNonRecursiveRequests = allNonRecursiveRequests.filter(request => nonRecursiveWatcher.isSuspended(request) === 'polling'); + const suspendedNonPollingNonRecursiveRequests = allNonRecursiveRequests.filter(request => nonRecursiveWatcher.isSuspended(request) === true); + + const nonRecursiveRequestsStatus = computeRequestStatus(allNonRecursiveRequests, nonRecursiveWatcher); const nonRecursiveWatcherStatus = computeNonRecursiveWatchStatus(nonRecursiveWatcher); lines.push('[Summary]'); - lines.push(`- Recursive Requests: total: ${recursiveRequests.length}, suspended: ${recursiveRequestsStatus.suspended}, polling: ${recursiveRequestsStatus.polling}`); - lines.push(`- Non-Recursive Requests: total: ${nonRecursiveRequests.length}, suspended: ${nonRecursiveRequestsStatus.suspended}, polling: ${nonRecursiveRequestsStatus.polling}`); + lines.push(`- Recursive Requests: total: ${allRecursiveRequests.length}, suspended: ${recursiveRequestsStatus.suspended}, polling: ${recursiveRequestsStatus.polling}`); + lines.push(`- Non-Recursive Requests: total: ${allNonRecursiveRequests.length}, suspended: ${nonRecursiveRequestsStatus.suspended}, polling: ${nonRecursiveRequestsStatus.polling}`); lines.push(`- Recursive Watchers: total: ${recursiveWatcher.watchers.size}, active: ${recursiveWatcherStatus.active}, failed: ${recursiveWatcherStatus.failed}, stopped: ${recursiveWatcherStatus.stopped}`); lines.push(`- Non-Recursive Watchers: total: ${nonRecursiveWatcher.watchers.size}, active: ${nonRecursiveWatcherStatus.active}, failed: ${nonRecursiveWatcherStatus.failed}, reusing: ${nonRecursiveWatcherStatus.reusing}`); lines.push(`- I/O Handles Impact: total: ${recursiveRequestsStatus.polling + nonRecursiveRequestsStatus.polling + recursiveWatcherStatus.active + nonRecursiveWatcherStatus.active}`); - lines.push(`\n[Recursive Requests (${recursiveRequests.length}, suspended: ${recursiveRequestsStatus.suspended}, polling: ${recursiveRequestsStatus.polling})]:`); - for (const request of recursiveRequests) { + lines.push(`\n[Recursive Requests (${allRecursiveRequests.length}, suspended: ${recursiveRequestsStatus.suspended}, polling: ${recursiveRequestsStatus.polling})]:`); + for (const request of [nonSuspendedRecursiveRequests, suspendedPollingRecursiveRequests, suspendedNonPollingRecursiveRequests].flat()) { fillRequestStats(lines, request, recursiveWatcher); } - lines.push(`\n[Non-Recursive Requests (${nonRecursiveRequests.length}, suspended: ${nonRecursiveRequestsStatus.suspended}, polling: ${nonRecursiveRequestsStatus.polling})]:`); - for (const request of nonRecursiveRequests) { + fillRecursiveWatcherStats(lines, recursiveWatcher); + + lines.push(`\n[Non-Recursive Requests (${allNonRecursiveRequests.length}, suspended: ${nonRecursiveRequestsStatus.suspended}, polling: ${nonRecursiveRequestsStatus.polling})]:`); + for (const request of [nonSuspendedNonRecursiveRequests, suspendedPollingNonRecursiveRequests, suspendedNonPollingNonRecursiveRequests].flat()) { fillRequestStats(lines, request, nonRecursiveWatcher); } - fillRecursiveWatcherStats(lines, recursiveWatcher); fillNonRecursiveWatcherStats(lines, nonRecursiveWatcher); let maxLength = 0; @@ -157,7 +166,7 @@ function fillRequestStats(lines: string[], request: IUniversalWatchRequest, watc } } - lines.push(`${request.path}\t${decorations.length > 0 ? decorations.join(' ') + ' ' : ''}(${requestDetailsToString(request)})`); + lines.push(` ${request.path}\t${decorations.length > 0 ? decorations.join(' ') + ' ' : ''}(${requestDetailsToString(request)})`); } function requestDetailsToString(request: IUniversalWatchRequest): string { @@ -184,17 +193,20 @@ function fillRecursiveWatcherStats(lines: string[], recursiveWatcher: ParcelWatc if (watcher.restarts > 0) { decorations.push(`[RESTARTED:${watcher.restarts}]`); } - lines.push(`${watcher.request.path}\t${decorations.length > 0 ? decorations.join(' ') + ' ' : ''}(${requestDetailsToString(watcher.request)})`); + lines.push(` ${watcher.request.path}\t${decorations.length > 0 ? decorations.join(' ') + ' ' : ''}(${requestDetailsToString(watcher.request)})`); } } function fillNonRecursiveWatcherStats(lines: string[], nonRecursiveWatcher: NodeJSWatcher): void { - const watchers = sortByPathPrefix(Array.from(nonRecursiveWatcher.watchers.values())); + const allWatchers = sortByPathPrefix(Array.from(nonRecursiveWatcher.watchers.values())); + const activeWatchers = allWatchers.filter(watcher => !watcher.instance.failed && !watcher.instance.isReusingRecursiveWatcher); + const failedWatchers = allWatchers.filter(watcher => watcher.instance.failed); + const reusingWatchers = allWatchers.filter(watcher => watcher.instance.isReusingRecursiveWatcher); const { active, failed, reusing } = computeNonRecursiveWatchStatus(nonRecursiveWatcher); - lines.push(`\n[Non-Recursive Watchers (${watchers.length}, active: ${active}, failed: ${failed}, reusing: ${reusing})]:`); + lines.push(`\n[Non-Recursive Watchers (${allWatchers.length}, active: ${active}, failed: ${failed}, reusing: ${reusing})]:`); - for (const watcher of watchers) { + for (const watcher of [activeWatchers, failedWatchers, reusingWatchers].flat()) { const decorations = []; if (watcher.instance.failed) { decorations.push('[FAILED]'); @@ -202,6 +214,6 @@ function fillNonRecursiveWatcherStats(lines: string[], nonRecursiveWatcher: Node if (watcher.instance.isReusingRecursiveWatcher) { decorations.push('[REUSING]'); } - lines.push(`${watcher.request.path}\t${decorations.length > 0 ? decorations.join(' ') + ' ' : ''}(${requestDetailsToString(watcher.request)})`); + lines.push(` ${watcher.request.path}\t${decorations.length > 0 ? decorations.join(' ') + ' ' : ''}(${requestDetailsToString(watcher.request)})`); } } From 74f288831db201d97ccb943ddf569dfa649102c7 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 18 Apr 2024 11:16:36 -0700 Subject: [PATCH 07/10] Detect ghost text in input Part of #210662 --- .../commandDetection/promptInputModel.ts | 80 +++++++++++++++++-- .../terminal.developer.contribution.ts | 8 +- 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts index 7131e0386ad71..02ad46c9fdde8 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts @@ -11,7 +11,7 @@ import { debounce } from 'vs/base/common/decorators'; // Importing types is safe in any layer // eslint-disable-next-line local/code-import-patterns -import type { Terminal, IMarker, IBufferLine, IBuffer } from '@xterm/headless'; +import type { Terminal, IMarker, IBufferCell, IBufferLine, IBuffer } from '@xterm/headless'; const enum PromptInputState { Unknown, @@ -26,6 +26,13 @@ export interface IPromptInputModel { readonly value: string; readonly cursorIndex: number; + readonly ghostTextIndex: number; + + /** + * Gets the prompt input as a user-friendly string where `|` is the cursor position and `[` and + * `]` wrap any ghost text. + */ + getCombinedString(): string; } export class PromptInputModel extends Disposable implements IPromptInputModel { @@ -41,6 +48,9 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { private _cursorIndex: number = 0; get cursorIndex() { return this._cursorIndex; } + private _ghostTextIndex: number = -1; + get ghostTextIndex() { return this._ghostTextIndex; } + private readonly _onDidStartInput = this._register(new Emitter()); readonly onDidStartInput = this._onDidStartInput.event; private readonly _onDidChangeInput = this._register(new Emitter()); @@ -67,6 +77,18 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { this._continuationPrompt = value; } + getCombinedString(): string { + const value = this._value.replaceAll('\n', '\u23CE'); + let result = `${value.substring(0, this.cursorIndex)}|`; + if (this.ghostTextIndex !== -1) { + result += `${value.substring(this.cursorIndex, this.ghostTextIndex)}[`; + result += `${value.substring(this.ghostTextIndex)}]`; + } else { + result += value.substring(this.cursorIndex); + } + return result; + } + private _handleCommandStart(command: { marker: IMarker }) { if (this._state === PromptInputState.Input) { return; @@ -111,7 +133,7 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { const buffer = this._xterm.buffer.active; let line = buffer.getLine(commandStartY); const commandLine = line?.translateToString(true, this._commandStartX); - if (!commandLine || !line) { + if (!line || commandLine === undefined) { this._logService.trace(`PromptInputModel#_sync: no line`); return; } @@ -122,9 +144,14 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { // Get cursor index const absoluteCursorY = buffer.baseY + buffer.cursorY; this._cursorIndex = absoluteCursorY === commandStartY ? this._getRelativeCursorIndex(this._commandStartX, buffer, line) : commandLine.length + 1; + this._ghostTextIndex = -1; + + // Detect ghost text by looking for italic or dim text in or after the cursor and + // non-italic/dim text in the cell closest non-whitespace cell before the cursor + if (absoluteCursorY === commandStartY && buffer.cursorX > 1) { + this._ghostTextIndex = this._scanForGhostText(buffer, line); + } - // IDEA: Detect ghost text based on SGR and cursor. This might work by checking for italic - // or dim only to avoid false positives from shells that do immediate coloring. // IDEA: Detect line continuation if it's not set // From command start line to cursor line @@ -160,12 +187,51 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { } if (this._logService.getLevel() === LogLevel.Trace) { - this._logService.trace(`PromptInputModel#_sync: Input="${this._value.substring(0, this._cursorIndex)}|${this.value.substring(this._cursorIndex)}"`); + this._logService.trace(`PromptInputModel#_sync: ${this.getCombinedString()}`); } this._onDidChangeInput.fire(); } + /** + * Detect ghost text by looking for italic or dim text in or after the cursor and + * non-italic/dim text in the cell closest non-whitespace cell before the cursor. + */ + private _scanForGhostText(buffer: IBuffer, line: IBufferLine): number { + // Check last non-whitespace character has non-ghost text styles + let ghostTextIndex = -1; + let proceedWithGhostTextCheck = false; + let x = buffer.cursorX; + while (x > 1) { + const cell = line.getCell(--x); + if (!cell) { + break; + } + if (cell.getChars().trim().length > 0) { + proceedWithGhostTextCheck = !this._isCellStyledLikeGhostText(cell); + break; + } + } + + // Check to the end of the line for possible ghost text. For example pwsh's ghost text + // can look like this `Get-|Ch[ildItem]` + if (proceedWithGhostTextCheck) { + let x = buffer.cursorX; + while (x < line.length) { + const cell = line.getCell(x++); + if (!cell || cell.getCode() === 0) { + break; + } + if (this._isCellStyledLikeGhostText(cell)) { + ghostTextIndex = this._cursorIndex; + break; + } + } + } + + return ghostTextIndex; + } + private _trimContinuationPrompt(lineText: string): string { if (this._lineContainsContinuationPrompt(lineText)) { lineText = lineText.substring(this._continuationPrompt!.length); @@ -192,4 +258,8 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { private _getRelativeCursorIndex(startCellX: number, buffer: IBuffer, line: IBufferLine): number { return line?.translateToString(true, startCellX, buffer.cursorX).length ?? 0; } + + private _isCellStyledLikeGhostText(cell: IBufferCell): boolean { + return !!(cell.isItalic() || cell.isDim()); + } } diff --git a/src/vs/workbench/contrib/terminalContrib/developer/browser/terminal.developer.contribution.ts b/src/vs/workbench/contrib/terminalContrib/developer/browser/terminal.developer.contribution.ts index 444a671795517..744cf3622c356 100644 --- a/src/vs/workbench/contrib/terminalContrib/developer/browser/terminal.developer.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/developer/browser/terminal.developer.contribution.ts @@ -245,11 +245,11 @@ class DevModeContribution extends Disposable implements ITerminalContribution { private _updatePromptInputStatusBar(commandDetection: ICommandDetectionCapability) { const promptInputModel = commandDetection.promptInputModel; if (promptInputModel) { - const promptInput = promptInputModel.value.replaceAll('\n', '\u23CE'); + const name = localize('terminalDevMode', 'Terminal Dev Mode'); this._statusbarEntry = { - name: localize('terminalDevMode', 'Terminal Dev Mode'), - text: `$(terminal) ${promptInput.substring(0, promptInputModel.cursorIndex)}|${promptInput.substring(promptInputModel.cursorIndex)}`, - ariaLabel: localize('terminalDevMode', 'Terminal Dev Mode'), + name, + text: `$(terminal) ${promptInputModel.getCombinedString()}`, + ariaLabel: name, kind: 'prominent' }; if (!this._statusbarEntryAccessor.value) { From 36e597114238302a6a0505e72746ae67ef0cf10b Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 18 Apr 2024 11:20:41 -0700 Subject: [PATCH 08/10] Test ghost text in recorded test --- .../commandDetection/promptInputModel.ts | 1 + .../commandDetection/promptInputModel.test.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts index 02ad46c9fdde8..d2b29eeb960ca 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts @@ -149,6 +149,7 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { // Detect ghost text by looking for italic or dim text in or after the cursor and // non-italic/dim text in the cell closest non-whitespace cell before the cursor if (absoluteCursorY === commandStartY && buffer.cursorX > 1) { + // Ghost text in pwsh only appears to happen on the cursor line this._ghostTextIndex = this._scanForGhostText(buffer, line); } diff --git a/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts b/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts index 25687967185f2..7d18150b850b6 100644 --- a/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts +++ b/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts @@ -46,14 +46,14 @@ suite('PromptInputModel', () => { promptInputModel.forceSync(); - const actualValueWithCursor = promptInputModel.value.substring(0, promptInputModel.cursorIndex) + '|' + promptInputModel.value.substring(promptInputModel.cursorIndex); + const actualValueWithCursor = promptInputModel.getCombinedString(); strictEqual( actualValueWithCursor.replaceAll('\n', '\u23CE'), valueWithCursor.replaceAll('\n', '\u23CE') ); // This is required to ensure the cursor index is correctly resolved for non-ascii characters - const value = valueWithCursor.replace('|', ''); + const value = valueWithCursor.replace(/[\|\[\]]/g, ''); const cursorIndex = valueWithCursor.indexOf('|'); strictEqual(promptInputModel.value, value); strictEqual(promptInputModel.cursorIndex, cursorIndex, `value=${promptInputModel.value}`); @@ -222,31 +222,31 @@ suite('PromptInputModel', () => { '[?25lecho "hello world"[?25h', '', ]); - assertPromptInput('e|cho "hello world"'); + assertPromptInput('e|[cho "hello world"]'); await replayEvents([ '[?25lecho "hello world"[?25h', '', ]); - assertPromptInput('ec|ho "hello world"'); + assertPromptInput('ec|[ho "hello world"]'); await replayEvents([ '[?25lecho "hello world"[?25h', '', ]); - assertPromptInput('ech|o "hello world"'); + assertPromptInput('ech|[o "hello world"]'); await replayEvents([ '[?25lecho "hello world"[?25h', '', ]); - assertPromptInput('echo| "hello world"'); + assertPromptInput('echo|[ "hello world"]'); await replayEvents([ '[?25lecho "hello world"[?25h', '', ]); - assertPromptInput('echo |"hello world"'); + assertPromptInput('echo |["hello world"]'); await replayEvents([ '[?25lecho "hello world"[?25h', From 7e9d977768616ccbeab4e777e347fcfdae88d756 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 18 Apr 2024 11:28:48 -0700 Subject: [PATCH 09/10] Test ghost text in simple unit test --- .../commandDetection/promptInputModel.ts | 4 +++- .../commandDetection/promptInputModel.test.ts | 14 +++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts index d2b29eeb960ca..bdb3c4e3358b6 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts @@ -217,6 +217,7 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { // Check to the end of the line for possible ghost text. For example pwsh's ghost text // can look like this `Get-|Ch[ildItem]` if (proceedWithGhostTextCheck) { + let potentialGhostIndexOffset = 0; let x = buffer.cursorX; while (x < line.length) { const cell = line.getCell(x++); @@ -224,9 +225,10 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { break; } if (this._isCellStyledLikeGhostText(cell)) { - ghostTextIndex = this._cursorIndex; + ghostTextIndex = this._cursorIndex + potentialGhostIndexOffset; break; } + potentialGhostIndexOffset += cell.getChars().length; } } diff --git a/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts b/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts index 7d18150b850b6..eaaaaabc91482 100644 --- a/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts +++ b/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts @@ -48,7 +48,7 @@ suite('PromptInputModel', () => { const actualValueWithCursor = promptInputModel.getCombinedString(); strictEqual( - actualValueWithCursor.replaceAll('\n', '\u23CE'), + actualValueWithCursor, valueWithCursor.replaceAll('\n', '\u23CE') ); @@ -110,6 +110,18 @@ suite('PromptInputModel', () => { assertPromptInput('foo bar|'); }); + test('ghost text', async () => { + await writePromise('$ '); + fireCommandStart(); + assertPromptInput('|'); + + await writePromise('foo\x1b[2m bar\x1b[0m\x1b[4D'); + assertPromptInput('foo|[ bar]'); + + await writePromise('\x1b[2D'); + assertPromptInput('f|oo[ bar]'); + }); + test('wide input (Korean)', async () => { await writePromise('$ '); fireCommandStart(); From 558ccacaf5a74cafff3a688bf6a5089111001b69 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 18 Apr 2024 11:36:17 -0700 Subject: [PATCH 10/10] Fix one off error --- .../common/capabilities/commandDetection/promptInputModel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts index bdb3c4e3358b6..26b9ebc2b1515 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts @@ -203,7 +203,7 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { let ghostTextIndex = -1; let proceedWithGhostTextCheck = false; let x = buffer.cursorX; - while (x > 1) { + while (x > 0) { const cell = line.getCell(--x); if (!cell) { break;