From 06e90b57d0c5fadf085fa53fba6f96c44cfdd838 Mon Sep 17 00:00:00 2001 From: RoFz <4117458+RoFz@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:22:16 +0100 Subject: [PATCH] feat(accessibility): add focus-follows-mouse support - feat(accessibility): add accessibility.focusFollowsMouse.enabled setting - feat(accessibility): add configurable accessibility.focusFollowsMouse.delay setting (default 200ms) - feat(accessibility): add FocusFollowsMouseContribution - feat(accessibility): register contribution at Restored lifecycle phase - test(accessibility): add FocusFollowsMouseContribution unit tests --- .../browser/accessibility.contribution.ts | 2 + .../browser/accessibilityConfiguration.ts | 17 + .../browser/focusFollowsMouseContribution.ts | 169 ++++++++ .../focusFollowsMouseContribution.test.ts | 367 ++++++++++++++++++ 4 files changed, 555 insertions(+) create mode 100644 src/vs/workbench/contrib/accessibility/browser/focusFollowsMouseContribution.ts create mode 100644 src/vs/workbench/contrib/accessibility/test/browser/focusFollowsMouseContribution.test.ts diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibility.contribution.ts b/src/vs/workbench/contrib/accessibility/browser/accessibility.contribution.ts index 71a01e7d4a6e8..77d51f7cb6609 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibility.contribution.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibility.contribution.ts @@ -9,6 +9,7 @@ import { IWorkbenchContributionsRegistry, WorkbenchPhase, Extensions as Workbenc import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { UnfocusedViewDimmingContribution } from './unfocusedViewDimmingContribution.js'; +import { FocusFollowsMouseContribution } from './focusFollowsMouseContribution.js'; import { AccessibilityStatus } from './accessibilityStatus.js'; import { EditorAccessibilityHelpContribution } from './editorAccessibilityHelp.js'; import { SaveAccessibilitySignalContribution } from '../../accessibilitySignals/browser/saveAccessibilitySignal.js'; @@ -37,3 +38,4 @@ registerWorkbenchContribution2(SaveAccessibilitySignalContribution.ID, SaveAcces registerWorkbenchContribution2(SpeechAccessibilitySignalContribution.ID, SpeechAccessibilitySignalContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(DiffEditorActiveAnnouncementContribution.ID, DiffEditorActiveAnnouncementContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(DynamicSpeechAccessibilityConfiguration.ID, DynamicSpeechAccessibilityConfiguration, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(FocusFollowsMouseContribution.ID, FocusFollowsMouseContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts index 2e66c87a17c57..cb98b17635ec3 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts @@ -34,6 +34,8 @@ export const accessibleViewHasAssignedKeybindings = new RawContextKey(' export const enum AccessibilityWorkbenchSettingId { DimUnfocusedEnabled = 'accessibility.dimUnfocused.enabled', DimUnfocusedOpacity = 'accessibility.dimUnfocused.opacity', + FocusFollowsMouseEnabled = 'accessibility.focusFollowsMouse.enabled', + FocusFollowsMouseDelay = 'accessibility.focusFollowsMouse.delay', HideAccessibleView = 'accessibility.hideAccessibleView', AccessibleViewCloseOnKeyPress = 'accessibility.accessibleView.closeOnKeyPress', VerboseChatProgressUpdates = 'accessibility.verboseChatProgressUpdates', @@ -864,6 +866,21 @@ export function registerAccessibilityConfiguration() { tags: ['accessibility'], scope: ConfigurationScope.APPLICATION, }, + [AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled]: { + description: localize('focusFollowsMouseEnabled', 'Controls whether keyboard focus follows the mouse when hovering over editor groups, the Side Bar, the Panel, and the Secondary Side Bar. Focusing requires the part to be visible and the cursor to remain over it for a brief moment.'), + type: 'boolean', + default: false, + tags: ['accessibility'], + scope: ConfigurationScope.APPLICATION, + }, + [AccessibilityWorkbenchSettingId.FocusFollowsMouseDelay]: { + markdownDescription: localize('focusFollowsMouseDelay', 'The delay in milliseconds before keyboard focus follows the mouse. This will only take effect when {0} is enabled.', `\`#${AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled}#\``), + type: 'number', + minimum: 0, + default: 200, + tags: ['accessibility'], + scope: ConfigurationScope.APPLICATION, + }, [AccessibilityWorkbenchSettingId.HideAccessibleView]: { description: localize('accessibility.hideAccessibleView', "Controls whether the Accessible View is hidden."), type: 'boolean', diff --git a/src/vs/workbench/contrib/accessibility/browser/focusFollowsMouseContribution.ts b/src/vs/workbench/contrib/accessibility/browser/focusFollowsMouseContribution.ts new file mode 100644 index 0000000000000..35179ce82fc2e --- /dev/null +++ b/src/vs/workbench/contrib/accessibility/browser/focusFollowsMouseContribution.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { addDisposableListener, EventType } from '../../../../base/browser/dom.js'; +import { mainWindow } from '../../../../base/browser/window.js'; +import { RunOnceScheduler } from '../../../../base/common/async.js'; +import { Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IEditorGroup, IEditorGroupsService, GroupsOrder } from '../../../services/editor/common/editorGroupsService.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { Parts, IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; +import { AccessibilityWorkbenchSettingId } from './accessibilityConfiguration.js'; +import { IEditorGroupView } from '../../../browser/parts/editor/editor.js'; + +/** Fallback debounce delay in ms when the configured value is missing or invalid. */ +const DEFAULT_FOCUS_DELAY_MS = 200; + +export class FocusFollowsMouseContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.focusFollowsMouse'; + + /** Disposables for the currently-active set of mouse listeners. Cleared when feature is disabled. */ + private readonly listenerStore = this._register(new DisposableStore()); + + /** Per-editor-group disposables, keyed by group id. */ + private readonly groupListeners = this._register(new DisposableMap()); + + // A single scheduler and pending action are shared across all parts and + // editor groups by design: only one focus can be pending at a time, and the + // most recently hovered target wins. Each `mouseenter` overwrites the + // pending action and restarts the timer; `mouseleave` (or disable) cancels + // it. This intentionally focuses whatever the cursor most recently entered. + private readonly focusScheduler = this._register(new RunOnceScheduler(() => this.pendingFocus?.(), DEFAULT_FOCUS_DELAY_MS)); + private pendingFocus: (() => void) | undefined = undefined; + + constructor( + @IConfigurationService private readonly configurationService: IConfigurationService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + ) { + super(); + + this._register(Event.runAndSubscribe(configurationService.onDidChangeConfiguration, e => { + // The delay is read lazily in getDelay(), so only the enabled flag + // needs to drive enable/disable here. + if (e && !e.affectsConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled)) { + return; + } + this.update(); + })); + } + + private update(): void { + const enabled = this.configurationService.getValue(AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled); + if (enabled) { + this.enable(); + } else { + this.disable(); + } + } + + /** + * Resolves the configured debounce delay, falling back to the default when + * the user-provided value is missing or not a non-negative number. + */ + private getDelay(): number { + const delay = this.configurationService.getValue(AccessibilityWorkbenchSettingId.FocusFollowsMouseDelay); + return typeof delay === 'number' && delay >= 0 ? delay : DEFAULT_FOCUS_DELAY_MS; + } + + private cancelPendingFocus(): void { + this.pendingFocus = undefined; + this.focusScheduler.cancel(); + } + + private enable(): void { + this.listenerStore.clear(); + this.groupListeners.clearAndDisposeAll(); + + // Sidebar + this.addPartListener(Parts.SIDEBAR_PART, () => this.layoutService.focusPart(Parts.SIDEBAR_PART)); + + // Panel + this.addPartListener(Parts.PANEL_PART, () => this.layoutService.focusPart(Parts.PANEL_PART)); + + // Auxiliary bar + this.addPartListener(Parts.AUXILIARYBAR_PART, () => this.layoutService.focusPart(Parts.AUXILIARYBAR_PART)); + + // Editor groups — attach to each existing group and watch for new ones + for (const group of this.editorGroupsService.getGroups(GroupsOrder.CREATION_TIME)) { + this.attachGroupListener(group); + } + + this.listenerStore.add(this.editorGroupsService.onDidAddGroup(group => this.attachGroupListener(group))); + this.listenerStore.add(this.editorGroupsService.onDidRemoveGroup(group => this.groupListeners.deleteAndDispose(group.id))); + } + + private disable(): void { + this.listenerStore.clear(); + this.groupListeners.clearAndDisposeAll(); + this.cancelPendingFocus(); + } + + /** + * Attaches mouse listeners to the container of a workbench part. + * Does nothing if the part container is not yet visible/available. + * Parts are resolved for the main window only; the same parts in auxiliary + * windows are not covered (editor groups, handled separately, are covered + * across all windows via IEditorGroupsService). + */ + private addPartListener(part: Parts, focusFn: () => void): void { + const container = this.layoutService.getContainer(mainWindow, part); + if (!container) { + return; + } + this.listenerStore.add(addDisposableListener(container, EventType.MOUSE_ENTER, (e: MouseEvent) => { + this.scheduleFocus(e, focusFn); + })); + this.listenerStore.add(addDisposableListener(container, EventType.MOUSE_LEAVE, () => this.cancelPendingFocus())); + } + + /** + * Attaches mouse listeners directly to the DOM element of an editor group. + * `IEditorGroup` exposes no public DOM accessor, so the group is narrowed to + * its internal `IEditorGroupView` (the same cast the editor service uses). + */ + private attachGroupListener(group: IEditorGroup): void { + const element = (group as IEditorGroupView).element; + if (!element) { + return; + } + + const store = new DisposableStore(); + store.add(addDisposableListener(element, EventType.MOUSE_ENTER, (e: MouseEvent) => { + this.scheduleFocus(e, () => { + // Re-resolve the group at focus time in case it has since been disposed. + const live = this.editorGroupsService.getGroup(group.id); + live?.focus(); + }); + })); + + // Cancel any pending focus when the mouse leaves the group, so a brief + // transit across a group border does not accidentally steal focus. + store.add(addDisposableListener(element, EventType.MOUSE_LEAVE, () => this.cancelPendingFocus())); + + this.groupListeners.set(group.id, store); + } + + /** + * Schedules a focus action after the configured delay, guarded against unsafe conditions. + */ + private scheduleFocus(e: MouseEvent, focusFn: () => void): void { + // If a mouse button is held (e.g. drag-and-drop), do not shift focus. + if (e.buttons !== 0) { + return; + } + + // If a context menu is open, do not shift focus. + if (this.layoutService.activeContainer.classList.contains('context-menu-visible')) { + return; + } + + this.pendingFocus = focusFn; + this.focusScheduler.schedule(this.getDelay()); + } +} diff --git a/src/vs/workbench/contrib/accessibility/test/browser/focusFollowsMouseContribution.test.ts b/src/vs/workbench/contrib/accessibility/test/browser/focusFollowsMouseContribution.test.ts new file mode 100644 index 0000000000000..911c9e084390b --- /dev/null +++ b/src/vs/workbench/contrib/accessibility/test/browser/focusFollowsMouseContribution.test.ts @@ -0,0 +1,367 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { timeout } from '../../../../../base/common/async.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IEditorGroup, IEditorGroupsService, GroupsOrder } from '../../../../services/editor/common/editorGroupsService.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/browser/layoutService.js'; +import { TestLayoutService, TestEditorGroupsService, TestEditorGroupView } from '../../../../test/browser/workbenchTestServices.js'; +import { FocusFollowsMouseContribution } from '../../browser/focusFollowsMouseContribution.js'; +import { AccessibilityWorkbenchSettingId } from '../../browser/accessibilityConfiguration.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; + +// --------------------------------------------------------------------------- +// Test doubles +// --------------------------------------------------------------------------- + +/** + * Layout service that routes getContainer() per-Part and records focusPart calls. + */ +class TestFocusLayoutService extends TestLayoutService { + + readonly focusedParts: Parts[] = []; + private readonly partContainers = new Map(); + + setPartContainer(part: Parts, el: HTMLElement): void { + this.partContainers.set(part, el); + } + + override getContainer(_targetWindow?: Window, part?: Parts): HTMLElement { + const el = part !== undefined ? this.partContainers.get(part) : undefined; + return el ?? super.getContainer(); + } + + override focusPart(part: Parts): void { + this.focusedParts.push(part); + } +} + +/** + * Minimal editor group with a real HTMLElement so mouseenter/mouseleave fire. + */ +class TestGroupWithElement extends TestEditorGroupView { + + override readonly element: HTMLElement; + readonly focusCalls: number[] = []; + + constructor(id: number) { + super(id); + this.element = mainWindow.document.createElement('div'); + } + + override focus(): void { + this.focusCalls.push(this.id); + } +} + +/** + * Editor groups service backed by mutable group list with emitters. + */ +class TestFocusEditorGroupsService extends TestEditorGroupsService { + + private readonly _onDidAddGroup = new Emitter(); + private readonly _onDidRemoveGroup = new Emitter(); + + override readonly onDidAddGroup = this._onDidAddGroup.event; + override readonly onDidRemoveGroup = this._onDidRemoveGroup.event; + + constructor(public override groups: TestGroupWithElement[] = []) { + super(groups); + } + + addTestGroup(group: TestGroupWithElement): void { + this.groups.push(group); + this._onDidAddGroup.fire(group); + } + + removeTestGroup(group: TestGroupWithElement): void { + this.groups = this.groups.filter(g => g.id !== group.id); + this._onDidRemoveGroup.fire(group); + } + + override getGroups(_order?: GroupsOrder): readonly IEditorGroup[] { + return this.groups; + } + + override getGroup(id: number): IEditorGroup | undefined { + return this.groups.find(g => g.id === id); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const DELAY = 200; // must match DEFAULT_FOCUS_DELAY_MS in the contribution + +function fireMouseEnter(el: HTMLElement, buttons = 0): void { + el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: false, buttons })); +} + +function fireMouseLeave(el: HTMLElement): void { + el.dispatchEvent(new MouseEvent('mouseleave', { bubbles: false })); +} + +function buildContribution( + store: Pick, + configService: TestConfigurationService, + layoutService: TestFocusLayoutService, + editorGroupsService: TestFocusEditorGroupsService, +): FocusFollowsMouseContribution { + // Wire services using a minimal manual DI approach + const contribution = new FocusFollowsMouseContribution( + configService as unknown as IConfigurationService, + layoutService as unknown as IWorkbenchLayoutService, + editorGroupsService as unknown as IEditorGroupsService, + ); + store.add(contribution); + return contribution; +} + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +suite('FocusFollowsMouseContribution', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let configService: TestConfigurationService; + let layoutService: TestFocusLayoutService; + let editorGroupsService: TestFocusEditorGroupsService; + let sidebarEl: HTMLElement; + let panelEl: HTMLElement; + + setup(() => { + configService = new TestConfigurationService(); + layoutService = new TestFocusLayoutService(); + + sidebarEl = mainWindow.document.createElement('div'); + panelEl = mainWindow.document.createElement('div'); + layoutService.setPartContainer(Parts.SIDEBAR_PART, sidebarEl); + layoutService.setPartContainer(Parts.PANEL_PART, panelEl); + + editorGroupsService = new TestFocusEditorGroupsService(); + }); + + // ------------------------------------------------------------------------- + // Disabled by default — no focus fired + // ------------------------------------------------------------------------- + + test('does not fire focus when feature is disabled', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled, false); + + buildContribution(store, configService, layoutService, editorGroupsService); + + fireMouseEnter(sidebarEl); + await timeout(DELAY + 50); + + assert.strictEqual(layoutService.focusedParts.length, 0, 'No part should be focused when feature is off'); + })); + + // ------------------------------------------------------------------------- + // Sidebar and panel focus + // ------------------------------------------------------------------------- + + test('focuses sidebar after delay on mouseenter', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled, true); + buildContribution(store, configService, layoutService, editorGroupsService); + + fireMouseEnter(sidebarEl); + + // No immediate focus + assert.strictEqual(layoutService.focusedParts.length, 0, 'Focus should not fire immediately'); + + await timeout(DELAY + 50); + assert.strictEqual(layoutService.focusedParts.length, 1); + assert.strictEqual(layoutService.focusedParts[0], Parts.SIDEBAR_PART); + })); + + test('focuses panel after delay on mouseenter', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled, true); + buildContribution(store, configService, layoutService, editorGroupsService); + + fireMouseEnter(panelEl); + await timeout(DELAY + 50); + + assert.strictEqual(layoutService.focusedParts[0], Parts.PANEL_PART); + })); + + // ------------------------------------------------------------------------- + // Guards + // ------------------------------------------------------------------------- + + test('does not focus when a mouse button is held during mouseenter', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled, true); + buildContribution(store, configService, layoutService, editorGroupsService); + + fireMouseEnter(sidebarEl, /* buttons = */ 1); // primary button held + await timeout(DELAY + 50); + + assert.strictEqual(layoutService.focusedParts.length, 0, 'Should not focus while dragging'); + })); + + test('does not focus when a context menu is visible', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled, true); + buildContribution(store, configService, layoutService, editorGroupsService); + + // activeContainer is mainWindow.document.body in TestLayoutService + layoutService.activeContainer.classList.add('context-menu-visible'); + try { + fireMouseEnter(sidebarEl); + await timeout(DELAY + 50); + assert.strictEqual(layoutService.focusedParts.length, 0, 'Should not focus with context menu open'); + } finally { + layoutService.activeContainer.classList.remove('context-menu-visible'); + } + })); + + // ------------------------------------------------------------------------- + // Editor group listeners + // ------------------------------------------------------------------------- + + test('focuses editor group after delay on mouseenter', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const group = store.add(new TestGroupWithElement(1)); + editorGroupsService.addTestGroup(group); + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled, true); + buildContribution(store, configService, layoutService, editorGroupsService); + + fireMouseEnter(group.element); + await timeout(DELAY + 50); + + assert.strictEqual(group.focusCalls.length, 1, 'Group focus() should be called once'); + })); + + test('cancels pending group focus on mouseleave', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const group = store.add(new TestGroupWithElement(1)); + editorGroupsService.addTestGroup(group); + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled, true); + buildContribution(store, configService, layoutService, editorGroupsService); + + fireMouseEnter(group.element); + fireMouseLeave(group.element); // cancel before delay elapses + await timeout(DELAY + 50); + + assert.strictEqual(group.focusCalls.length, 0, 'Focus should be cancelled by mouseleave'); + })); + + test('attaches listener to groups added after construction', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled, true); + buildContribution(store, configService, layoutService, editorGroupsService); + + const group = store.add(new TestGroupWithElement(2)); + editorGroupsService.addTestGroup(group); + + fireMouseEnter(group.element); + await timeout(DELAY + 50); + + assert.strictEqual(group.focusCalls.length, 1, 'Dynamically added group should receive focus'); + })); + + test('cleans up listener when a group is removed', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const group = store.add(new TestGroupWithElement(3)); + editorGroupsService.addTestGroup(group); + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled, true); + buildContribution(store, configService, layoutService, editorGroupsService); + + editorGroupsService.removeTestGroup(group); + + // After removal, mouseenter should not schedule focus (listener disposed) + fireMouseEnter(group.element); + await timeout(DELAY + 50); + + assert.strictEqual(group.focusCalls.length, 0, 'Removed group should not receive focus'); + })); + + // ------------------------------------------------------------------------- + // Runtime config change + // ------------------------------------------------------------------------- + + test('enables listeners when setting is toggled on at runtime', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled, false); + buildContribution(store, configService, layoutService, editorGroupsService); + + // Toggle on and notify the contribution + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled, true); + configService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: (key: string) => key === AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled, + } as unknown as IConfigurationChangeEvent); + + fireMouseEnter(sidebarEl); + await timeout(DELAY + 50); + + assert.strictEqual(layoutService.focusedParts.length, 1); + assert.strictEqual(layoutService.focusedParts[0], Parts.SIDEBAR_PART); + })); + + test('disables listeners when setting is toggled off at runtime', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled, true); + buildContribution(store, configService, layoutService, editorGroupsService); + + // Toggle off and notify the contribution + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled, false); + configService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: (key: string) => key === AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled, + } as unknown as IConfigurationChangeEvent); + + fireMouseEnter(sidebarEl); + await timeout(DELAY + 50); + + assert.strictEqual(layoutService.focusedParts.length, 0, 'No focus should fire after feature is disabled'); + })); + + // ------------------------------------------------------------------------- + // Configurable debounce delay + // ------------------------------------------------------------------------- + + test('respects a custom configured delay', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const customDelay = 500; + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled, true); + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseDelay, customDelay); + buildContribution(store, configService, layoutService, editorGroupsService); + + fireMouseEnter(sidebarEl); + + // Still pending at the old default delay + await timeout(DELAY + 50); + assert.strictEqual(layoutService.focusedParts.length, 0, 'Focus should not fire before the custom delay elapses'); + + // Fires once the custom delay has elapsed + await timeout(customDelay); + assert.strictEqual(layoutService.focusedParts[0], Parts.SIDEBAR_PART); + })); + + test('picks up a delay change at runtime without a config event', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled, true); + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseDelay, 100); + buildContribution(store, configService, layoutService, editorGroupsService); + + // Change the delay; the next schedule reads it live (no enable/disable needed) + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseDelay, 400); + + fireMouseEnter(sidebarEl); + await timeout(150); + assert.strictEqual(layoutService.focusedParts.length, 0, 'Focus should respect the updated delay'); + + await timeout(300); + assert.strictEqual(layoutService.focusedParts[0], Parts.SIDEBAR_PART); + })); + + test('falls back to the default delay when configured value is invalid', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseEnabled, true); + configService.setUserConfiguration(AccessibilityWorkbenchSettingId.FocusFollowsMouseDelay, -1); + buildContribution(store, configService, layoutService, editorGroupsService); + + fireMouseEnter(sidebarEl); + await timeout(DELAY + 50); + + assert.strictEqual(layoutService.focusedParts[0], Parts.SIDEBAR_PART); + })); +});