mirror of
https://github.com/Microsoft/vscode
synced 2024-08-28 13:29:44 +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.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Emitter } from 'vs/base/common/event';
|
||||
import { KeyCode, KeyMod } from 'vs/base/common/keyCodes';
|
||||
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import { firstNonWhitespaceIndex } from 'vs/base/common/strings';
|
||||
|
@ -37,6 +38,9 @@ export class GhostTextController extends Disposable {
|
|||
return this.activeController.value?.model;
|
||||
}
|
||||
|
||||
private readonly activeModelDidChangeEmitter = this._register(new Emitter<void>());
|
||||
public readonly onActiveModelDidChange = this.activeModelDidChangeEmitter.event;
|
||||
|
||||
constructor(
|
||||
public readonly editor: ICodeEditor,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService
|
||||
|
@ -71,6 +75,7 @@ export class GhostTextController extends Disposable {
|
|||
this.editor
|
||||
)
|
||||
: undefined;
|
||||
this.activeModelDidChangeEmitter.fire();
|
||||
}
|
||||
|
||||
public shouldShowHoverAt(hoverRange: Range): boolean {
|
||||
|
|
|
@ -5,9 +5,9 @@
|
|||
|
||||
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
|
||||
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 { Emitter, Event } from 'vs/base/common/event';
|
||||
import { Event } from 'vs/base/common/event';
|
||||
import { ICodeEditor, isCodeEditor, isDiffEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
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 { IMarkerService, MarkerSeverity } from 'vs/platform/markers/common/markers';
|
||||
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 { 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 readonly store = this.add(new DisposableStore());
|
||||
private readonly store = this._register(new DisposableStore());
|
||||
|
||||
constructor(
|
||||
@IEditorService private readonly editorService: IEditorService,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@IAccessibilityService private readonly accessibilityService: IAccessibilityService,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
) {
|
||||
|
@ -35,8 +36,8 @@ export class AudioCueContribution extends DisposableStore implements IWorkbenchC
|
|||
this.updateAudioCuesEnabled();
|
||||
});
|
||||
|
||||
this.add(
|
||||
_configurationService.onDidChangeConfiguration((e) => {
|
||||
this._register(
|
||||
configurationService.onDidChangeConfiguration((e) => {
|
||||
if (e.affectsConfiguration('audioCues.enabled')) {
|
||||
this.updateAudioCuesEnabled();
|
||||
}
|
||||
|
@ -47,7 +48,7 @@ export class AudioCueContribution extends DisposableStore implements IWorkbenchC
|
|||
}
|
||||
|
||||
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') {
|
||||
return true;
|
||||
} else if (value === 'auto') {
|
||||
|
@ -81,96 +82,90 @@ export class AudioCueContribution extends DisposableStore implements IWorkbenchC
|
|||
? activeTextEditorControl
|
||||
: undefined;
|
||||
|
||||
if (editor) {
|
||||
this.handleCurrentEditor(editor, store);
|
||||
if (editor && editor.hasModel()) {
|
||||
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[] = [
|
||||
this.instantiationService.createInstance(ErrorFeature),
|
||||
this.instantiationService.createInstance(FoldedAreaFeature),
|
||||
this.instantiationService.createInstance(BreakpointFeature),
|
||||
this.instantiationService.createInstance(InlineCompletionFeature),
|
||||
];
|
||||
|
||||
const featuresPerEditor = new Map(
|
||||
features.map((feature) => [
|
||||
feature,
|
||||
feature.createForEditor(editor, editor.getModel()!.uri),
|
||||
])
|
||||
const observableFeatureStates = features.map((feature) =>
|
||||
feature.getObservableState(editor, editorModel)
|
||||
);
|
||||
|
||||
interface State {
|
||||
lineNumber: number;
|
||||
featureStates: Map<Feature, boolean>;
|
||||
}
|
||||
const curLineNumber = fromEvent(
|
||||
editor.onDidChangeCursorPosition,
|
||||
() => editor.getPosition()?.lineNumber
|
||||
);
|
||||
const debouncedLineNumber = debouncedObservable(curLineNumber, 100, store);
|
||||
|
||||
const computeNewState = (): State | undefined => {
|
||||
if (!editor.hasModel()) {
|
||||
const lineNumberWithObservableFeatures = debouncedLineNumber.map(
|
||||
(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;
|
||||
}
|
||||
const position = editor.getPosition();
|
||||
|
||||
const lineNumber = position.lineNumber;
|
||||
const featureStates = new Map(
|
||||
features.map((feature) => [
|
||||
feature,
|
||||
featuresPerEditor.get(feature)!.isActive(lineNumber),
|
||||
])
|
||||
);
|
||||
return {
|
||||
lineNumber,
|
||||
featureStates
|
||||
lineNumber: lineInfo.lineNumber,
|
||||
featureStates: new Map(
|
||||
lineInfo.featureStatesForLine.map((featureState, idx) => [
|
||||
features[idx],
|
||||
featureStatesBeforeTyping.read(reader)?.at(idx) ??
|
||||
featureState.read(reader),
|
||||
])
|
||||
),
|
||||
};
|
||||
};
|
||||
}, 'state');
|
||||
|
||||
let lastState: State | undefined;
|
||||
const updateState = () => {
|
||||
const newState = computeNewState();
|
||||
|
||||
for (const feature of features) {
|
||||
if (
|
||||
newState &&
|
||||
newState.featureStates.get(feature) &&
|
||||
(!lastState?.featureStates?.get(feature) ||
|
||||
newState.lineNumber !== lastState.lineNumber)
|
||||
) {
|
||||
this.playSound(feature.audioCueFilename);
|
||||
store.add(
|
||||
autorunDelta(state, ({ lastValue, newValue }) => {
|
||||
for (const feature of features) {
|
||||
if (
|
||||
newValue?.featureStates.get(feature) &&
|
||||
(!lastValue?.featureStates?.get(feature) ||
|
||||
newValue.lineNumber !== lastValue.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) {
|
||||
|
@ -197,82 +192,69 @@ export class AudioCueContribution extends DisposableStore implements IWorkbenchC
|
|||
|
||||
interface Feature {
|
||||
audioCueFilename: string;
|
||||
createForEditor(
|
||||
debounceWhileTyping?: boolean;
|
||||
getObservableState(
|
||||
editor: ICodeEditor,
|
||||
uri: URI
|
||||
): FeatureResult;
|
||||
model: ITextModel
|
||||
): IObservable<FeatureState>;
|
||||
}
|
||||
|
||||
interface FeatureResult {
|
||||
interface FeatureState {
|
||||
isActive(lineNumber: number): boolean;
|
||||
onChange?: Event<void>;
|
||||
}
|
||||
|
||||
class ErrorFeature implements Feature {
|
||||
public readonly audioCueFilename = 'error';
|
||||
public readonly debounceWhileTyping = true;
|
||||
|
||||
constructor(@IMarkerService private readonly markerService: IMarkerService) { }
|
||||
|
||||
createForEditor(
|
||||
editor: ICodeEditor,
|
||||
uri: URI
|
||||
): FeatureResult {
|
||||
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
|
||||
getObservableState(editor: ICodeEditor, model: ITextModel): IObservable<FeatureState> {
|
||||
return fromEvent(
|
||||
Event.filter(this.markerService.onMarkerChanged, (changedUris) =>
|
||||
changedUris.some((u) => u.toString() === model.uri.toString())
|
||||
),
|
||||
};
|
||||
() => ({
|
||||
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 {
|
||||
public readonly audioCueFilename = 'foldedAreas';
|
||||
|
||||
createForEditor(
|
||||
editor: ICodeEditor,
|
||||
uri: URI
|
||||
): FeatureResult {
|
||||
const emitter = new Emitter<void>();
|
||||
let foldingModel: FoldingModel | null = null;
|
||||
editor
|
||||
.getContribution<FoldingController>(FoldingController.ID)
|
||||
?.getFoldingModel()
|
||||
?.then((newFoldingModel) => {
|
||||
foldingModel = newFoldingModel;
|
||||
emitter.fire();
|
||||
getObservableState(editor: ICodeEditor, model: ITextModel): IObservable<FeatureState> {
|
||||
const foldingController = FoldingController.get(editor);
|
||||
if (!foldingController) {
|
||||
return constObservable({
|
||||
isActive: () => false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isActive: lineNumber => {
|
||||
const regionAtLine = foldingModel?.getRegionAtLine(lineNumber);
|
||||
const foldingModel = fromPromise(
|
||||
foldingController.getFoldingModel() ?? Promise.resolve(undefined)
|
||||
);
|
||||
return foldingModel.map((v) => ({
|
||||
isActive: (lineNumber) => {
|
||||
const regionAtLine = v.value?.getRegionAtLine(lineNumber);
|
||||
const hasFolding = !regionAtLine
|
||||
? false
|
||||
: regionAtLine.isCollapsed &&
|
||||
regionAtLine.startLineNumber === lineNumber;
|
||||
return hasFolding;
|
||||
},
|
||||
onChange: emitter.event,
|
||||
};
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -281,22 +263,52 @@ class BreakpointFeature implements Feature {
|
|||
|
||||
constructor(@IDebugService private readonly debugService: IDebugService) { }
|
||||
|
||||
createForEditor(
|
||||
editor: ICodeEditor,
|
||||
uri: URI
|
||||
): FeatureResult {
|
||||
return {
|
||||
isActive: (lineNumber) => {
|
||||
const breakpoints = this.debugService
|
||||
.getModel()
|
||||
.getBreakpoints({ uri, lineNumber });
|
||||
const hasBreakpoints = breakpoints.length > 0;
|
||||
return hasBreakpoints;
|
||||
},
|
||||
onChange: Event.map(
|
||||
this.debugService.getModel().onDidChangeBreakpoints,
|
||||
() => undefined
|
||||
),
|
||||
};
|
||||
getObservableState(editor: ICodeEditor, model: ITextModel): IObservable<FeatureState> {
|
||||
return fromEvent(
|
||||
this.debugService.getModel().onDidChangeBreakpoints,
|
||||
() => ({
|
||||
isActive: (lineNumber) => {
|
||||
const breakpoints = this.debugService
|
||||
.getModel()
|
||||
.getBreakpoints({ uri: model.uri, lineNumber });
|
||||
const hasBreakpoints = breakpoints.length > 0;
|
||||
return hasBreakpoints;
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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