style: format state subscriptions and their adopted controls

This commit is contained in:
Sameh Khamis committed 2026-09-13 05:43:05 -07:00
1 parent 45d97376d0
commit e0652b19f2
12 files changed
+299 -88

No files matched your search

+32 -13
View File
@@ -539,13 +539,25 @@ export class StyleManager {
);
});
this._shareState = createStateChannel(() => this._readShareState());
this._shareState.subscribe(({ state }) => {
this.shareLinkManager.onToggleChange(state.bloomEnabled, state.sharpenEnabled, state.options);
}, { emitCurrent: false });
this._locationState = createStateChannel(() => this._locationLookup?.getState() || null);
this._locationState.subscribe(({ state, change }) => {
this._handleLocationSearchState(state, change);
}, { emitCurrent: false });
this._shareState.subscribe(
({ state }) => {
this.shareLinkManager.onToggleChange(
state.bloomEnabled,
state.sharpenEnabled,
state.options,
);
},
{ emitCurrent: false },
);
this._locationState = createStateChannel(
() => this._locationLookup?.getState() || null,
);
this._locationState.subscribe(
({ state, change }) => {
this._handleLocationSearchState(state, change);
},
{ emitCurrent: false },
);
// Parse before panel chrome initializes so every valid share URL starts
// from deterministic markup defaults instead of recipient-local panel
// preferences. Encoded panel fields are applied after all panels exist.
@@ -4513,7 +4525,8 @@ export class StyleManager {
_handleLocationSearchState(state, change) {
if (this._disposed || !change) return;
if (change.type === 'started') this._activeLocationSearchGeneration = change.generation;
if (change.type === 'started')
this._activeLocationSearchGeneration = change.generation;
else if (change.type === 'found') {
this._searchedLocationLabel = state.destination.label || state.query;
this._setActiveLocation(null);
@@ -4522,8 +4535,12 @@ export class StyleManager {
this._updateLocationMiniStatus();
} else if (change.type === 'missing') this._showToast('Location not found');
else if (change.type === 'failed') this._showToast('Search failed');
else if (change.type === 'settled') this._settleLocationSearchUi(change.generation);
else if (change.type === 'reset' && this._activeLocationSearchGeneration !== null) {
else if (change.type === 'settled')
this._settleLocationSearchUi(change.generation);
else if (
change.type === 'reset' &&
this._activeLocationSearchGeneration !== null
) {
this._settleLocationSearchUi(this._activeLocationSearchGeneration);
}
}
@@ -4552,9 +4569,11 @@ export class StyleManager {
}),
onError: (error) => console.error('[Search] Geocoding failed:', error),
});
this._locationLookupUnsubscribe = this._locationLookup.subscribe(({ initial, change }) => {
this._locationState.publish(initial ? { type: 'reset' } : change);
});
this._locationLookupUnsubscribe = this._locationLookup.subscribe(
({ initial, change }) => {
this._locationState.publish(initial ? { type: 'reset' } : change);
},
);
this._locationControls = new LocationControls({
elements: {
pills: this._locationPills,
+77 -14
View File
@@ -2,16 +2,50 @@ import { createStateChannel } from '../app/stateChannel.js';
/** One cancellable place lookup at a time, under the caller's camera authority. */
export class LocationSearch {
constructor({ input, begin, isCurrent, beforeFly, search, onStart, onResult, onMissing, onError, onSettled }) {
Object.assign(this, { input, begin, isCurrent, beforeFly, search, onStart, onResult, onMissing, onError, onSettled });
constructor({
input,
begin,
isCurrent,
beforeFly,
search,
onStart,
onResult,
onMissing,
onError,
onSettled,
}) {
Object.assign(this, {
input,
begin,
isCurrent,
beforeFly,
search,
onStart,
onResult,
onMissing,
onError,
onSettled,
});
this.controller = null;
this.generation = 0;
this.destroyed = false;
this.state = { status: 'idle', searching: false, query: '', generation: null, requestId: 0, destination: null, error: null };
this.state = {
status: 'idle',
searching: false,
query: '',
generation: null,
requestId: 0,
destination: null,
error: null,
};
this.channel = createStateChannel(() => this.state);
}
getState() { return this.channel.getSnapshot(); }
subscribe(listener, options) { return this.channel.subscribe(listener, options); }
getState() {
return this.channel.getSnapshot();
}
subscribe(listener, options) {
return this.channel.subscribe(listener, options);
}
async run(query) {
query = String(query || '').trim();
if (!query || this.destroyed) return;
@@ -25,9 +59,25 @@ export class LocationSearch {
const controller = new AbortController();
this.controller = controller;
const generation = ++this.generation;
const current = () => !this.destroyed && generation === this.generation && this.isCurrent(authority);
const change = (type) => ({ type, generation: authority, requestId: generation, query });
this.state = { status: 'searching', searching: true, query, generation: authority, requestId: generation, destination: null, error: null };
const current = () =>
!this.destroyed &&
generation === this.generation &&
this.isCurrent(authority);
const change = (type) => ({
type,
generation: authority,
requestId: generation,
query,
});
this.state = {
status: 'searching',
searching: true,
query,
generation: authority,
requestId: generation,
destination: null,
error: null,
};
try {
this.onStart?.(authority);
if (!current() || controller.signal.aborted) return;
@@ -43,7 +93,11 @@ export class LocationSearch {
if (destination) {
this.onResult?.(destination, query);
if (!current() || controller.signal.aborted) return;
this.state = { ...this.state, status: 'found', destination: { ...destination } };
this.state = {
...this.state,
status: 'found',
destination: { ...destination },
};
this.channel.publish(change('found'));
} else {
this.onMissing?.();
@@ -55,15 +109,24 @@ export class LocationSearch {
if (controller.signal.aborted || !current()) return;
this.onError?.(error);
if (!current() || controller.signal.aborted) return;
this.state = { ...this.state, status: 'failed', error: { message: String(error?.message || error) } };
this.state = {
...this.state,
status: 'failed',
error: { message: String(error?.message || error) },
};
this.channel.publish(change('failed'));
} finally {
if (this.controller === controller) this.controller = null;
if (!this.destroyed) {
if (generation === this.generation) this.state = {
...this.state, searching: false,
status: this.state.status === 'searching' ? 'cancelled' : this.state.status,
};
if (generation === this.generation)
this.state = {
...this.state,
searching: false,
status:
this.state.status === 'searching'
? 'cancelled'
: this.state.status,
};
this.onSettled?.(authority);
this.channel.publish(change('settled'));
}
+3 -2
View File
@@ -109,7 +109,6 @@ test('empty queries and refused navigation do not issue a lookup', async () => {
assert.ok(f.calls.some((x) => x[0] === 'blur'));
});
test('search snapshots retain the current busy owner when an older request settles', async () => {
const f = fixture();
const events = [];
@@ -128,7 +127,9 @@ test('search snapshots retain the current busy owner when an older request settl
const found = events.filter((event) => event.change?.type === 'found');
assert.equal(found.length, 1);
assert.equal(found[0].state.query, 'Second');
assert.throws(() => { found[0].state.destination.label = 'Changed'; }, TypeError);
assert.throws(() => {
found[0].state.destination.label = 'Changed';
}, TypeError);
assert.equal(f.search.getState().destination.label, 'Second');
assert.equal(f.search.getState().searching, false);
const count = events.length;
+46 -11
View File
@@ -20,8 +20,14 @@ export class SceneControls {
this.rowRemovers = [];
this.playbackKeyRemover = null;
if (!elements.select) return;
if (subscribe) this.unsubscribe = subscribe((notification) => this.present(notification));
else { this.renderSceneSelect(); this.renderShotList(); }
if (subscribe)
this.unsubscribe = subscribe((notification) =>
this.present(notification),
);
else {
this.renderSceneSelect();
this.renderShotList();
}
this.listen(elements.select, 'change', () =>
this.run('selectScene', elements.select.value),
);
@@ -58,18 +64,47 @@ export class SceneControls {
present({ state, change, initial }) {
if (this.destroyed) return;
const type = change?.type;
if (initial || ['scene-options-changed', 'scene-created', 'scene-deleted', 'project-imported'].includes(type)) this.renderSceneSelect();
if (initial || ['shots-changed', 'scene-created', 'scene-deleted', 'shot-captured', 'shot-updated', 'shot-renamed', 'shot-deleted', 'project-imported'].includes(type)) this.renderShotList();
if (type === 'selection-changed') presentSceneSelection(this.elements.shots, state.selectedShotId);
if (
initial ||
[
'scene-options-changed',
'scene-created',
'scene-deleted',
'project-imported',
].includes(type)
)
this.renderSceneSelect();
if (
initial ||
[
'shots-changed',
'scene-created',
'scene-deleted',
'shot-captured',
'shot-updated',
'shot-renamed',
'shot-deleted',
'project-imported',
].includes(type)
)
this.renderShotList();
if (type === 'selection-changed')
presentSceneSelection(this.elements.shots, state.selectedShotId);
if (initial || type === 'buttons-changed') this.setButtons(state.running);
if (initial || type === 'progress-changed') this.setProgress(state.progress);
if (initial || type === 'progress-changed')
this.setProgress(state.progress);
if (initial || type === 'status-changed') this.updateStatus(state.status);
if (initial || type === 'runtime-changed') this.updateRuntime(state.runtime);
if (initial || type === 'playback-presentation') this.setPlaybackActive(state.playbackActive);
if (initial || type === 'playback-keyboard') this.setPlaybackKeyboardEnabled(state.keyboardEnabled);
if (initial || type === 'runtime-changed')
this.updateRuntime(state.runtime);
if (initial || type === 'playback-presentation')
this.setPlaybackActive(state.playbackActive);
if (initial || type === 'playback-keyboard')
this.setPlaybackKeyboardEnabled(state.keyboardEnabled);
if (type === 'project-exported') this.updateStatus('Project exported');
if (type === 'shot-loaded') this.updateStatus(`Loaded: ${change.sceneTitle} / ${change.shot.title}`);
if (type === 'run-event' && change.event === 'shot_start') this.setButtons(state.running);
if (type === 'shot-loaded')
this.updateStatus(`Loaded: ${change.sceneTitle} / ${change.shot.title}`);
if (type === 'run-event' && change.event === 'shot_start')
this.setButtons(state.running);
}
listen(target, type, callback, removers = this.removers) {
+16 -4
View File
@@ -351,14 +351,24 @@ test('the real director preserves a selected shot label for the following double
}
});
test('Scene controls consume current state, preserve rows on progress, and unsubscribe on destruction', () => {
const f = fixture();
try {
f.owner.destroy();
Object.assign(f.state, { status: 'Ready to resume', progress: 0.25, runtime: '', playbackActive: false, keyboardEnabled: false });
Object.assign(f.state, {
status: 'Ready to resume',
progress: 0.25,
runtime: '',
playbackActive: false,
keyboardEnabled: false,
});
const channel = createStateChannel(() => f.state);
const owner = new SceneControls({ read: () => f.state, actions: f.actions, elements: f.elements, subscribe: (listener) => channel.subscribe(listener) });
const owner = new SceneControls({
read: () => f.state,
actions: f.actions,
elements: f.elements,
subscribe: (listener) => channel.subscribe(listener),
});
assert.equal(f.elements.status.textContent, 'Ready to resume');
const row = f.elements.shots.children[0];
f.state.progress = 0.5;
@@ -372,5 +382,7 @@ test('Scene controls consume current state, preserve rows on progress, and unsub
assert.equal(f.elements.status.textContent, 'Project exported');
assert.equal(owner.unsubscribe, null);
channel.destroy();
} finally { f.restore(); }
} finally {
f.restore();
}
});