@weasel-js/labkit 1.7.3 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/_dts/{CanvasStackContext-Cjy6I-Iv.d.ts → CanvasStackContext-B05ZM04l.d.ts} +3 -1
- package/dist/_dts/{Stage-D6t7o0pF.d.ts → Stage-CIJlbjrE.d.ts} +5 -3
- package/dist/_dts/{index-DCMA4Fy1.d.ts → index-8geQNQet.d.ts} +84 -12
- package/dist/_dts/{index-BTmaRy6a.d.ts → index-DRi3V5sN.d.ts} +6 -4
- package/dist/_dts/{types-BUBSIZuV.d.ts → types-DUfQ0isU.d.ts} +5 -1
- package/dist/_dts/{useTrialState-Bh8bzFgE.d.ts → useTrialState-Ar0AFpCe.d.ts} +94 -87
- package/dist/canvas/index.d.ts +5 -5
- package/dist/canvas/index.js +2 -2
- package/dist/chrome/index.d.ts +3 -3
- package/dist/chrome/index.js +7 -7
- package/dist/{chunk-HBGH344L.js → chunk-4RCVWX4M.js} +55 -18
- package/dist/chunk-4RCVWX4M.js.map +1 -0
- package/dist/{chunk-WIG6XHJ7.js → chunk-6IIKLXBZ.js} +13 -4
- package/dist/chunk-6IIKLXBZ.js.map +1 -0
- package/dist/{chunk-F4ACIK7Z.js → chunk-6Q6ACJ3H.js} +3 -3
- package/dist/{chunk-F4ACIK7Z.js.map → chunk-6Q6ACJ3H.js.map} +1 -1
- package/dist/{chunk-PMLZP7RT.js → chunk-7ALJOVGP.js} +5 -9
- package/dist/chunk-7ALJOVGP.js.map +1 -0
- package/dist/{chunk-XE5HJOPL.js → chunk-7FMZT2WL.js} +3 -3
- package/dist/{chunk-XE5HJOPL.js.map → chunk-7FMZT2WL.js.map} +1 -1
- package/dist/{chunk-XVUKDSYV.js → chunk-AVOILDN2.js} +2 -2
- package/dist/{chunk-XVUKDSYV.js.map → chunk-AVOILDN2.js.map} +1 -1
- package/dist/{chunk-2N7UBSS2.js → chunk-J5MDJEOZ.js} +3 -3
- package/dist/{chunk-2N7UBSS2.js.map → chunk-J5MDJEOZ.js.map} +1 -1
- package/dist/{chunk-LPGBC63T.js → chunk-KHB3KRRQ.js} +7 -7
- package/dist/chunk-KHB3KRRQ.js.map +1 -0
- package/dist/{chunk-IBVRMHW5.js → chunk-NHIO2O3O.js} +31 -30
- package/dist/chunk-NHIO2O3O.js.map +1 -0
- package/dist/{chunk-EESJUSYR.js → chunk-QMAY74TP.js} +56 -5
- package/dist/chunk-QMAY74TP.js.map +1 -0
- package/dist/{chunk-4X44DMRS.js → chunk-RN7T7OBO.js} +46 -24
- package/dist/chunk-RN7T7OBO.js.map +1 -0
- package/dist/{chunk-4TJ34FXA.js → chunk-RXO5UEGT.js} +5 -12
- package/dist/chunk-RXO5UEGT.js.map +1 -0
- package/dist/{chunk-E6NARGU7.js → chunk-SQX35X5V.js} +3 -3
- package/dist/{chunk-E6NARGU7.js.map → chunk-SQX35X5V.js.map} +1 -1
- package/dist/{chunk-YCFKW5RX.js → chunk-T5OEUNCB.js} +16 -2
- package/dist/chunk-T5OEUNCB.js.map +1 -0
- package/dist/{chunk-RL2LOLNI.js → chunk-UX66USTN.js} +2 -2
- package/dist/chunk-UX66USTN.js.map +1 -0
- package/dist/{chunk-IRMPZIYU.js → chunk-V4SMGEYY.js} +6732 -6507
- package/dist/chunk-V4SMGEYY.js.map +1 -0
- package/dist/{chunk-E23Q6A3Y.js → chunk-VCK5T6EP.js} +3 -3
- package/dist/{chunk-E23Q6A3Y.js.map → chunk-VCK5T6EP.js.map} +1 -1
- package/dist/{chunk-T6DLGEJB.js → chunk-XWR2P6MR.js} +23 -6
- package/dist/chunk-XWR2P6MR.js.map +1 -0
- package/dist/{chunk-XSJJTXNA.js → chunk-YZA73U4A.js} +19 -19
- package/dist/chunk-YZA73U4A.js.map +1 -0
- package/dist/config/index.d.ts +12 -5
- package/dist/config/index.js +2 -2
- package/dist/controls/index.d.ts +2 -2
- package/dist/controls/index.js +5 -5
- package/dist/dragdrop/index.d.ts +2 -2
- package/dist/dragdrop/index.js +2 -2
- package/dist/index.d.ts +158 -16
- package/dist/index.js +1036 -415
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +2 -2
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +176 -28
- package/dist/loupe/index.js +547 -141
- package/dist/loupe/index.js.map +1 -1
- package/dist/overview/index.d.ts +1 -1
- package/dist/overview/index.js +5 -5
- package/dist/passthrough/weasel-ui.js +3 -3
- package/dist/primitives/index.d.ts +4 -19
- package/dist/primitives/index.js +7 -7
- package/dist/state/index.d.ts +6 -8
- package/dist/state/index.js +5 -5
- package/dist/styles.css +181 -44
- package/dist/surface/index.d.ts +20 -1
- package/dist/surface/index.js +2 -2
- package/dist/undo/index.d.ts +19 -8
- package/dist/undo/index.js +1 -1
- package/package.json +11 -11
- package/src/annotations/AnnotationOverlay.abandon.test.tsx +4 -0
- package/src/annotations/AnnotationOverlay.tsx +10 -1
- package/src/annotations/Annotations.less +2 -0
- package/src/annotations/Annotations.overlay.test.tsx +29 -0
- package/src/canvas/CanvasStack.test.tsx +35 -0
- package/src/canvas/CanvasStack.tsx +12 -1
- package/src/canvas/useLayerScheduler.ts +14 -0
- package/src/chrome/builtins.test.ts +14 -0
- package/src/chrome/builtins.tsx +17 -2
- package/src/chrome/regions/StatusRegion.tsx +20 -14
- package/src/chrome/regions/regions.test.tsx +28 -0
- package/src/chrome/types.ts +8 -0
- package/src/clock/clock.browser.test.less +5 -0
- package/src/clock/clock.browser.test.tsx +77 -0
- package/src/clock/clock.integration.test.tsx +95 -0
- package/src/clock/clockRegistry.test.ts +33 -0
- package/src/clock/clockRegistry.ts +46 -0
- package/src/clock/hooks.ts +44 -0
- package/src/clock/index.ts +2 -0
- package/src/clock/trialClock.test.ts +265 -0
- package/src/clock/trialClock.ts +245 -0
- package/src/clock/useClockLoop.ts +45 -0
- package/src/config/builder.ts +11 -1
- package/src/config/resolve.test.ts +12 -0
- package/src/config/resolve.ts +5 -0
- package/src/config/types.ts +5 -1
- package/src/controls/ControlMatrix.test.tsx +10 -0
- package/src/controls/ControlMatrix.tsx +110 -58
- package/src/controls/ControlPanel.test.tsx +30 -0
- package/src/index.ts +3 -2
- package/src/instrument/types.ts +21 -1
- package/src/lab/Lab.chrome.test.tsx +8 -1
- package/src/lab/Lab.presentation.test.tsx +113 -0
- package/src/lab/Lab.surface.test.tsx +20 -9
- package/src/lab/Lab.tsx +132 -257
- package/src/lab/LabLightbox.browser.test.less +17 -0
- package/src/lab/LabLightbox.browser.test.tsx +216 -0
- package/src/lab/LabShell.less +10 -3
- package/src/lab/LabSurface.tsx +129 -0
- package/src/lab/Workspace.stories.tsx +18 -0
- package/src/lab/Workspace.surface.test.tsx +4 -0
- package/src/lab/Workspace.test.tsx +37 -1
- package/src/lab/Workspace.tsx +34 -6
- package/src/lab/index.ts +2 -0
- package/src/lab/nebula.ts +30 -0
- package/src/lab/openLab.test.ts +81 -0
- package/src/lab/openLab.ts +119 -0
- package/src/lab/presentation.browser.test.less +5 -0
- package/src/lab/presentation.browser.test.tsx +119 -0
- package/src/lab/presentation.less +64 -0
- package/src/lab/presentation.tsx +47 -0
- package/src/lab/useFocusPick.ts +43 -0
- package/src/lightbox/Lightbox.browser.test.less +14 -0
- package/src/lightbox/Lightbox.browser.test.tsx +113 -0
- package/src/lightbox/Lightbox.less +128 -0
- package/src/lightbox/Lightbox.test.tsx +195 -0
- package/src/lightbox/Lightbox.tsx +205 -0
- package/src/lightbox/LightboxContext.ts +38 -0
- package/src/lightbox/LightboxLayers.tsx +40 -0
- package/src/lightbox/index.ts +8 -0
- package/src/lightbox/modal.ts +94 -0
- package/src/loupe/AGENTS.md +40 -2
- package/src/loupe/CanvasLoupe.tsx +5 -4
- package/src/loupe/DomLoupe.tsx +7 -5
- package/src/loupe/Loupe.less +15 -0
- package/src/loupe/LoupeBubble.tsx +35 -13
- package/src/loupe/SourceLoupe.browser.test.tsx +204 -0
- package/src/loupe/SourceLoupe.tsx +60 -0
- package/src/loupe/TrialLoupe.browser.test.tsx +342 -0
- package/src/loupe/TrialLoupe.tsx +126 -28
- package/src/loupe/canvasLens.ts +20 -17
- package/src/loupe/index.ts +26 -2
- package/src/loupe/sourceLens.test.ts +65 -0
- package/src/loupe/sourceLens.ts +97 -0
- package/src/loupe/types.test.ts +6 -0
- package/src/loupe/types.ts +109 -7
- package/src/loupe/useLoupe.test.tsx +188 -3
- package/src/loupe/useLoupe.ts +106 -11
- package/src/primitives/index.ts +2 -2
- package/src/specimen/Specimen.tsx +0 -7
- package/src/state/helpers.test.ts +4 -11
- package/src/state/helpers.ts +2 -8
- package/src/state/index.ts +0 -2
- package/src/state/openLabStore.test.ts +10 -8
- package/src/state/openLabStore.ts +1 -1
- package/src/state/store.test.ts +2 -2
- package/src/state/store.ts +15 -16
- package/src/state/types.ts +9 -9
- package/src/styles.less +2 -1
- package/src/surface/AGENTS.md +13 -0
- package/src/surface/index.ts +1 -0
- package/src/surface/useSurfaceTile.test.tsx +4 -0
- package/src/surface/useSurfaceTile.ts +17 -1
- package/src/surface/useTiledSurface.test.tsx +65 -0
- package/src/surface/useTiledSurface.ts +68 -1
- package/src/theme/interstellar.theme.json +0 -14
- package/src/trial/Trial.stories.tsx +0 -1
- package/src/trial/Trial.test.tsx +40 -3
- package/src/trial/Trial.tsx +87 -43
- package/src/trial/TrialChrome.tsx +20 -2
- package/src/trial/trialOps.test.ts +1 -2
- package/src/trial/trialOps.ts +2 -3
- package/src/undo/stateOp.ts +21 -0
- package/src/undo/undoStack.ts +18 -6
- package/dist/chunk-4TJ34FXA.js.map +0 -1
- package/dist/chunk-4X44DMRS.js.map +0 -1
- package/dist/chunk-EESJUSYR.js.map +0 -1
- package/dist/chunk-HBGH344L.js.map +0 -1
- package/dist/chunk-IBVRMHW5.js.map +0 -1
- package/dist/chunk-IRMPZIYU.js.map +0 -1
- package/dist/chunk-LPGBC63T.js.map +0 -1
- package/dist/chunk-PMLZP7RT.js.map +0 -1
- package/dist/chunk-RL2LOLNI.js.map +0 -1
- package/dist/chunk-T6DLGEJB.js.map +0 -1
- package/dist/chunk-WIG6XHJ7.js.map +0 -1
- package/dist/chunk-XSJJTXNA.js.map +0 -1
- package/dist/chunk-YCFKW5RX.js.map +0 -1
- package/src/primitives/StatusBar.less +0 -34
- package/src/primitives/StatusBar.stories.tsx +0 -24
- package/src/primitives/StatusBar.test.tsx +0 -25
- package/src/primitives/StatusBar.tsx +0 -28
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an instrument declares to get a clock: how long a pass runs, how many
|
|
3
|
+
* passes, and how it opens. Shaped like a blits voice spec.
|
|
4
|
+
*/
|
|
5
|
+
export interface ClockCapability {
|
|
6
|
+
/** ms per pass. Omitted, the run never ends. */
|
|
7
|
+
duration?: number;
|
|
8
|
+
/** Passes: `true` endless, `false` one, `n` that many. Default `false`. */
|
|
9
|
+
loop?: boolean | number;
|
|
10
|
+
/** The rate it opens at; `0`, the default, opens paused. */
|
|
11
|
+
rate?: number;
|
|
12
|
+
/** `false` for an instrument whose state is built up by running, so time can
|
|
13
|
+
* only move by running: no seek and no negative rate. Default `true`. */
|
|
14
|
+
seekable?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A trial's time. A position (`elapsed`) moved by a signed `rate`, synced by
|
|
19
|
+
* the lab's frame loop once a frame. Named after blits' owner handle and
|
|
20
|
+
* `Ticked`, so a blits ticker can drive one and blits voices can play under it.
|
|
21
|
+
*/
|
|
22
|
+
export interface TrialClock {
|
|
23
|
+
/** ms the trial has played, rate applied, counted across passes. */
|
|
24
|
+
readonly elapsed: number;
|
|
25
|
+
/** Which pass `elapsed` falls in, 0 first; the last once the run is over. */
|
|
26
|
+
readonly pass: number;
|
|
27
|
+
/** How far through its pass, 0 to 1; 1 once the run is over. 0 with no duration. */
|
|
28
|
+
readonly phase: number;
|
|
29
|
+
/** Signed: 0 pauses and a negative rate plays backward. A write changes speed
|
|
30
|
+
* at once and replaces any ramp. */
|
|
31
|
+
rate: number;
|
|
32
|
+
/** Passes, as `ClockCapability.loop`. */
|
|
33
|
+
loop: boolean | number;
|
|
34
|
+
/** Move `rate` to `to` linearly over `over` real ms; position stays continuous. */
|
|
35
|
+
ramp(to: number, over: number): void;
|
|
36
|
+
/** Move to `elapsed` ms from the start, clamped to the run. Never runs state
|
|
37
|
+
* forward to meet it. Throws on a clock that is not seekable. */
|
|
38
|
+
seek(elapsed: number): void;
|
|
39
|
+
/** The frame loop reports the time, once a frame. */
|
|
40
|
+
sync(timestamp: number): void;
|
|
41
|
+
/** The time between now and the next sync does not count. */
|
|
42
|
+
rebase(): void;
|
|
43
|
+
/** Another frame would change nothing: paused, no ramp, nothing changed since
|
|
44
|
+
* the last sync. */
|
|
45
|
+
readonly inert: boolean;
|
|
46
|
+
/** Called when a change needs frames again, once until the next sync. */
|
|
47
|
+
onWake(fn: () => void): () => void;
|
|
48
|
+
/** Called on a rate, seek or loop change, and when the run ends; never per frame. */
|
|
49
|
+
subscribe(fn: () => void): () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A clock, and what labkit alone does with it. */
|
|
53
|
+
export interface TrialClockHandle {
|
|
54
|
+
clock: TrialClock;
|
|
55
|
+
/** Back to 0 at the declared rate — the trial's Reset, allowed on a clock
|
|
56
|
+
* that is not seekable. */
|
|
57
|
+
reset(): void;
|
|
58
|
+
/** Called after each sync that moved the clock or followed a change. */
|
|
59
|
+
onFrame(fn: (elapsed: number, pass: number) => void): () => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const passesOf = (loop: boolean | number): number =>
|
|
63
|
+
loop === true ? Number.POSITIVE_INFINITY : loop === false ? 1 : Math.max(1, loop);
|
|
64
|
+
|
|
65
|
+
const checkRate = (rate: number, seekable: boolean): void => {
|
|
66
|
+
if (!Number.isFinite(rate)) throw new RangeError(`labkit: a clock's rate is finite, not ${rate}`);
|
|
67
|
+
if (rate < 0 && !seekable)
|
|
68
|
+
throw new RangeError('labkit: a clock that is not seekable cannot run backward');
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** A trial's clock, opened from its declaration and, for a trial reopened,
|
|
72
|
+
* where it stood. */
|
|
73
|
+
export function createTrialClock(
|
|
74
|
+
spec: ClockCapability,
|
|
75
|
+
start?: { elapsed: number; rate: number },
|
|
76
|
+
): TrialClockHandle {
|
|
77
|
+
const duration = spec.duration ?? Number.POSITIVE_INFINITY;
|
|
78
|
+
const seekable = spec.seekable ?? true;
|
|
79
|
+
const openRate = spec.rate ?? 0;
|
|
80
|
+
checkRate(openRate, seekable);
|
|
81
|
+
|
|
82
|
+
let loop = spec.loop ?? false;
|
|
83
|
+
let span = duration * passesOf(loop);
|
|
84
|
+
let rate = start ? start.rate : openRate;
|
|
85
|
+
let elapsed = start ? Math.min(Math.max(0, start.elapsed), span) : 0;
|
|
86
|
+
let ramp: { to: number; left: number } | null = null;
|
|
87
|
+
let last = Number.NaN;
|
|
88
|
+
/** Changed since the last sync, so the next one is due even when paused. */
|
|
89
|
+
let changed = false;
|
|
90
|
+
/** Inert as of the last sync: the next one only records the time. */
|
|
91
|
+
let slept = false;
|
|
92
|
+
let wakeFired = false;
|
|
93
|
+
const wakes = new Set<() => void>();
|
|
94
|
+
const subs = new Set<() => void>();
|
|
95
|
+
const frames = new Set<(elapsed: number, pass: number) => void>();
|
|
96
|
+
|
|
97
|
+
const isInert = (): boolean => rate === 0 && ramp === null && !changed;
|
|
98
|
+
const notify = (): void => {
|
|
99
|
+
for (const fn of [...subs]) fn();
|
|
100
|
+
};
|
|
101
|
+
const change = (): void => {
|
|
102
|
+
changed = true;
|
|
103
|
+
if (wakeFired) return;
|
|
104
|
+
wakeFired = true;
|
|
105
|
+
for (const fn of [...wakes]) fn();
|
|
106
|
+
};
|
|
107
|
+
const passAt = (): number =>
|
|
108
|
+
duration > 0 && Number.isFinite(duration)
|
|
109
|
+
? elapsed >= span
|
|
110
|
+
? passesOf(loop) - 1
|
|
111
|
+
: Math.floor(elapsed / duration)
|
|
112
|
+
: 0;
|
|
113
|
+
const phaseAt = (): number =>
|
|
114
|
+
duration > 0 && Number.isFinite(duration)
|
|
115
|
+
? elapsed >= span
|
|
116
|
+
? 1
|
|
117
|
+
: (elapsed % duration) / duration
|
|
118
|
+
: 0;
|
|
119
|
+
|
|
120
|
+
/** ms of trial time over `dt` real ms, ramp included; settles the ramp. */
|
|
121
|
+
const advance = (dt: number): number => {
|
|
122
|
+
if (!ramp) return rate * dt;
|
|
123
|
+
if (dt >= ramp.left) {
|
|
124
|
+
const area = ((rate + ramp.to) / 2) * ramp.left + ramp.to * (dt - ramp.left);
|
|
125
|
+
rate = ramp.to;
|
|
126
|
+
ramp = null;
|
|
127
|
+
notify();
|
|
128
|
+
return area;
|
|
129
|
+
}
|
|
130
|
+
const next = rate + ((ramp.to - rate) * dt) / ramp.left;
|
|
131
|
+
const area = ((rate + next) / 2) * dt;
|
|
132
|
+
rate = next;
|
|
133
|
+
ramp.left -= dt;
|
|
134
|
+
return area;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const clock: TrialClock = {
|
|
138
|
+
get elapsed() {
|
|
139
|
+
return elapsed;
|
|
140
|
+
},
|
|
141
|
+
get pass() {
|
|
142
|
+
return passAt();
|
|
143
|
+
},
|
|
144
|
+
get phase() {
|
|
145
|
+
return phaseAt();
|
|
146
|
+
},
|
|
147
|
+
get rate() {
|
|
148
|
+
return rate;
|
|
149
|
+
},
|
|
150
|
+
set rate(next: number) {
|
|
151
|
+
checkRate(next, seekable);
|
|
152
|
+
rate = next;
|
|
153
|
+
ramp = null;
|
|
154
|
+
change();
|
|
155
|
+
notify();
|
|
156
|
+
},
|
|
157
|
+
get loop() {
|
|
158
|
+
return loop;
|
|
159
|
+
},
|
|
160
|
+
set loop(next: boolean | number) {
|
|
161
|
+
loop = next;
|
|
162
|
+
span = duration * passesOf(loop);
|
|
163
|
+
elapsed = Math.min(elapsed, span);
|
|
164
|
+
change();
|
|
165
|
+
notify();
|
|
166
|
+
},
|
|
167
|
+
ramp(to, over) {
|
|
168
|
+
checkRate(to, seekable);
|
|
169
|
+
if (!(over > 0)) {
|
|
170
|
+
clock.rate = to;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
ramp = { to, left: over };
|
|
174
|
+
change();
|
|
175
|
+
notify();
|
|
176
|
+
},
|
|
177
|
+
seek(target) {
|
|
178
|
+
if (!seekable) throw new Error('labkit: this clock is not seekable');
|
|
179
|
+
elapsed = Math.min(Math.max(0, target), span);
|
|
180
|
+
change();
|
|
181
|
+
notify();
|
|
182
|
+
},
|
|
183
|
+
sync(timestamp) {
|
|
184
|
+
const wasChanged = changed;
|
|
185
|
+
const before = elapsed;
|
|
186
|
+
if (!Number.isNaN(last) && !slept) {
|
|
187
|
+
elapsed += advance(Math.max(0, timestamp - last));
|
|
188
|
+
if (elapsed >= span && rate > 0) {
|
|
189
|
+
elapsed = span;
|
|
190
|
+
rate = 0;
|
|
191
|
+
ramp = null;
|
|
192
|
+
notify();
|
|
193
|
+
} else if (elapsed <= 0 && rate < 0) {
|
|
194
|
+
elapsed = 0;
|
|
195
|
+
rate = 0;
|
|
196
|
+
ramp = null;
|
|
197
|
+
notify();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
last = timestamp;
|
|
201
|
+
changed = false;
|
|
202
|
+
wakeFired = false;
|
|
203
|
+
slept = isInert();
|
|
204
|
+
if (elapsed !== before || wasChanged) {
|
|
205
|
+
const pass = passAt();
|
|
206
|
+
for (const fn of [...frames]) fn(elapsed, pass);
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
rebase() {
|
|
210
|
+
last = Number.NaN;
|
|
211
|
+
},
|
|
212
|
+
get inert() {
|
|
213
|
+
return isInert();
|
|
214
|
+
},
|
|
215
|
+
onWake(fn) {
|
|
216
|
+
wakes.add(fn);
|
|
217
|
+
return () => {
|
|
218
|
+
wakes.delete(fn);
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
subscribe(fn) {
|
|
222
|
+
subs.add(fn);
|
|
223
|
+
return () => {
|
|
224
|
+
subs.delete(fn);
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
clock,
|
|
231
|
+
reset() {
|
|
232
|
+
elapsed = 0;
|
|
233
|
+
rate = openRate;
|
|
234
|
+
ramp = null;
|
|
235
|
+
change();
|
|
236
|
+
notify();
|
|
237
|
+
},
|
|
238
|
+
onFrame(fn) {
|
|
239
|
+
frames.add(fn);
|
|
240
|
+
return () => {
|
|
241
|
+
frames.delete(fn);
|
|
242
|
+
};
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useVisibleRaf } from '@weasel-js/core';
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
import type { ClockRegistry } from './clockRegistry';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The lab's one frame loop for every trial clock: syncs each clock that is not
|
|
7
|
+
* inert, and sleeps once every clock is, until one wakes. Shaped like a blits
|
|
8
|
+
* `ticker`, so one could take its place.
|
|
9
|
+
*/
|
|
10
|
+
export function useClockLoop(registry: ClockRegistry): void {
|
|
11
|
+
const loop = useVisibleRaf(
|
|
12
|
+
(timestamp) => {
|
|
13
|
+
let live = false;
|
|
14
|
+
for (const { clock } of registry.all()) {
|
|
15
|
+
if (clock.inert) continue;
|
|
16
|
+
clock.sync(timestamp);
|
|
17
|
+
if (!clock.inert) live = true;
|
|
18
|
+
}
|
|
19
|
+
if (live) loop.request();
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
onResume: () => {
|
|
23
|
+
for (const { clock } of registry.all()) clock.rebase();
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
let unwakes: (() => void)[] = [];
|
|
30
|
+
const attach = (): void => {
|
|
31
|
+
for (const off of unwakes) off();
|
|
32
|
+
unwakes = [];
|
|
33
|
+
for (const { clock } of registry.all()) {
|
|
34
|
+
unwakes.push(clock.onWake(() => loop.request()));
|
|
35
|
+
if (!clock.inert) loop.request();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
attach();
|
|
39
|
+
const off = registry.subscribe(attach);
|
|
40
|
+
return () => {
|
|
41
|
+
off();
|
|
42
|
+
for (const unwake of unwakes) unwake();
|
|
43
|
+
};
|
|
44
|
+
}, [registry, loop]);
|
|
45
|
+
}
|
package/src/config/builder.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Display } from '@weasel-js/quantity';
|
|
1
|
+
import type { Display, InfinityText } from '@weasel-js/quantity';
|
|
2
2
|
import type { PrefLeaf, PrefNumberUnit } from '@weasel-js/ui';
|
|
3
3
|
import { type Auto, isAuto } from './auto';
|
|
4
4
|
import type {
|
|
@@ -159,6 +159,16 @@ export class NumberNode extends BaseNode<number> {
|
|
|
159
159
|
return this.ann({ unit });
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Make the top of the range mean infinity: the slider's `max` stop stores
|
|
164
|
+
* `Infinity` and reads `word` — `.range(0, 5000).endless('never')`. A value
|
|
165
|
+
* of `Infinity` sits at that stop. `at: 'min'` puts it at the bottom
|
|
166
|
+
* instead, as `-Infinity`; `'both'` does both.
|
|
167
|
+
*/
|
|
168
|
+
endless(word?: InfinityText, at: 'min' | 'max' | 'both' = 'max'): this {
|
|
169
|
+
return this.ann(word === undefined ? { endless: at } : { endless: at, infinity: word });
|
|
170
|
+
}
|
|
171
|
+
|
|
162
172
|
/** Force a slider even without both bounds. */
|
|
163
173
|
slider(): this {
|
|
164
174
|
return this.ann({ control: 'slider' });
|
|
@@ -215,6 +215,18 @@ describe('resolveConfigSchema / nested groups', () => {
|
|
|
215
215
|
expect(() => resolveConfigSchema(schema, [])).toThrow(/manual/);
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
+
it('rejects a value leaf whose kind nothing can infer, naming it and the fix', () => {
|
|
219
|
+
const schema = f.schema({ grid: f.group({ when: f.value<number | null>(null) }) });
|
|
220
|
+
expect(() => resolveConfigSchema(schema, [])).toThrow(/grid\.when/);
|
|
221
|
+
expect(() => resolveConfigSchema(schema, [])).toThrow(/f\.custom/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('accepts a kindless value leaf once a lab rule supplies its kind', () => {
|
|
225
|
+
const rule: ConfigRule = (ctx) => (ctx.default === null ? { kind: 'number' } : null);
|
|
226
|
+
const schema = f.schema({ when: f.value<number | null>(null) });
|
|
227
|
+
expect(leafAt(resolveConfigSchema(schema, [rule]), 'when').kind).toBe('number');
|
|
228
|
+
});
|
|
229
|
+
|
|
218
230
|
it('carries autoResolve, unpinned and manual onto the resolved leaf', () => {
|
|
219
231
|
const r = resolveConfigSchema(
|
|
220
232
|
f.schema({
|
package/src/config/resolve.ts
CHANGED
|
@@ -127,6 +127,11 @@ function resolveEntry(
|
|
|
127
127
|
...(entry.kind === null ? {} : { kind: entry.kind }),
|
|
128
128
|
});
|
|
129
129
|
const patch = applyRules(seed, { key, path, default: entry.default }, sink.chain);
|
|
130
|
+
if (patch.kind === undefined) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`[labkit] "${path}" is an f.value whose kind cannot be inferred from its default (${String(entry.default)}) — declare it with a typed builder, or f.custom(kind, default) for a value with no row`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
130
135
|
const { autoResolve, unpinned, manual } = entry.options;
|
|
131
136
|
return {
|
|
132
137
|
...patch,
|
package/src/config/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Display } from '@weasel-js/quantity';
|
|
1
|
+
import type { Display, InfinityText } from '@weasel-js/quantity';
|
|
2
2
|
import type { PrefGroup, PrefLeaf, PrefNumberUnit, PrefRenderer } from '@weasel-js/ui';
|
|
3
3
|
import type { ReactNode } from 'react';
|
|
4
4
|
|
|
@@ -36,6 +36,10 @@ export interface Annotations {
|
|
|
36
36
|
/** Display-unit conversion for a number stored in a canonical unit — the
|
|
37
37
|
* value, its bounds and its step all convert at the control's edge. */
|
|
38
38
|
unit?: PrefNumberUnit;
|
|
39
|
+
/** Which end of a number's slider stands for infinity. */
|
|
40
|
+
endless?: 'min' | 'max' | 'both';
|
|
41
|
+
/** The word a number's infinity shows as — `'never'`. Default `∞`. */
|
|
42
|
+
infinity?: InfinityText;
|
|
39
43
|
/** A color leaf's value carries alpha (`#rrggbbaa`) and its row edits it. */
|
|
40
44
|
alpha?: boolean;
|
|
41
45
|
control?: string;
|
|
@@ -158,6 +158,16 @@ describe('<ControlMatrix>', () => {
|
|
|
158
158
|
expect(setConfig).toHaveBeenCalledWith('looks.window.glow', 0.75);
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
it('lists an enum cell’s options in its popover, with no dropdown inside, and closes on a choice', () => {
|
|
162
|
+
const { setConfig } = setup();
|
|
163
|
+
fireEvent.click(cell('Window Shading'));
|
|
164
|
+
const dialog = screen.getByRole('dialog', { name: 'Window Shading' });
|
|
165
|
+
expect(within(dialog).queryByRole('button', { name: /Shading/ })).toBeNull();
|
|
166
|
+
fireEvent.click(within(dialog).getByRole('radio', { name: 'Soft' }));
|
|
167
|
+
expect(setConfig).toHaveBeenCalledWith('looks.window.shading', 'soft');
|
|
168
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
161
171
|
it('unpins from the popover with Inherit, disabled while already inherited', () => {
|
|
162
172
|
const { setConfig, unmount } = setup();
|
|
163
173
|
fireEvent.click(cell('Window Glow'));
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
isPrefLeaf,
|
|
6
6
|
type PrefLeaf,
|
|
7
7
|
type PropertyControlProps,
|
|
8
|
+
PropertyField,
|
|
8
9
|
PropertyHelp,
|
|
9
10
|
PropertyList,
|
|
10
11
|
PropertyPanel,
|
|
@@ -115,7 +116,7 @@ export function ControlMatrix({
|
|
|
115
116
|
inheritHint ?? ((_: ControlMatrixColumn) => (fallback ? `from ${nameOf(fallback)}` : ''));
|
|
116
117
|
const [open, setOpen] = useState<OpenCell | null>(null);
|
|
117
118
|
const triggerRef = useRef<HTMLElement | null>(null);
|
|
118
|
-
const { anchor, portalProps } = useOverlayPortal();
|
|
119
|
+
const { anchor, portalProps, ready } = useOverlayPortal();
|
|
119
120
|
|
|
120
121
|
const leafAt = (path: string): PrefLeaf | undefined => {
|
|
121
122
|
const node = schemaNodeAtPath(schema.group, path);
|
|
@@ -194,63 +195,61 @@ export function ControlMatrix({
|
|
|
194
195
|
))}
|
|
195
196
|
</tbody>
|
|
196
197
|
</table>
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
<
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
>
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
>
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
) : null}
|
|
253
|
-
</RACPopover>
|
|
198
|
+
{ready && (
|
|
199
|
+
<RACPopover
|
|
200
|
+
triggerRef={triggerRef}
|
|
201
|
+
isOpen={open !== null}
|
|
202
|
+
onOpenChange={(next) => {
|
|
203
|
+
if (!next) setOpen(null);
|
|
204
|
+
}}
|
|
205
|
+
placement="bottom"
|
|
206
|
+
className="lk-control-matrix__popover"
|
|
207
|
+
data-weasel-overlay=""
|
|
208
|
+
{...portalProps}
|
|
209
|
+
>
|
|
210
|
+
{open ? (
|
|
211
|
+
<RACDialog
|
|
212
|
+
className="lk-control-matrix__editor"
|
|
213
|
+
aria-label={`${nameOf(open.column)} ${open.label}`}
|
|
214
|
+
>
|
|
215
|
+
<PropertyList pack="one-up">
|
|
216
|
+
<Editor
|
|
217
|
+
open={open}
|
|
218
|
+
schema={schema}
|
|
219
|
+
config={config}
|
|
220
|
+
setConfig={setConfig}
|
|
221
|
+
close={() => setOpen(null)}
|
|
222
|
+
/>
|
|
223
|
+
</PropertyList>
|
|
224
|
+
{clearable(open.leaf) ? (
|
|
225
|
+
<div className="lk-control-matrix__editor-actions">
|
|
226
|
+
<Button
|
|
227
|
+
size="sm"
|
|
228
|
+
variant="ghost"
|
|
229
|
+
disabled={!valueAtPath(config, open.path) || auto.has(open.path)}
|
|
230
|
+
onClick={() => setConfig(open.path, null)}
|
|
231
|
+
>
|
|
232
|
+
Clear
|
|
233
|
+
</Button>
|
|
234
|
+
</div>
|
|
235
|
+
) : null}
|
|
236
|
+
{open.first ? null : (
|
|
237
|
+
<div className="lk-control-matrix__editor-actions">
|
|
238
|
+
<Button
|
|
239
|
+
size="sm"
|
|
240
|
+
variant="ghost"
|
|
241
|
+
disabled={auto.has(open.path)}
|
|
242
|
+
tooltip={`Inherit ${hintFor(open.column)}`}
|
|
243
|
+
onClick={() => setConfig(open.path, autoValue)}
|
|
244
|
+
>
|
|
245
|
+
Inherit
|
|
246
|
+
</Button>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</RACDialog>
|
|
250
|
+
) : null}
|
|
251
|
+
</RACPopover>
|
|
252
|
+
)}
|
|
254
253
|
</div>
|
|
255
254
|
);
|
|
256
255
|
|
|
@@ -262,6 +261,59 @@ export function ControlMatrix({
|
|
|
262
261
|
);
|
|
263
262
|
}
|
|
264
263
|
|
|
264
|
+
/**
|
|
265
|
+
* The control a cell opens. An enum lists its options outright, as radios: the
|
|
266
|
+
* panel's dropdown would be a second popup opened from inside this one.
|
|
267
|
+
* Choosing one is the whole edit, so it closes the popover.
|
|
268
|
+
*/
|
|
269
|
+
function Editor({
|
|
270
|
+
open,
|
|
271
|
+
schema,
|
|
272
|
+
config,
|
|
273
|
+
setConfig,
|
|
274
|
+
close,
|
|
275
|
+
}: {
|
|
276
|
+
open: OpenCell;
|
|
277
|
+
schema: ResolvedConfig;
|
|
278
|
+
config: Record<string, unknown>;
|
|
279
|
+
setConfig: (path: string, value: unknown) => void;
|
|
280
|
+
close: () => void;
|
|
281
|
+
}) {
|
|
282
|
+
const { path, leaf } = open;
|
|
283
|
+
const field = isBuiltinToolPref(leaf)
|
|
284
|
+
? prefFieldProps(leaf, {
|
|
285
|
+
value: valueAtPath(config, path) ?? leaf.default,
|
|
286
|
+
setValue: (next) => setConfig(path, next),
|
|
287
|
+
})
|
|
288
|
+
: null;
|
|
289
|
+
if (field?.kind === 'enum') {
|
|
290
|
+
return (
|
|
291
|
+
<PropertyField
|
|
292
|
+
{...field}
|
|
293
|
+
label={leaf.name}
|
|
294
|
+
control="radio"
|
|
295
|
+
onChange={(next: Parameters<typeof field.onChange>[0]) => {
|
|
296
|
+
field.onChange(next);
|
|
297
|
+
close();
|
|
298
|
+
}}
|
|
299
|
+
/>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return (
|
|
303
|
+
<ControlRow
|
|
304
|
+
path={path}
|
|
305
|
+
leaf={leaf}
|
|
306
|
+
resolved={schema}
|
|
307
|
+
config={config}
|
|
308
|
+
shown={config}
|
|
309
|
+
setConfig={setConfig}
|
|
310
|
+
pack="one-up"
|
|
311
|
+
auto={NO_AUTO}
|
|
312
|
+
setRowAuto={noop}
|
|
313
|
+
/>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
265
317
|
function ColumnHeader({
|
|
266
318
|
column,
|
|
267
319
|
onClick,
|
|
@@ -1188,3 +1188,33 @@ describe('<ControlPanel> resettable group', () => {
|
|
|
1188
1188
|
]);
|
|
1189
1189
|
});
|
|
1190
1190
|
});
|
|
1191
|
+
|
|
1192
|
+
describe('<ControlPanel> endless number', () => {
|
|
1193
|
+
it('reads its word at the top of the range and writes Infinity there', () => {
|
|
1194
|
+
const schema = resolveConfigSchema(
|
|
1195
|
+
f.schema({ cut: f.number(Infinity).range(0, 5000).step(50).suffix('ms').endless('never') }),
|
|
1196
|
+
[],
|
|
1197
|
+
);
|
|
1198
|
+
const setConfig = vi.fn();
|
|
1199
|
+
function Harness() {
|
|
1200
|
+
const [config, setState] = useState<Record<string, unknown>>({ cut: Infinity });
|
|
1201
|
+
return (
|
|
1202
|
+
<ControlPanel
|
|
1203
|
+
schema={schema}
|
|
1204
|
+
config={config}
|
|
1205
|
+
setConfig={(key: string, value: unknown) => {
|
|
1206
|
+
setConfig(key, value);
|
|
1207
|
+
setState((c) => ({ ...c, [key]: value }));
|
|
1208
|
+
}}
|
|
1209
|
+
/>
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
render(<Harness />);
|
|
1213
|
+
expect(screen.getByRole('spinbutton')).toHaveValue('never');
|
|
1214
|
+
expect(screen.queryByText('ms')).toBeNull();
|
|
1215
|
+
fireEvent.change(screen.getByRole('slider'), { target: { value: '4950' } });
|
|
1216
|
+
expect(setConfig).toHaveBeenLastCalledWith('cut', 4950);
|
|
1217
|
+
fireEvent.change(screen.getByRole('slider'), { target: { value: '5000' } });
|
|
1218
|
+
expect(setConfig).toHaveBeenLastCalledWith('cut', Infinity);
|
|
1219
|
+
});
|
|
1220
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -173,6 +173,7 @@ export {
|
|
|
173
173
|
zoomAt,
|
|
174
174
|
} from './canvas';
|
|
175
175
|
export * from './chrome';
|
|
176
|
+
export * from './clock';
|
|
176
177
|
export { type Auto, auto, isAuto } from './config/auto';
|
|
177
178
|
export { autoPathsOf, resolveAutoConfig } from './config/autoConfig';
|
|
178
179
|
export {
|
|
@@ -274,6 +275,7 @@ export * from './job';
|
|
|
274
275
|
export * from './lab';
|
|
275
276
|
export type { LayerListItem, LayerListProps, LayerMove } from './layers';
|
|
276
277
|
export { LayerList, moveLayers } from './layers';
|
|
278
|
+
export * from './lightbox';
|
|
277
279
|
export * from './primitives';
|
|
278
280
|
export { SPECIMEN_SECTIONS, Specimen } from './specimen/Specimen';
|
|
279
281
|
export {
|
|
@@ -311,7 +313,6 @@ export type {
|
|
|
311
313
|
TrialInfo,
|
|
312
314
|
TrialRecord,
|
|
313
315
|
TrialStateHandle,
|
|
314
|
-
UndoStack,
|
|
315
316
|
} from './state/types';
|
|
316
317
|
export type { UndockedPanel, UndockedPanels } from './state/undock';
|
|
317
318
|
export { dockPanel, panelKey, undockPanel } from './state/undock';
|
|
@@ -323,5 +324,5 @@ export * from './surface';
|
|
|
323
324
|
export { interstellarTheme } from './theme/interstellar';
|
|
324
325
|
export type { ToolCapability, TrialTool } from './tools/types';
|
|
325
326
|
export * from './trial';
|
|
326
|
-
export type { EventBus, EventListener } from './undo';
|
|
327
|
+
export type { EventBus, EventListener, UndoStack } from './undo';
|
|
327
328
|
export { clearUndo, createEventBus, emptyStack, pushSnapshot, redo, undo } from './undo';
|