Iterates on audio cues.

This commit is contained in:
Henning Dieterichs 2022-02-11 12:20:27 +01:00
parent 6e5373e758
commit e041149fd1
No known key found for this signature in database
GPG key ID: 771381EFFDB9EC06
3 changed files with 739 additions and 147 deletions

View file

@ -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 {

View file

@ -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');
} }
} }

View 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;
}