mirror of
https://github.com/Microsoft/vscode
synced 2024-09-13 21:55:38 +00:00
Iterates on audio cues.
This commit is contained in:
parent
6e5373e758
commit
e041149fd1
|
@ -3,6 +3,7 @@
|
||||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||||
*--------------------------------------------------------------------------------------------*/
|
*--------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
import { Emitter } from 'vs/base/common/event';
|
||||||
import { KeyCode, KeyMod } from 'vs/base/common/keyCodes';
|
import { KeyCode, KeyMod } from 'vs/base/common/keyCodes';
|
||||||
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||||
import { firstNonWhitespaceIndex } from 'vs/base/common/strings';
|
import { firstNonWhitespaceIndex } from 'vs/base/common/strings';
|
||||||
|
@ -37,6 +38,9 @@ export class GhostTextController extends Disposable {
|
||||||
return this.activeController.value?.model;
|
return this.activeController.value?.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly activeModelDidChangeEmitter = this._register(new Emitter<void>());
|
||||||
|
public readonly onActiveModelDidChange = this.activeModelDidChangeEmitter.event;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly editor: ICodeEditor,
|
public readonly editor: ICodeEditor,
|
||||||
@IInstantiationService private readonly instantiationService: IInstantiationService
|
@IInstantiationService private readonly instantiationService: IInstantiationService
|
||||||
|
@ -71,6 +75,7 @@ export class GhostTextController extends Disposable {
|
||||||
this.editor
|
this.editor
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
this.activeModelDidChangeEmitter.fire();
|
||||||
}
|
}
|
||||||
|
|
||||||
public shouldShowHoverAt(hoverRange: Range): boolean {
|
public shouldShowHoverAt(hoverRange: Range): boolean {
|
||||||
|
|
|
@ -5,9 +5,9 @@
|
||||||
|
|
||||||
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
|
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
|
||||||
import { IDebugService } from 'vs/workbench/contrib/debug/common/debug';
|
import { IDebugService } from 'vs/workbench/contrib/debug/common/debug';
|
||||||
import { DisposableStore } from 'vs/base/common/lifecycle';
|
import { Disposable, DisposableStore } from 'vs/base/common/lifecycle';
|
||||||
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
||||||
import { Emitter, Event } from 'vs/base/common/event';
|
import { Event } from 'vs/base/common/event';
|
||||||
import { ICodeEditor, isCodeEditor, isDiffEditor } from 'vs/editor/browser/editorBrowser';
|
import { ICodeEditor, isCodeEditor, isDiffEditor } from 'vs/editor/browser/editorBrowser';
|
||||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||||
import { raceTimeout } from 'vs/base/common/async';
|
import { raceTimeout } from 'vs/base/common/async';
|
||||||
|
@ -15,17 +15,18 @@ import { FileAccess } from 'vs/base/common/network';
|
||||||
import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility';
|
import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility';
|
||||||
import { IMarkerService, MarkerSeverity } from 'vs/platform/markers/common/markers';
|
import { IMarkerService, MarkerSeverity } from 'vs/platform/markers/common/markers';
|
||||||
import { FoldingController } from 'vs/editor/contrib/folding/browser/folding';
|
import { FoldingController } from 'vs/editor/contrib/folding/browser/folding';
|
||||||
import { FoldingModel } from 'vs/editor/contrib/folding/browser/foldingModel';
|
|
||||||
import { URI } from 'vs/base/common/uri';
|
|
||||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||||
|
import { autorunDelta, constObservable, debouncedObservable, fromEvent, fromPromise, IObservable, LazyDerived, wasEventTriggeredRecently } from 'vs/workbench/contrib/audioCues/browser/observable';
|
||||||
|
import { ITextModel } from 'vs/editor/common/model';
|
||||||
|
import { GhostTextController } from 'vs/editor/contrib/inlineCompletions/browser/ghostTextController';
|
||||||
|
|
||||||
export class AudioCueContribution extends DisposableStore implements IWorkbenchContribution {
|
export class AudioCueContribution extends Disposable implements IWorkbenchContribution {
|
||||||
private audioCuesEnabled = false;
|
private audioCuesEnabled = false;
|
||||||
private readonly store = this.add(new DisposableStore());
|
private readonly store = this._register(new DisposableStore());
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@IEditorService private readonly editorService: IEditorService,
|
@IEditorService private readonly editorService: IEditorService,
|
||||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||||
@IAccessibilityService private readonly accessibilityService: IAccessibilityService,
|
@IAccessibilityService private readonly accessibilityService: IAccessibilityService,
|
||||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||||
) {
|
) {
|
||||||
|
@ -35,8 +36,8 @@ export class AudioCueContribution extends DisposableStore implements IWorkbenchC
|
||||||
this.updateAudioCuesEnabled();
|
this.updateAudioCuesEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.add(
|
this._register(
|
||||||
_configurationService.onDidChangeConfiguration((e) => {
|
configurationService.onDidChangeConfiguration((e) => {
|
||||||
if (e.affectsConfiguration('audioCues.enabled')) {
|
if (e.affectsConfiguration('audioCues.enabled')) {
|
||||||
this.updateAudioCuesEnabled();
|
this.updateAudioCuesEnabled();
|
||||||
}
|
}
|
||||||
|
@ -47,7 +48,7 @@ export class AudioCueContribution extends DisposableStore implements IWorkbenchC
|
||||||
}
|
}
|
||||||
|
|
||||||
private getAudioCuesEnabled(): boolean {
|
private getAudioCuesEnabled(): boolean {
|
||||||
const value = this._configurationService.getValue<'auto' | 'on' | 'off'>('audioCues.enabled');
|
const value = this.configurationService.getValue<'auto' | 'on' | 'off'>('audioCues.enabled');
|
||||||
if (value === 'on') {
|
if (value === 'on') {
|
||||||
return true;
|
return true;
|
||||||
} else if (value === 'auto') {
|
} else if (value === 'auto') {
|
||||||
|
@ -81,96 +82,90 @@ export class AudioCueContribution extends DisposableStore implements IWorkbenchC
|
||||||
? activeTextEditorControl
|
? activeTextEditorControl
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (editor) {
|
if (editor && editor.hasModel()) {
|
||||||
this.handleCurrentEditor(editor, store);
|
this.handleCurrentEditor(editor, editor.getModel(), store);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleCurrentEditor(editor: ICodeEditor, store: DisposableStore): void {
|
private handleCurrentEditor(editor: ICodeEditor, editorModel: ITextModel, store: DisposableStore): void {
|
||||||
const features: Feature[] = [
|
const features: Feature[] = [
|
||||||
this.instantiationService.createInstance(ErrorFeature),
|
this.instantiationService.createInstance(ErrorFeature),
|
||||||
this.instantiationService.createInstance(FoldedAreaFeature),
|
this.instantiationService.createInstance(FoldedAreaFeature),
|
||||||
this.instantiationService.createInstance(BreakpointFeature),
|
this.instantiationService.createInstance(BreakpointFeature),
|
||||||
|
this.instantiationService.createInstance(InlineCompletionFeature),
|
||||||
];
|
];
|
||||||
|
const observableFeatureStates = features.map((feature) =>
|
||||||
const featuresPerEditor = new Map(
|
feature.getObservableState(editor, editorModel)
|
||||||
features.map((feature) => [
|
|
||||||
feature,
|
|
||||||
feature.createForEditor(editor, editor.getModel()!.uri),
|
|
||||||
])
|
|
||||||
);
|
);
|
||||||
|
|
||||||
interface State {
|
const curLineNumber = fromEvent(
|
||||||
lineNumber: number;
|
editor.onDidChangeCursorPosition,
|
||||||
featureStates: Map<Feature, boolean>;
|
() => editor.getPosition()?.lineNumber
|
||||||
}
|
);
|
||||||
|
const debouncedLineNumber = debouncedObservable(curLineNumber, 100, store);
|
||||||
|
|
||||||
const computeNewState = (): State | undefined => {
|
const lineNumberWithObservableFeatures = debouncedLineNumber.map(
|
||||||
if (!editor.hasModel()) {
|
(lineNumber) => lineNumber === undefined ? undefined : {
|
||||||
|
lineNumber,
|
||||||
|
featureStatesForLine: observableFeatureStates.map(
|
||||||
|
(featureResult) =>
|
||||||
|
// This caches the feature state for the active line
|
||||||
|
new LazyDerived(
|
||||||
|
(reader) => featureResult.read(reader).isActive(lineNumber),
|
||||||
|
'isActiveForLine'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const isTyping = wasEventTriggeredRecently(
|
||||||
|
editorModel.onDidChangeContent.bind(editorModel),
|
||||||
|
1000,
|
||||||
|
store
|
||||||
|
);
|
||||||
|
const featureStatesBeforeTyping = isTyping.map((isTyping) =>
|
||||||
|
!isTyping
|
||||||
|
? undefined
|
||||||
|
: lineNumberWithObservableFeatures
|
||||||
|
.get()
|
||||||
|
?.featureStatesForLine?.map((featureState, idx) =>
|
||||||
|
features[idx].debounceWhileTyping ? featureState.get() : undefined
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const state = new LazyDerived(reader => {
|
||||||
|
const lineInfo = lineNumberWithObservableFeatures.read(reader);
|
||||||
|
if (lineInfo === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const position = editor.getPosition();
|
|
||||||
|
|
||||||
const lineNumber = position.lineNumber;
|
|
||||||
const featureStates = new Map(
|
|
||||||
features.map((feature) => [
|
|
||||||
feature,
|
|
||||||
featuresPerEditor.get(feature)!.isActive(lineNumber),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
lineNumber,
|
lineNumber: lineInfo.lineNumber,
|
||||||
featureStates
|
featureStates: new Map(
|
||||||
|
lineInfo.featureStatesForLine.map((featureState, idx) => [
|
||||||
|
features[idx],
|
||||||
|
featureStatesBeforeTyping.read(reader)?.at(idx) ??
|
||||||
|
featureState.read(reader),
|
||||||
|
])
|
||||||
|
),
|
||||||
};
|
};
|
||||||
};
|
}, 'state');
|
||||||
|
|
||||||
let lastState: State | undefined;
|
store.add(
|
||||||
const updateState = () => {
|
autorunDelta(state, ({ lastValue, newValue }) => {
|
||||||
const newState = computeNewState();
|
for (const feature of features) {
|
||||||
|
if (
|
||||||
for (const feature of features) {
|
newValue?.featureStates.get(feature) &&
|
||||||
if (
|
(!lastValue?.featureStates?.get(feature) ||
|
||||||
newState &&
|
newValue.lineNumber !== lastValue.lineNumber)
|
||||||
newState.featureStates.get(feature) &&
|
) {
|
||||||
(!lastState?.featureStates?.get(feature) ||
|
this.playSound(feature.audioCueFilename);
|
||||||
newState.lineNumber !== lastState.lineNumber)
|
}
|
||||||
) {
|
|
||||||
this.playSound(feature.audioCueFilename);
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
);
|
||||||
lastState = newState;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const feature of featuresPerEditor.values()) {
|
|
||||||
if (feature.onChange) {
|
|
||||||
store.add(feature.onChange(updateState));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let lastLineNumber = -1;
|
|
||||||
store.add(
|
|
||||||
editor.onDidChangeCursorPosition(() => {
|
|
||||||
const position = editor.getPosition();
|
|
||||||
if (!position) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lineNumber = position.lineNumber;
|
|
||||||
if (lineNumber === lastLineNumber) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastLineNumber = lineNumber;
|
|
||||||
|
|
||||||
updateState();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async playSound(fileName: string) {
|
private async playSound(fileName: string) {
|
||||||
|
@ -197,82 +192,69 @@ export class AudioCueContribution extends DisposableStore implements IWorkbenchC
|
||||||
|
|
||||||
interface Feature {
|
interface Feature {
|
||||||
audioCueFilename: string;
|
audioCueFilename: string;
|
||||||
createForEditor(
|
debounceWhileTyping?: boolean;
|
||||||
|
getObservableState(
|
||||||
editor: ICodeEditor,
|
editor: ICodeEditor,
|
||||||
uri: URI
|
model: ITextModel
|
||||||
): FeatureResult;
|
): IObservable<FeatureState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FeatureResult {
|
interface FeatureState {
|
||||||
isActive(lineNumber: number): boolean;
|
isActive(lineNumber: number): boolean;
|
||||||
onChange?: Event<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ErrorFeature implements Feature {
|
class ErrorFeature implements Feature {
|
||||||
public readonly audioCueFilename = 'error';
|
public readonly audioCueFilename = 'error';
|
||||||
|
public readonly debounceWhileTyping = true;
|
||||||
|
|
||||||
constructor(@IMarkerService private readonly markerService: IMarkerService) { }
|
constructor(@IMarkerService private readonly markerService: IMarkerService) { }
|
||||||
|
|
||||||
createForEditor(
|
getObservableState(editor: ICodeEditor, model: ITextModel): IObservable<FeatureState> {
|
||||||
editor: ICodeEditor,
|
return fromEvent(
|
||||||
uri: URI
|
Event.filter(this.markerService.onMarkerChanged, (changedUris) =>
|
||||||
): FeatureResult {
|
changedUris.some((u) => u.toString() === model.uri.toString())
|
||||||
return {
|
|
||||||
isActive: (lineNumber) => {
|
|
||||||
const hasMarker = this.markerService
|
|
||||||
.read({ resource: uri })
|
|
||||||
.some(
|
|
||||||
(m) =>
|
|
||||||
m.severity === MarkerSeverity.Error &&
|
|
||||||
m.startLineNumber <= lineNumber &&
|
|
||||||
lineNumber <= m.endLineNumber
|
|
||||||
);
|
|
||||||
return hasMarker;
|
|
||||||
},
|
|
||||||
onChange: Event.map(
|
|
||||||
Event.filter(
|
|
||||||
this.markerService.onMarkerChanged,
|
|
||||||
(changedUris) => {
|
|
||||||
const curUri = editor.getModel()?.uri?.toString();
|
|
||||||
return (
|
|
||||||
!!curUri && changedUris.some((u) => u.toString() === curUri)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
(x) => undefined
|
|
||||||
),
|
),
|
||||||
};
|
() => ({
|
||||||
|
isActive: (lineNumber) => {
|
||||||
|
const hasMarker = this.markerService
|
||||||
|
.read({ resource: model.uri })
|
||||||
|
.some(
|
||||||
|
(m) =>
|
||||||
|
m.severity === MarkerSeverity.Error &&
|
||||||
|
m.startLineNumber <= lineNumber &&
|
||||||
|
lineNumber <= m.endLineNumber
|
||||||
|
);
|
||||||
|
return hasMarker;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FoldedAreaFeature implements Feature {
|
class FoldedAreaFeature implements Feature {
|
||||||
public readonly audioCueFilename = 'foldedAreas';
|
public readonly audioCueFilename = 'foldedAreas';
|
||||||
|
|
||||||
createForEditor(
|
getObservableState(editor: ICodeEditor, model: ITextModel): IObservable<FeatureState> {
|
||||||
editor: ICodeEditor,
|
const foldingController = FoldingController.get(editor);
|
||||||
uri: URI
|
if (!foldingController) {
|
||||||
): FeatureResult {
|
return constObservable({
|
||||||
const emitter = new Emitter<void>();
|
isActive: () => false,
|
||||||
let foldingModel: FoldingModel | null = null;
|
|
||||||
editor
|
|
||||||
.getContribution<FoldingController>(FoldingController.ID)
|
|
||||||
?.getFoldingModel()
|
|
||||||
?.then((newFoldingModel) => {
|
|
||||||
foldingModel = newFoldingModel;
|
|
||||||
emitter.fire();
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
const foldingModel = fromPromise(
|
||||||
isActive: lineNumber => {
|
foldingController.getFoldingModel() ?? Promise.resolve(undefined)
|
||||||
const regionAtLine = foldingModel?.getRegionAtLine(lineNumber);
|
);
|
||||||
|
return foldingModel.map((v) => ({
|
||||||
|
isActive: (lineNumber) => {
|
||||||
|
const regionAtLine = v.value?.getRegionAtLine(lineNumber);
|
||||||
const hasFolding = !regionAtLine
|
const hasFolding = !regionAtLine
|
||||||
? false
|
? false
|
||||||
: regionAtLine.isCollapsed &&
|
: regionAtLine.isCollapsed &&
|
||||||
regionAtLine.startLineNumber === lineNumber;
|
regionAtLine.startLineNumber === lineNumber;
|
||||||
return hasFolding;
|
return hasFolding;
|
||||||
},
|
},
|
||||||
onChange: emitter.event,
|
}));
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -281,22 +263,52 @@ class BreakpointFeature implements Feature {
|
||||||
|
|
||||||
constructor(@IDebugService private readonly debugService: IDebugService) { }
|
constructor(@IDebugService private readonly debugService: IDebugService) { }
|
||||||
|
|
||||||
createForEditor(
|
getObservableState(editor: ICodeEditor, model: ITextModel): IObservable<FeatureState> {
|
||||||
editor: ICodeEditor,
|
return fromEvent(
|
||||||
uri: URI
|
this.debugService.getModel().onDidChangeBreakpoints,
|
||||||
): FeatureResult {
|
() => ({
|
||||||
return {
|
isActive: (lineNumber) => {
|
||||||
isActive: (lineNumber) => {
|
const breakpoints = this.debugService
|
||||||
const breakpoints = this.debugService
|
.getModel()
|
||||||
.getModel()
|
.getBreakpoints({ uri: model.uri, lineNumber });
|
||||||
.getBreakpoints({ uri, lineNumber });
|
const hasBreakpoints = breakpoints.length > 0;
|
||||||
const hasBreakpoints = breakpoints.length > 0;
|
return hasBreakpoints;
|
||||||
return hasBreakpoints;
|
},
|
||||||
},
|
})
|
||||||
onChange: Event.map(
|
);
|
||||||
this.debugService.getModel().onDidChangeBreakpoints,
|
}
|
||||||
() => undefined
|
}
|
||||||
),
|
|
||||||
};
|
class InlineCompletionFeature implements Feature {
|
||||||
|
public readonly audioCueFilename = 'break';
|
||||||
|
|
||||||
|
getObservableState(editor: ICodeEditor, model: ITextModel): IObservable<FeatureState> {
|
||||||
|
const ghostTextController = GhostTextController.get(editor);
|
||||||
|
if (!ghostTextController) {
|
||||||
|
return constObservable({
|
||||||
|
isActive: () => false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeGhostText = fromEvent(
|
||||||
|
ghostTextController.onActiveModelDidChange,
|
||||||
|
() => ghostTextController.activeModel
|
||||||
|
).map((activeModel) => (
|
||||||
|
activeModel
|
||||||
|
? fromEvent(
|
||||||
|
activeModel.inlineCompletionsModel.onDidChange,
|
||||||
|
() => activeModel.inlineCompletionsModel.ghostText
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
));
|
||||||
|
|
||||||
|
return new LazyDerived(reader => {
|
||||||
|
const ghostText = activeGhostText.read(reader)?.read(reader);
|
||||||
|
return {
|
||||||
|
isActive(lineNumber) {
|
||||||
|
return ghostText?.lineNumber === lineNumber;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, 'ghostText');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
575
src/vs/workbench/contrib/audioCues/browser/observable.ts
Normal file
575
src/vs/workbench/contrib/audioCues/browser/observable.ts
Normal file
|
@ -0,0 +1,575 @@
|
||||||
|
/*---------------------------------------------------------------------------------------------
|
||||||
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||||
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||||
|
*--------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
import { Event } from 'vs/base/common/event';
|
||||||
|
import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle';
|
||||||
|
|
||||||
|
export interface IObservable<T> {
|
||||||
|
/**
|
||||||
|
* Reads the current value.
|
||||||
|
*
|
||||||
|
* This causes a recomputation if needed.
|
||||||
|
* Calling this method forces changes to propagate to observers during update operations.
|
||||||
|
* Must not be called from {@link IObserver.handleChange}.
|
||||||
|
*/
|
||||||
|
get(): T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers an observer.
|
||||||
|
*
|
||||||
|
* Calls {@link IObserver.handleChange} immediately after a change is noticed.
|
||||||
|
* Might happen while someone calls {@link IObservable.get} or {@link IObservable.read}.
|
||||||
|
*/
|
||||||
|
subscribe(observer: IObserver): void;
|
||||||
|
unsubscribe(observer: IObserver): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls {@link IObservable.get} and then {@link IReader.handleBeforeReadObservable}.
|
||||||
|
*/
|
||||||
|
read(reader: IReader): T;
|
||||||
|
|
||||||
|
map<TNew>(fn: (value: T) => TNew): IObservable<TNew>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReader {
|
||||||
|
/**
|
||||||
|
* Reports an observable that was read.
|
||||||
|
*
|
||||||
|
* Is called by `Observable.read`.
|
||||||
|
*/
|
||||||
|
handleBeforeReadObservable<T>(observable: IObservable<T>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IObserver {
|
||||||
|
/**
|
||||||
|
* Indicates that an update operation is about to begin.
|
||||||
|
*
|
||||||
|
* During an update, invariants might not hold for subscribed observables and
|
||||||
|
* change events might be delayed.
|
||||||
|
* However, all changes must be reported before all update operations are over.
|
||||||
|
*/
|
||||||
|
beginUpdate<T>(observable: IObservable<T>): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is called by a subscribed observable immediately after it notices a change.
|
||||||
|
*
|
||||||
|
* When {@link IObservable.get} returns and no change has been reported,
|
||||||
|
* there has been no change for that observable.
|
||||||
|
*
|
||||||
|
* Implementations must not call into other observables!
|
||||||
|
* The change should be processed when {@link IObserver.endUpdate} is called.
|
||||||
|
*/
|
||||||
|
handleChange<T>(observable: IObservable<T>): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates that an update operation has completed.
|
||||||
|
*/
|
||||||
|
endUpdate<T>(observable: IObservable<T>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISettable<T> {
|
||||||
|
set(value: T, transaction: ITransaction | undefined): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITransaction {
|
||||||
|
/**
|
||||||
|
* Calls `Observer.beginUpdate` immediately
|
||||||
|
* and `Observer.endUpdate` when the transaction is complete.
|
||||||
|
*/
|
||||||
|
updateObserver(
|
||||||
|
observer: IObserver,
|
||||||
|
observable: IObservable<any>
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Base ===
|
||||||
|
export abstract class ConvenientObservable<T> implements IObservable<T> {
|
||||||
|
public abstract get(): T;
|
||||||
|
public abstract subscribe(observer: IObserver): void;
|
||||||
|
public abstract unsubscribe(observer: IObserver): void;
|
||||||
|
|
||||||
|
public read(reader: IReader): T {
|
||||||
|
reader.handleBeforeReadObservable(this);
|
||||||
|
return this.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public map<TNew>(fn: (value: T) => TNew): IObservable<TNew> {
|
||||||
|
return new LazyDerived((reader) => fn(this.read(reader)), '(mapped)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class BaseObservable<T> extends ConvenientObservable<T> {
|
||||||
|
protected readonly observers = new Set<IObserver>();
|
||||||
|
|
||||||
|
public subscribe(observer: IObserver): void {
|
||||||
|
const len = this.observers.size;
|
||||||
|
this.observers.add(observer);
|
||||||
|
if (len === 0) {
|
||||||
|
this.onFirstObserverSubscribed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsubscribe(observer: IObserver): void {
|
||||||
|
const deleted = this.observers.delete(observer);
|
||||||
|
if (deleted && this.observers.size === 0) {
|
||||||
|
this.onLastObserverUnsubscribed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onFirstObserverSubscribed(): void { }
|
||||||
|
protected onLastObserverUnsubscribed(): void { }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transaction(fn: (tx: ITransaction) => void) {
|
||||||
|
const tx = new TransactionImpl();
|
||||||
|
try {
|
||||||
|
fn(tx);
|
||||||
|
} finally {
|
||||||
|
tx.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TransactionImpl implements ITransaction {
|
||||||
|
private readonly finishActions = new Array<() => void>();
|
||||||
|
|
||||||
|
public updateObserver(
|
||||||
|
observer: IObserver,
|
||||||
|
observable: IObservable<any>
|
||||||
|
): void {
|
||||||
|
this.finishActions.push(function () {
|
||||||
|
observer.endUpdate(observable);
|
||||||
|
});
|
||||||
|
observer.beginUpdate(observable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public finish(): void {
|
||||||
|
for (const action of this.finishActions) {
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ObservableValue<T>
|
||||||
|
extends BaseObservable<T>
|
||||||
|
implements ISettable<T>
|
||||||
|
{
|
||||||
|
private value: T;
|
||||||
|
|
||||||
|
constructor(initialValue: T, public readonly name: string) {
|
||||||
|
super();
|
||||||
|
this.value = initialValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(): T {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set(value: T, tx: ITransaction | undefined): void {
|
||||||
|
if (this.value === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tx) {
|
||||||
|
transaction((tx) => {
|
||||||
|
this.set(value, tx);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.value = value;
|
||||||
|
|
||||||
|
for (const observer of this.observers) {
|
||||||
|
tx.updateObserver(observer, this);
|
||||||
|
observer.handleChange(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function constObservable<T>(value: T): IObservable<T> {
|
||||||
|
return new ConstObservable(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConstObservable<T> extends ConvenientObservable<T> {
|
||||||
|
constructor(private readonly value: T) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(): T {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
public subscribe(observer: IObserver): void {
|
||||||
|
// NO OP
|
||||||
|
}
|
||||||
|
public unsubscribe(observer: IObserver): void {
|
||||||
|
// NO OP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// == autorun ==
|
||||||
|
export function autorun(
|
||||||
|
fn: (reader: IReader) => void,
|
||||||
|
name: string
|
||||||
|
): IDisposable {
|
||||||
|
return new AutorunObserver(fn, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AutorunObserver implements IObserver, IReader, IDisposable {
|
||||||
|
public needsToRun = true;
|
||||||
|
private updateCount = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The actual dependencies.
|
||||||
|
*/
|
||||||
|
private _dependencies = new Set<IObservable<any>>();
|
||||||
|
public get dependencies() {
|
||||||
|
return this._dependencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dependencies that have to be removed when {@link runFn} ran through.
|
||||||
|
*/
|
||||||
|
private staleDependencies = new Set<IObservable<any>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly runFn: (reader: IReader) => void,
|
||||||
|
public readonly name: string
|
||||||
|
) {
|
||||||
|
this.runIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleBeforeReadObservable<T>(observable: IObservable<T>) {
|
||||||
|
this._dependencies.add(observable);
|
||||||
|
if (!this.staleDependencies.delete(observable)) {
|
||||||
|
observable.subscribe(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleChange() {
|
||||||
|
this.needsToRun = true;
|
||||||
|
|
||||||
|
if (this.updateCount === 0) {
|
||||||
|
this.runIfNeeded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public beginUpdate() {
|
||||||
|
this.updateCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public endUpdate() {
|
||||||
|
this.updateCount--;
|
||||||
|
if (this.updateCount === 0) {
|
||||||
|
this.runIfNeeded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private runIfNeeded(): void {
|
||||||
|
if (!this.needsToRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Assert: this.staleDependencies is an empty set.
|
||||||
|
const emptySet = this.staleDependencies;
|
||||||
|
this.staleDependencies = this._dependencies;
|
||||||
|
this._dependencies = emptySet;
|
||||||
|
|
||||||
|
this.needsToRun = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.runFn(this);
|
||||||
|
} finally {
|
||||||
|
// We don't want our observed observables to think that they are (not even temporarily) not being observed.
|
||||||
|
// Thus, we only unsubscribe from observables that are definitely not read anymore.
|
||||||
|
for (const o of this.staleDependencies) {
|
||||||
|
o.unsubscribe(this);
|
||||||
|
}
|
||||||
|
this.staleDependencies.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose() {
|
||||||
|
for (const o of this._dependencies) {
|
||||||
|
o.unsubscribe(this);
|
||||||
|
}
|
||||||
|
this._dependencies.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autorunDelta<T>(
|
||||||
|
observable: IObservable<T>,
|
||||||
|
handler: (args: { lastValue: T | undefined; newValue: T }) => void
|
||||||
|
): IDisposable {
|
||||||
|
let _lastValue: T | undefined;
|
||||||
|
return autorun((reader) => {
|
||||||
|
const newValue = observable.read(reader);
|
||||||
|
const lastValue = _lastValue;
|
||||||
|
_lastValue = newValue;
|
||||||
|
handler({ lastValue, newValue });
|
||||||
|
}, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// == Lazy Derived ==
|
||||||
|
|
||||||
|
export class LazyDerived<T> extends ConvenientObservable<T> {
|
||||||
|
private readonly observer: LazyDerivedObserver<T>;
|
||||||
|
|
||||||
|
constructor(computeFn: (reader: IReader) => T, name: string) {
|
||||||
|
super();
|
||||||
|
this.observer = new LazyDerivedObserver(computeFn, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribe(observer: IObserver): void {
|
||||||
|
this.observer.subscribe(observer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsubscribe(observer: IObserver): void {
|
||||||
|
this.observer.unsubscribe(observer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(): T {
|
||||||
|
return this.observer.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class LazyDerivedObserver<T>
|
||||||
|
extends BaseObservable<T>
|
||||||
|
implements IReader, IObserver {
|
||||||
|
private hadValue = false;
|
||||||
|
private hasValue = false;
|
||||||
|
private value: T | undefined = undefined;
|
||||||
|
private updateCount = 0;
|
||||||
|
|
||||||
|
private _dependencies = new Set<IObservable<any>>();
|
||||||
|
public get dependencies(): ReadonlySet<IObservable<any>> {
|
||||||
|
return this._dependencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dependencies that have to be removed when {@link runFn} ran through.
|
||||||
|
*/
|
||||||
|
private staleDependencies = new Set<IObservable<any>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly computeFn: (reader: IReader) => T,
|
||||||
|
public readonly name: string
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override onLastObserverUnsubscribed(): void {
|
||||||
|
/**
|
||||||
|
* We are not tracking changes anymore, thus we have to assume
|
||||||
|
* that our cache is invalid.
|
||||||
|
*/
|
||||||
|
this.hasValue = false;
|
||||||
|
this.hadValue = false;
|
||||||
|
this.value = undefined;
|
||||||
|
for (const d of this._dependencies) {
|
||||||
|
d.unsubscribe(this);
|
||||||
|
}
|
||||||
|
this._dependencies.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleBeforeReadObservable<T>(observable: IObservable<T>) {
|
||||||
|
this._dependencies.add(observable);
|
||||||
|
if (!this.staleDependencies.delete(observable)) {
|
||||||
|
observable.subscribe(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleChange() {
|
||||||
|
if (this.hasValue) {
|
||||||
|
this.hadValue = true;
|
||||||
|
this.hasValue = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in transaction: Recompute & inform observers immediately
|
||||||
|
if (this.updateCount === 0 && this.observers.size > 0) {
|
||||||
|
this.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, recompute in `endUpdate` or on demand.
|
||||||
|
}
|
||||||
|
|
||||||
|
public beginUpdate() {
|
||||||
|
if (this.updateCount === 0) {
|
||||||
|
for (const r of this.observers) {
|
||||||
|
r.beginUpdate(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.updateCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public endUpdate() {
|
||||||
|
this.updateCount--;
|
||||||
|
if (this.updateCount === 0) {
|
||||||
|
if (this.observers.size > 0) {
|
||||||
|
// Propagate invalidation
|
||||||
|
this.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const r of this.observers) {
|
||||||
|
r.endUpdate(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(): T {
|
||||||
|
if (this.observers.size === 0) {
|
||||||
|
// Cache is not valid and don't refresh the cache.
|
||||||
|
// Observables should not be read in non-reactive contexts.
|
||||||
|
return this.computeFn(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.updateCount > 0 && this.hasValue) {
|
||||||
|
// Refresh dependencies
|
||||||
|
for (const d of this._dependencies) {
|
||||||
|
// Maybe `.get()` triggers `handleChange`?
|
||||||
|
d.get();
|
||||||
|
if (!this.hasValue) {
|
||||||
|
// The other dependencies will refresh on demand
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.hasValue) {
|
||||||
|
const emptySet = this.staleDependencies;
|
||||||
|
this.staleDependencies = this._dependencies;
|
||||||
|
this._dependencies = emptySet;
|
||||||
|
|
||||||
|
const oldValue = this.value;
|
||||||
|
try {
|
||||||
|
this.value = this.computeFn(this);
|
||||||
|
} finally {
|
||||||
|
// We don't want our observed observables to think that they are (not even temporarily) not being observed.
|
||||||
|
// Thus, we only unsubscribe from observables that are definitely not read anymore.
|
||||||
|
for (const o of this.staleDependencies) {
|
||||||
|
o.unsubscribe(this);
|
||||||
|
}
|
||||||
|
this.staleDependencies.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hasValue = true;
|
||||||
|
if (this.hadValue && oldValue !== this.value) {
|
||||||
|
//
|
||||||
|
for (const r of this.observers) {
|
||||||
|
r.handleChange(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.value!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromPromise<T>(promise: Promise<T>): IObservable<{ value?: T }> {
|
||||||
|
const observable = new ObservableValue<{ value?: T }>({}, 'promiseValue');
|
||||||
|
promise.then((value) => {
|
||||||
|
observable.set({ value }, undefined);
|
||||||
|
});
|
||||||
|
return observable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromEvent<TArgs, T>(
|
||||||
|
event: Event<TArgs>,
|
||||||
|
getValue: (args: TArgs | undefined) => T
|
||||||
|
): IObservable<T> {
|
||||||
|
return new FromEventObservable(event, getValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
class FromEventObservable<TArgs, T> extends BaseObservable<T> {
|
||||||
|
private value: T | undefined;
|
||||||
|
private hasValue = false;
|
||||||
|
private subscription: IDisposable | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly event: Event<TArgs>,
|
||||||
|
private readonly getValue: (args: TArgs | undefined) => T
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override onFirstObserverSubscribed(): void {
|
||||||
|
this.subscription = this.event(this.handleEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly handleEvent = (args: TArgs | undefined) => {
|
||||||
|
const newValue = this.getValue(args);
|
||||||
|
if (this.value !== newValue) {
|
||||||
|
this.value = newValue;
|
||||||
|
|
||||||
|
if (this.hasValue) {
|
||||||
|
transaction(tx => {
|
||||||
|
for (const o of this.observers) {
|
||||||
|
tx.updateObserver(o, this);
|
||||||
|
o.handleChange(this);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.hasValue = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override onLastObserverUnsubscribed(): void {
|
||||||
|
this.subscription!.dispose();
|
||||||
|
this.subscription = undefined;
|
||||||
|
this.hasValue = false;
|
||||||
|
this.value = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(): T {
|
||||||
|
if (this.subscription) {
|
||||||
|
if (!this.hasValue) {
|
||||||
|
this.handleEvent(undefined);
|
||||||
|
}
|
||||||
|
return this.value!;
|
||||||
|
} else {
|
||||||
|
// no cache, as there are no subscribers to clean it up
|
||||||
|
return this.getValue(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debouncedObservable<T>(observable: IObservable<T>, debounceMs: number, disposableStore: DisposableStore): IObservable<T> {
|
||||||
|
const debouncedObservable = new ObservableValue(observable.get(), 'debounced');
|
||||||
|
|
||||||
|
let timeout: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
disposableStore.add(autorun(reader => {
|
||||||
|
const value = observable.read(reader);
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
transaction(tx => {
|
||||||
|
debouncedObservable.set(value, tx);
|
||||||
|
});
|
||||||
|
}, debounceMs);
|
||||||
|
}, 'debounce'));
|
||||||
|
|
||||||
|
return debouncedObservable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wasEventTriggeredRecently(event: Event<any>, timeoutMs: number, disposableStore: DisposableStore): IObservable<boolean> {
|
||||||
|
const observable = new ObservableValue(false, 'triggeredRecently');
|
||||||
|
|
||||||
|
let timeout: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
disposableStore.add(event(() => {
|
||||||
|
observable.set(true, undefined);
|
||||||
|
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
observable.set(false, undefined);
|
||||||
|
}, timeoutMs);
|
||||||
|
}));
|
||||||
|
|
||||||
|
return observable;
|
||||||
|
}
|
Loading…
Reference in a new issue