@weasel-js/labkit 1.5.0 → 1.5.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 +41 -4
- package/dist/_dts/{CanvasStackContext-CqkxkFJW.d.ts → CanvasStackContext-CJNMS6mz.d.ts} +1 -1
- package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
- package/dist/_dts/Select.d-Gwr8fLpK.d.ts +453 -0
- package/dist/_dts/{frac-8R6UmlvN.d.ts → frac-B4okC2lX.d.ts} +42 -4
- package/dist/_dts/{index-retnAut7.d.ts → index-BNdz-tNd.d.ts} +90 -10
- package/dist/_dts/{index-B8uu2ba7.d.ts → index-CSJoj4LC.d.ts} +17 -6
- package/dist/_dts/{types-Ca2LCnPq.d.ts → types-DzoNY1pO.d.ts} +28 -4
- package/dist/_dts/{usePanZoom-nS798rOF.d.ts → usePanZoom-Pzf9f2jh.d.ts} +2 -2
- package/dist/_dts/{useTrialState-jcx_TdU6.d.ts → useTrialState-uLlmaccn.d.ts} +1 -1
- package/dist/canvas/index.d.ts +5 -5
- package/dist/canvas/index.js +1 -1
- package/dist/chrome/index.d.ts +4 -4
- package/dist/chrome/index.js +7 -7
- package/dist/chunk-7PFH3CNM.js +150 -0
- package/dist/chunk-7PFH3CNM.js.map +1 -0
- package/dist/{chunk-PGETSEDK.js → chunk-CEIUBPPW.js} +10 -6
- package/dist/chunk-CEIUBPPW.js.map +1 -0
- package/dist/chunk-EH7UEQV7.js +590 -0
- package/dist/chunk-EH7UEQV7.js.map +1 -0
- package/dist/{chunk-EKIICY6X.js → chunk-EHGVROVV.js} +37 -25
- package/dist/chunk-EHGVROVV.js.map +1 -0
- package/dist/{chunk-ZPH5WTWF.js → chunk-ERFMC6WU.js} +142 -66
- package/dist/chunk-ERFMC6WU.js.map +1 -0
- package/dist/{chunk-NS64DXMN.js → chunk-IHUVQ3TX.js} +39 -16
- package/dist/chunk-IHUVQ3TX.js.map +1 -0
- package/dist/{chunk-QLSV2N3G.js → chunk-IJJUK4FU.js} +25 -13
- package/dist/chunk-IJJUK4FU.js.map +1 -0
- package/dist/{chunk-67SJLMC7.js → chunk-IXTQI7JX.js} +45 -12
- package/dist/chunk-IXTQI7JX.js.map +1 -0
- package/dist/{chunk-MUKOW3TC.js → chunk-MDVTK5U3.js} +4 -4
- package/dist/{chunk-MUKOW3TC.js.map → chunk-MDVTK5U3.js.map} +1 -1
- package/dist/{chunk-UU3NJO6N.js → chunk-PHK4W562.js} +3 -3
- package/dist/{chunk-UU3NJO6N.js.map → chunk-PHK4W562.js.map} +1 -1
- package/dist/chunk-PJ7WEQBJ.js +18 -0
- package/dist/chunk-PJ7WEQBJ.js.map +1 -0
- package/dist/chunk-T3EC2DXD.js +12628 -0
- package/dist/chunk-T3EC2DXD.js.map +1 -0
- package/dist/{chunk-KJALCDWE.js → chunk-UN4U4P63.js} +4 -4
- package/dist/{chunk-KJALCDWE.js.map → chunk-UN4U4P63.js.map} +1 -1
- package/dist/{chunk-NDRYLVOW.js → chunk-Z76KEZAX.js} +2 -2
- package/dist/chunk-Z76KEZAX.js.map +1 -0
- package/dist/{chunk-6ZDGOZQV.js → chunk-ZDHY47EV.js} +70 -17
- package/dist/chunk-ZDHY47EV.js.map +1 -0
- package/dist/config/index.d.ts +60 -16
- package/dist/config/index.js +6 -4
- package/dist/controls/index.d.ts +3 -3
- package/dist/controls/index.js +4 -4
- package/dist/dragdrop/index.d.ts +3 -3
- package/dist/index.d.ts +177 -53
- package/dist/index.js +1333 -1683
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +4 -4
- package/dist/layers/index.js +2 -2
- package/dist/loupe/index.d.ts +67 -16
- package/dist/loupe/index.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +26 -202
- package/dist/passthrough/weasel-ui.js +1 -1
- package/dist/primitives/index.d.ts +3 -1
- package/dist/primitives/index.js +3 -3
- package/dist/state/index.d.ts +5 -5
- package/dist/state/index.js +5 -3
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +153 -40
- package/dist/surface/index.js +1 -1
- package/dist/ui/layers/index.js +1 -1
- package/dist/undo/index.d.ts +3 -3
- package/package.json +9 -9
- package/src/annotations/AnnotationOverlay.tsx +3 -0
- package/src/annotations/Annotations.less +4 -0
- package/src/annotations/Annotations.overlay.test.tsx +20 -0
- package/src/annotations/svgNodes.ts +1 -0
- package/src/annotations/toolMap.test.ts +9 -5
- package/src/annotations/toolMap.ts +3 -0
- package/src/canvas/CanvasStack.stories.tsx +1 -1
- package/src/canvas/usePanZoom.test.ts +28 -0
- package/src/canvas/usePanZoom.ts +11 -4
- package/src/chrome/ChromeRegions.stories.tsx +1 -1
- package/src/chrome/LabChrome.tsx +21 -5
- package/src/chrome/builtins.test.ts +1 -0
- package/src/chrome/builtins.tsx +1 -0
- package/src/chrome/index.ts +1 -0
- package/src/chrome/labTypes.ts +2 -2
- package/src/chrome/regions/PaletteRegion.tsx +1 -6
- package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
- package/src/chrome/regions/ToolbarRegion.tsx +11 -7
- package/src/chrome/types.ts +3 -0
- package/src/config/auto.test.ts +12 -0
- package/src/config/auto.ts +15 -0
- package/src/config/autoConfig.test.ts +107 -0
- package/src/config/autoConfig.ts +153 -0
- package/src/config/builder.test.ts +38 -0
- package/src/config/builder.ts +53 -8
- package/src/config/index.ts +3 -0
- package/src/config/path.ts +7 -2
- package/src/config/resolve.test.ts +25 -0
- package/src/config/resolve.ts +17 -1
- package/src/config/types.ts +32 -2
- package/src/config/useResolvedConfig.ts +26 -0
- package/src/controls/AutoControls.stories.tsx +88 -0
- package/src/controls/ControlPanel.stories.tsx +42 -1
- package/src/controls/ControlPanel.test.tsx +514 -4
- package/src/controls/ControlPanel.tsx +557 -80
- package/src/index.ts +29 -1
- package/src/instrument/serializers.ts +29 -1
- package/src/lab/Lab.chrome.test.tsx +16 -0
- package/src/lab/Lab.stories.tsx +1 -1
- package/src/lab/Lab.surface.test.tsx +7 -2
- package/src/lab/Lab.test.tsx +79 -1
- package/src/lab/Lab.tsx +105 -13
- package/src/lab/LabContext.ts +8 -0
- package/src/lab/LabFit.stories.tsx +4 -4
- package/src/lab/LabFullChrome.stories.tsx +1 -1
- package/src/lab/LabHeader.tsx +0 -1
- package/src/lab/LabRoot.test.tsx +56 -0
- package/src/lab/LabRoot.tsx +47 -0
- package/src/lab/LabShell.less +24 -3
- package/src/lab/LabShell.stories.tsx +50 -1
- package/src/lab/LabShell.tsx +18 -27
- package/src/lab/LabSwitcher.less +10 -10
- package/src/lab/LabSwitcher.test.tsx +14 -9
- package/src/lab/LabSwitcher.tsx +8 -4
- package/src/lab/Workspace.less +1 -1
- package/src/lab/Workspace.stories.tsx +1 -1
- package/src/lab/Workspace.surface.test.tsx +1 -1
- package/src/lab/index.ts +2 -0
- package/src/layers/LayerList.stories.tsx +1 -1
- package/src/loupe/AGENTS.md +16 -4
- package/src/loupe/LoupeGestures.tsx +67 -0
- package/src/loupe/TrialLoupe.tsx +43 -28
- package/src/loupe/index.ts +4 -0
- package/src/loupe/loupeActions.ts +98 -0
- package/src/loupe/useLoupe.test.tsx +59 -2
- package/src/loupe/useLoupe.ts +28 -48
- package/src/passthrough/weasel-ui.ts +3 -3
- package/src/primitives/FloatingPanel.stories.tsx +1 -1
- package/src/primitives/FpsMeter.stories.tsx +1 -1
- package/src/primitives/JobProgress.stories.tsx +1 -1
- package/src/primitives/Legend.stories.tsx +1 -1
- package/src/primitives/ScaleIndicator.stories.tsx +1 -1
- package/src/primitives/Sidebar.less +7 -0
- package/src/primitives/Sidebar.stories.tsx +1 -1
- package/src/primitives/Split.test.tsx +25 -0
- package/src/primitives/Split.tsx +36 -20
- package/src/primitives/StatusBar.stories.tsx +1 -1
- package/src/primitives/Toolbar.stories.tsx +1 -1
- package/src/specimen/Specimen.less +95 -0
- package/src/specimen/Specimen.stories.tsx +13 -0
- package/src/specimen/Specimen.test.tsx +13 -0
- package/src/specimen/Specimen.tsx +888 -0
- package/src/state/Persistence.stories.tsx +4 -4
- package/src/state/store.test.ts +65 -0
- package/src/state/store.ts +32 -8
- package/src/state/types.ts +26 -2
- package/src/state/useTrialState.test.tsx +85 -0
- package/src/state/useTrialState.ts +9 -1
- package/src/styles.less +1 -0
- package/src/surface/index.ts +1 -1
- package/src/surface/useSurfaceTile.test.tsx +1 -1
- package/src/surface/useTiledSurface.test.tsx +65 -10
- package/src/surface/useTiledSurface.ts +23 -11
- package/src/theme/Interstellar.stories.tsx +2 -2
- package/src/theme/interstellar.test.ts +16 -5
- package/src/theme/interstellar.theme.json +180 -0
- package/src/theme/interstellar.ts +8 -5
- package/src/tools/labTool.ts +3 -3
- package/src/trial/Trial.annotations.test.tsx +13 -4
- package/src/trial/Trial.canvas.test.tsx +32 -0
- package/src/trial/Trial.config.test.tsx +75 -2
- package/src/trial/Trial.less +4 -0
- package/src/trial/Trial.stories.tsx +4 -1
- package/src/trial/Trial.test.tsx +3 -0
- package/src/trial/Trial.tsx +21 -14
- package/src/trial/TrialChrome.tsx +17 -3
- package/src/trial/trialOps.test.ts +101 -1
- package/src/trial/trialOps.ts +62 -4
- package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +0 -116
- package/dist/chunk-67SJLMC7.js.map +0 -1
- package/dist/chunk-6ZDGOZQV.js.map +0 -1
- package/dist/chunk-E44SU6XS.js +0 -50
- package/dist/chunk-E44SU6XS.js.map +0 -1
- package/dist/chunk-EKIICY6X.js.map +0 -1
- package/dist/chunk-NDRYLVOW.js.map +0 -1
- package/dist/chunk-NS64DXMN.js.map +0 -1
- package/dist/chunk-PGETSEDK.js.map +0 -1
- package/dist/chunk-QLSV2N3G.js.map +0 -1
- package/dist/chunk-S2SHZRD7.js +0 -7681
- package/dist/chunk-S2SHZRD7.js.map +0 -1
- package/dist/chunk-TQBBYTAF.js +0 -280
- package/dist/chunk-TQBBYTAF.js.map +0 -1
- package/dist/chunk-ZPH5WTWF.js.map +0 -1
- package/src/theme/interstellar.tokens.json +0 -61
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
NumberRow,
|
|
7
7
|
type PrefGroup,
|
|
8
8
|
type PrefLeaf,
|
|
9
|
+
type PrefNumber,
|
|
9
10
|
type PrefNumberFormat,
|
|
11
|
+
type PrefNumberUnit,
|
|
10
12
|
type PropertyAlign,
|
|
11
13
|
type PropertyDensity,
|
|
12
14
|
PropertyGroup,
|
|
@@ -14,12 +16,18 @@ import {
|
|
|
14
16
|
type PropertyListPack,
|
|
15
17
|
PropertyRow,
|
|
16
18
|
type PropertyRowLayout,
|
|
19
|
+
prefDisplayBounds,
|
|
20
|
+
Select,
|
|
17
21
|
SelectRow,
|
|
18
22
|
SliderRow,
|
|
23
|
+
Switch,
|
|
24
|
+
SwitchRow,
|
|
19
25
|
TextRow,
|
|
20
26
|
ToggleRow,
|
|
21
27
|
} from '@weasel-js/ui';
|
|
22
28
|
import { Fragment, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
|
29
|
+
import { auto as autoValue } from '../config/auto';
|
|
30
|
+
import { resolveAutoConfig } from '../config/autoConfig';
|
|
23
31
|
import { fromConfigFields } from '../config/fromConfigField';
|
|
24
32
|
import { schemaNodeAtPath, valueAtPath } from '../config/path';
|
|
25
33
|
import type { ControlRenderer, ResolvedConfig, SectionSpec } from '../config/types';
|
|
@@ -36,6 +44,24 @@ import type { ConfigField } from './types';
|
|
|
36
44
|
*/
|
|
37
45
|
export type ControlPack = 'auto' | 'pairs' | 'one-up';
|
|
38
46
|
|
|
47
|
+
/** How the rows under one heading are laid out: the panel's, or a section's own. */
|
|
48
|
+
interface Rows {
|
|
49
|
+
pack: ControlPack;
|
|
50
|
+
layout: PropertyRowLayout | undefined;
|
|
51
|
+
grid: PropertyListPack;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const gridOf = (pack: ControlPack): PropertyListPack =>
|
|
55
|
+
pack === 'one-up' ? 'auto-color' : 'pairs';
|
|
56
|
+
|
|
57
|
+
function sectionRows(section: SectionSpec, rows: Rows): Rows {
|
|
58
|
+
return {
|
|
59
|
+
pack: section.pack ?? rows.pack,
|
|
60
|
+
layout: section.layout ?? rows.layout,
|
|
61
|
+
grid: section.pack ? gridOf(section.pack) : rows.grid,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
39
65
|
export interface ControlPanelProps<TC extends Record<string, unknown>> {
|
|
40
66
|
/** The instrument's resolved config schema. */
|
|
41
67
|
schema?: ResolvedConfig;
|
|
@@ -80,11 +106,19 @@ export interface ControlPanelProps<TC extends Record<string, unknown>> {
|
|
|
80
106
|
/** A fold moved. The key is a section's label — prefixed by its group's
|
|
81
107
|
* dotted path when the section sits inside one — or a group's own path. */
|
|
82
108
|
onCollapse?: (key: string, collapsed: boolean) => void;
|
|
109
|
+
/** Dotted paths currently unpinned. A row in this set draws ghosted and its
|
|
110
|
+
* dot reads as auto, and the dot writes the sentinel back through
|
|
111
|
+
* `setConfig`. Omitted, the panel keeps the set itself: the dots still work
|
|
112
|
+
* and the sentinel never reaches `setConfig`, which would otherwise store it
|
|
113
|
+
* as the row's value. */
|
|
114
|
+
auto?: ReadonlySet<string>;
|
|
83
115
|
/** Draw leaves marked `hidden`. */
|
|
84
116
|
showHidden?: boolean;
|
|
85
117
|
className?: string;
|
|
86
118
|
}
|
|
87
119
|
|
|
120
|
+
const NO_AUTO: ReadonlySet<string> = new Set();
|
|
121
|
+
|
|
88
122
|
/** Render an instrument's config schema as a stack of controls, each writing
|
|
89
123
|
* back through `setConfig`. Built on the property rows, so a lab's controls
|
|
90
124
|
* are the same aligned, themed rows the rest of the kit uses. */
|
|
@@ -101,12 +135,64 @@ export function ControlPanel<TC extends Record<string, unknown>>({
|
|
|
101
135
|
collapse,
|
|
102
136
|
collapsed,
|
|
103
137
|
onCollapse,
|
|
138
|
+
auto: given,
|
|
104
139
|
showHidden = false,
|
|
105
140
|
className,
|
|
106
141
|
}: ControlPanelProps<TC>) {
|
|
107
142
|
const resolved = useMemo(() => schema ?? fromConfigFields(fields ?? []), [schema, fields]);
|
|
108
143
|
|
|
109
|
-
|
|
144
|
+
// An owner that tracks the set decides what a dot means; one that does not
|
|
145
|
+
// gets a panel that decides for itself, because the alternative is handing it
|
|
146
|
+
// a sentinel it would write into the config as though it were a value.
|
|
147
|
+
const [ownAuto, setOwnAuto] = useState<ReadonlySet<string>>(NO_AUTO);
|
|
148
|
+
const auto = given ?? ownAuto;
|
|
149
|
+
const setRowAuto = (path: string, next: boolean, value: unknown): void => {
|
|
150
|
+
if (given) {
|
|
151
|
+
setConfig(path, next ? autoValue : value);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
setOwnAuto((prev) => {
|
|
155
|
+
const now = new Set(prev);
|
|
156
|
+
if (next) now.add(path);
|
|
157
|
+
else now.delete(path);
|
|
158
|
+
return now;
|
|
159
|
+
});
|
|
160
|
+
// Pinning keeps whatever the row was showing, the same value the controlled
|
|
161
|
+
// path writes back.
|
|
162
|
+
if (!next) setConfig(path, value);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// What the instrument is reading. A row draws and reports from this rather
|
|
166
|
+
// than calling its own resolver against the raw config, which would disagree
|
|
167
|
+
// with the instrument whenever a resolver depends on another auto path.
|
|
168
|
+
const shown = useMemo(
|
|
169
|
+
() => (auto && auto.size > 0 ? resolveAutoConfig(resolved, config, auto) : config),
|
|
170
|
+
[resolved, config, auto],
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
174
|
+
const toggles = useRef(new Map<string, () => void>());
|
|
175
|
+
// Capture phase, because `PropertyRow` is a <label>: a bubbled handler runs
|
|
176
|
+
// after the browser has already begun a range drag or a native control's
|
|
177
|
+
// activation, so the shift-click moves the very value it was meant to unpin.
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
const host = listRef.current;
|
|
180
|
+
if (!host) return;
|
|
181
|
+
const onDown = (e: PointerEvent) => {
|
|
182
|
+
if (!e.shiftKey) return;
|
|
183
|
+
const row = (e.target as HTMLElement | null)?.closest('[data-auto-path]');
|
|
184
|
+
const path = row?.getAttribute('data-auto-path');
|
|
185
|
+
const toggle = path ? toggles.current.get(path) : undefined;
|
|
186
|
+
if (!toggle) return;
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
e.stopPropagation();
|
|
189
|
+
toggle();
|
|
190
|
+
};
|
|
191
|
+
host.addEventListener('pointerdown', onDown, true);
|
|
192
|
+
return () => host.removeEventListener('pointerdown', onDown, true);
|
|
193
|
+
}, []);
|
|
194
|
+
|
|
195
|
+
const gridPack = gridOf(pack);
|
|
110
196
|
// A section that declares how it opens is foldable whether or not the lab
|
|
111
197
|
// asked for folds — there is nothing else for the declaration to mean.
|
|
112
198
|
const folds =
|
|
@@ -116,8 +202,8 @@ export function ControlPanel<TC extends Record<string, unknown>>({
|
|
|
116
202
|
resolved.sections.some((s) => s.collapsed !== undefined);
|
|
117
203
|
const startsFolded = collapse === 'closed';
|
|
118
204
|
|
|
119
|
-
const fold = (key: string, declared?: boolean) => ({
|
|
120
|
-
pack:
|
|
205
|
+
const fold = (key: string, declared?: boolean, grid: PropertyListPack = gridPack) => ({
|
|
206
|
+
pack: grid,
|
|
121
207
|
collapsible: folds,
|
|
122
208
|
defaultCollapsed: declared ?? startsFolded,
|
|
123
209
|
collapsed: collapsed ? (collapsed[key] ?? declared ?? startsFolded) : undefined,
|
|
@@ -125,7 +211,7 @@ export function ControlPanel<TC extends Record<string, unknown>>({
|
|
|
125
211
|
});
|
|
126
212
|
|
|
127
213
|
/** One node, which is either a group to recurse into or a row to draw. */
|
|
128
|
-
const node = (path: string): ReactNode => {
|
|
214
|
+
const node = (path: string, rows: Rows): ReactNode => {
|
|
129
215
|
const found = schemaNodeAtPath(resolved.group, path);
|
|
130
216
|
if (!found) return null;
|
|
131
217
|
if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden)) return null;
|
|
@@ -137,53 +223,117 @@ export function ControlPanel<TC extends Record<string, unknown>>({
|
|
|
137
223
|
leaf={found}
|
|
138
224
|
resolved={resolved}
|
|
139
225
|
config={config}
|
|
226
|
+
shown={shown}
|
|
140
227
|
setConfig={setConfig}
|
|
141
228
|
renderers={renderers}
|
|
142
|
-
pack={pack}
|
|
143
|
-
layout={layout}
|
|
229
|
+
pack={rows.pack}
|
|
230
|
+
layout={rows.layout}
|
|
231
|
+
auto={auto}
|
|
232
|
+
setRowAuto={setRowAuto}
|
|
233
|
+
toggles={toggles.current}
|
|
144
234
|
/>
|
|
145
235
|
);
|
|
146
236
|
}
|
|
147
237
|
// A group with no name organizes without heading it — core's rule for an
|
|
148
238
|
// empty `PrefGroup.name` — so it contributes its rows and no chrome.
|
|
149
|
-
if (found.name === '') return <Fragment key={path}>{body(found, path)}</Fragment>;
|
|
239
|
+
if (found.name === '') return <Fragment key={path}>{body(found, path, rows)}</Fragment>;
|
|
150
240
|
return (
|
|
151
|
-
<PropertyGroup key={path} title={found.name} {...fold(path)}>
|
|
152
|
-
{body(found, path)}
|
|
241
|
+
<PropertyGroup key={path} title={found.name} {...fold(path, undefined, rows.grid)}>
|
|
242
|
+
{body(found, path, rows)}
|
|
153
243
|
</PropertyGroup>
|
|
154
244
|
);
|
|
155
245
|
};
|
|
156
246
|
|
|
247
|
+
/**
|
|
248
|
+
* The leaf at `path` if it may share a row with its neighbours: `pair` is
|
|
249
|
+
* presentational, so a leaf whose control the lab draws itself, or one whose
|
|
250
|
+
* control needs the whole row, keeps its own row instead.
|
|
251
|
+
*/
|
|
252
|
+
const pairable = (path: string): { leaf: PrefLeaf; pair: string } | undefined => {
|
|
253
|
+
const found = schemaNodeAtPath(resolved.group, path);
|
|
254
|
+
if (!found || !isPrefLeaf(found)) return undefined;
|
|
255
|
+
const pair = extra<string>(found, 'pair');
|
|
256
|
+
if (pair === undefined) return undefined;
|
|
257
|
+
if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden))
|
|
258
|
+
return undefined;
|
|
259
|
+
if (renderers?.[path] ?? resolved.renderers[path] ?? renderers?.[found.kind]) return undefined;
|
|
260
|
+
if (!isBuiltinToolPref(found)) return undefined;
|
|
261
|
+
if (found.kind === 'paint' || found.kind === 'object' || isSliderLeaf(found)) return undefined;
|
|
262
|
+
return { leaf: found, pair };
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/** A run of sibling paths as rows, merging each run of adjacent leaves
|
|
266
|
+
* sharing a `pair` id into one row named by the pair. */
|
|
267
|
+
const rowsFor = (paths: readonly string[], rows: Rows): ReactNode[] => {
|
|
268
|
+
const out: ReactNode[] = [];
|
|
269
|
+
for (let i = 0; i < paths.length; ) {
|
|
270
|
+
const head = pairable(paths[i]);
|
|
271
|
+
if (!head) {
|
|
272
|
+
out.push(node(paths[i], rows));
|
|
273
|
+
i += 1;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const { pair } = head;
|
|
277
|
+
const cells = [{ path: paths[i], leaf: head.leaf }];
|
|
278
|
+
let j = i + 1;
|
|
279
|
+
for (; j < paths.length; j += 1) {
|
|
280
|
+
const next = pairable(paths[j]);
|
|
281
|
+
if (!next || next.pair !== pair) break;
|
|
282
|
+
cells.push({ path: paths[j], leaf: next.leaf });
|
|
283
|
+
}
|
|
284
|
+
out.push(
|
|
285
|
+
<PairedRow
|
|
286
|
+
key={paths[i]}
|
|
287
|
+
label={pair}
|
|
288
|
+
cells={cells}
|
|
289
|
+
shown={shown}
|
|
290
|
+
setConfig={setConfig}
|
|
291
|
+
layout={rows.layout}
|
|
292
|
+
/>,
|
|
293
|
+
);
|
|
294
|
+
i = j;
|
|
295
|
+
}
|
|
296
|
+
return out;
|
|
297
|
+
};
|
|
298
|
+
|
|
157
299
|
/** One group's children: its loose nodes, then its sections. */
|
|
158
|
-
const body = (group: PrefGroup, at: string): ReactNode => {
|
|
300
|
+
const body = (group: PrefGroup, at: string, rows: Rows): ReactNode => {
|
|
159
301
|
const sections = resolved.sections.filter((s) => s.at === at);
|
|
160
302
|
const sectioned = new Set(sections.flatMap((s) => s.paths));
|
|
161
303
|
const paths = Object.keys(group.children).map((key) => (at === '' ? key : `${at}.${key}`));
|
|
162
304
|
return (
|
|
163
305
|
<>
|
|
164
|
-
{
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
306
|
+
{rowsFor(
|
|
307
|
+
paths.filter((p) => !sectioned.has(p)),
|
|
308
|
+
rows,
|
|
309
|
+
)}
|
|
310
|
+
{sections.map((section) => {
|
|
311
|
+
const inner = sectionRows(section, rows);
|
|
312
|
+
return (
|
|
313
|
+
<PropertyGroup
|
|
314
|
+
key={sectionKey(section)}
|
|
315
|
+
title={section.label}
|
|
316
|
+
{...fold(sectionKey(section), section.collapsed, inner.grid)}
|
|
317
|
+
>
|
|
318
|
+
{rowsFor(section.paths, inner)}
|
|
319
|
+
</PropertyGroup>
|
|
320
|
+
);
|
|
321
|
+
})}
|
|
174
322
|
</>
|
|
175
323
|
);
|
|
176
324
|
};
|
|
177
325
|
|
|
178
326
|
return (
|
|
179
|
-
<
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
327
|
+
<div ref={listRef}>
|
|
328
|
+
<PropertyList
|
|
329
|
+
pack={gridPack}
|
|
330
|
+
density={density}
|
|
331
|
+
align={align}
|
|
332
|
+
className={className ? `lk-control-panel ${className}` : 'lk-control-panel'}
|
|
333
|
+
>
|
|
334
|
+
{body(resolved.group, '', { pack, layout, grid: gridPack })}
|
|
335
|
+
</PropertyList>
|
|
336
|
+
</div>
|
|
187
337
|
);
|
|
188
338
|
}
|
|
189
339
|
|
|
@@ -199,10 +349,27 @@ interface ControlRowProps<TC extends Record<string, unknown>> {
|
|
|
199
349
|
leaf: PrefLeaf;
|
|
200
350
|
resolved: ResolvedConfig;
|
|
201
351
|
config: TC;
|
|
352
|
+
/** `config` with every auto path resolved -- what the instrument reads. */
|
|
353
|
+
shown: TC;
|
|
202
354
|
setConfig: (path: string, value: unknown) => void;
|
|
203
355
|
renderers?: Record<string, ControlRenderer>;
|
|
204
356
|
pack: ControlPack;
|
|
205
357
|
layout?: PropertyRowLayout;
|
|
358
|
+
auto?: ReadonlySet<string>;
|
|
359
|
+
setRowAuto: (path: string, next: boolean, value: unknown) => void;
|
|
360
|
+
toggles: Map<string, () => void>;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Whether this leaf draws as a slider, mirroring the condition the `number`
|
|
364
|
+
* arm below branches on. A slider is the one control whose value cannot be
|
|
365
|
+
* read off the control itself. */
|
|
366
|
+
function isSliderLeaf(leaf: PrefLeaf): boolean {
|
|
367
|
+
return (
|
|
368
|
+
leaf.kind === 'number' &&
|
|
369
|
+
extra<string>(leaf, 'control') === 'slider' &&
|
|
370
|
+
extra<number>(leaf, 'min') !== undefined &&
|
|
371
|
+
extra<number>(leaf, 'max') !== undefined
|
|
372
|
+
);
|
|
206
373
|
}
|
|
207
374
|
|
|
208
375
|
/** Reads a labkit-only extra off a leaf. `PrefLeaf` has no field for these,
|
|
@@ -216,21 +383,62 @@ function ControlRow<TC extends Record<string, unknown>>({
|
|
|
216
383
|
leaf,
|
|
217
384
|
resolved,
|
|
218
385
|
config,
|
|
386
|
+
shown,
|
|
219
387
|
setConfig,
|
|
220
388
|
renderers,
|
|
221
389
|
pack,
|
|
222
390
|
layout,
|
|
391
|
+
auto,
|
|
392
|
+
setRowAuto,
|
|
393
|
+
toggles,
|
|
223
394
|
}: ControlRowProps<TC>) {
|
|
224
395
|
const write = (value: unknown): void => setConfig(path, value);
|
|
225
396
|
const fallback = extra<unknown>(leaf, 'default');
|
|
226
|
-
const
|
|
397
|
+
const pinned = valueAtPath(config, path) ?? fallback;
|
|
398
|
+
|
|
399
|
+
const isAutoRow = auto?.has(path) ?? false;
|
|
400
|
+
const canAuto = !extra<boolean>(leaf, 'manual');
|
|
401
|
+
// Read out of the resolved config rather than re-running this leaf's own
|
|
402
|
+
// resolver, so the row reports the value the instrument actually got — the
|
|
403
|
+
// two diverge as soon as a resolver reads another auto path.
|
|
404
|
+
const resolvedValue = isAutoRow ? valueAtPath(shown, path) : undefined;
|
|
405
|
+
// An auto row draws what the resolver decided, not the value underneath it —
|
|
406
|
+
// a handle sitting at the pinned number while the readout says something else
|
|
407
|
+
// reads as a rendering bug. Pinning then keeps what you were looking at.
|
|
408
|
+
const value = resolvedValue ?? pinned;
|
|
409
|
+
const setAuto = (next: boolean): void => setRowAuto(path, next, value);
|
|
410
|
+
const onAutoChange = canAuto ? setAuto : undefined;
|
|
411
|
+
// A slider's handle is a position, not a number, so the readout is the only
|
|
412
|
+
// place its resolved value can be read. Every other control renders its own
|
|
413
|
+
// value, and repeating it there says it twice and wraps the narrow slot.
|
|
414
|
+
const readsItsOwnValue = !isSliderLeaf(leaf);
|
|
415
|
+
// A readout says what the control would say, so it reads in the display
|
|
416
|
+
// unit: a radian in a row that edits degrees is a different number.
|
|
417
|
+
const readoutValue =
|
|
418
|
+
leaf.kind === 'number' && typeof resolvedValue === 'number'
|
|
419
|
+
? numberField(leaf, resolvedValue).shown
|
|
420
|
+
: resolvedValue;
|
|
421
|
+
const autoReadout = !isAutoRow
|
|
422
|
+
? undefined
|
|
423
|
+
: resolvedValue === undefined || readsItsOwnValue
|
|
424
|
+
? 'auto'
|
|
425
|
+
: `auto · ${String(readoutValue)}`;
|
|
426
|
+
const autoProps = { auto: isAutoRow, onAutoChange, 'data-auto-path': canAuto ? path : undefined };
|
|
427
|
+
|
|
428
|
+
useEffect(() => {
|
|
429
|
+
if (!canAuto) return;
|
|
430
|
+
toggles.set(path, () => setAuto(!isAutoRow));
|
|
431
|
+
return () => {
|
|
432
|
+
toggles.delete(path);
|
|
433
|
+
};
|
|
434
|
+
});
|
|
227
435
|
|
|
228
436
|
// Most specific wins, and within a tier the lab's entry beats the
|
|
229
437
|
// instrument's: controls[path] -> node .render -> controls[kind] -> built-in.
|
|
230
438
|
const custom = renderers?.[path] ?? resolved.renderers[path] ?? renderers?.[leaf.kind];
|
|
231
439
|
// A custom row places itself like any other; one that needs the full width
|
|
232
440
|
// says so with `<PropertyRow span>`, which is the same opt-out a built-in has.
|
|
233
|
-
if (custom) return custom({ path, pref: leaf, value, setValue: write });
|
|
441
|
+
if (custom) return custom({ path, pref: leaf, value, setValue: write, auto: isAutoRow, setAuto });
|
|
234
442
|
|
|
235
443
|
const label = leaf.name;
|
|
236
444
|
const description = leaf.description;
|
|
@@ -244,57 +452,61 @@ function ControlRow<TC extends Record<string, unknown>>({
|
|
|
244
452
|
|
|
245
453
|
switch (leaf.kind) {
|
|
246
454
|
case 'number': {
|
|
247
|
-
const
|
|
248
|
-
const
|
|
249
|
-
const step = extra<number>(leaf, 'step');
|
|
250
|
-
const suffix = extra<string>(leaf, 'suffix');
|
|
455
|
+
const field = numberField(leaf, read<number>());
|
|
456
|
+
const suffix = extra<string>(leaf, 'suffix') ?? field.unit?.suffix;
|
|
251
457
|
const notation = extra<PrefNumberFormat>(leaf, 'format');
|
|
252
|
-
if (
|
|
458
|
+
if (isSliderLeaf(leaf)) {
|
|
253
459
|
return (
|
|
254
460
|
<SliderRow
|
|
255
461
|
label={label}
|
|
256
|
-
value={
|
|
257
|
-
min={min}
|
|
258
|
-
max={max}
|
|
259
|
-
step={step}
|
|
462
|
+
value={field.shown}
|
|
463
|
+
min={field.min as number}
|
|
464
|
+
max={field.max as number}
|
|
465
|
+
step={field.step}
|
|
260
466
|
notation={notation}
|
|
261
467
|
unit={suffix}
|
|
262
|
-
onChange={write}
|
|
468
|
+
onChange={(n) => write(field.store(n))}
|
|
469
|
+
readout={autoReadout}
|
|
263
470
|
layout={layout}
|
|
264
471
|
span={wide}
|
|
265
472
|
description={description}
|
|
473
|
+
{...autoProps}
|
|
266
474
|
/>
|
|
267
475
|
);
|
|
268
476
|
}
|
|
269
477
|
// NumberRow commits every keystroke and does not clamp, so the bounds a
|
|
270
478
|
// schema declares are enforced here — an instrument should never be
|
|
271
479
|
// handed a config value outside the range it asked for.
|
|
272
|
-
const lo = min ?? Number.NEGATIVE_INFINITY;
|
|
273
|
-
const hi = max ?? Number.POSITIVE_INFINITY;
|
|
274
480
|
return (
|
|
275
481
|
<NumberRow
|
|
276
482
|
label={label}
|
|
277
|
-
value={
|
|
278
|
-
min={min}
|
|
279
|
-
max={max}
|
|
280
|
-
step={step}
|
|
483
|
+
value={field.shown}
|
|
484
|
+
min={field.min}
|
|
485
|
+
max={field.max}
|
|
486
|
+
step={field.step}
|
|
281
487
|
unit={suffix}
|
|
282
|
-
onChange={(n) => write(
|
|
488
|
+
onChange={(n) => write(field.store(n))}
|
|
489
|
+
readout={autoReadout}
|
|
283
490
|
layout={layout}
|
|
284
491
|
description={description}
|
|
492
|
+
{...autoProps}
|
|
285
493
|
/>
|
|
286
494
|
);
|
|
287
495
|
}
|
|
288
|
-
case 'boolean':
|
|
496
|
+
case 'boolean': {
|
|
497
|
+
const Row = extra<string>(leaf, 'control') === 'switch' ? SwitchRow : CheckboxRow;
|
|
289
498
|
return (
|
|
290
|
-
<
|
|
499
|
+
<Row
|
|
291
500
|
label={label}
|
|
292
501
|
value={read<boolean>()}
|
|
293
502
|
onChange={write}
|
|
503
|
+
readout={autoReadout}
|
|
294
504
|
layout={layout}
|
|
295
505
|
description={description}
|
|
506
|
+
{...autoProps}
|
|
296
507
|
/>
|
|
297
508
|
);
|
|
509
|
+
}
|
|
298
510
|
case 'enum': {
|
|
299
511
|
const options = extra<readonly { value: string; label: string }[]>(leaf, 'options') ?? [];
|
|
300
512
|
const segmented = extra<string>(leaf, 'control') === 'radio';
|
|
@@ -305,9 +517,11 @@ function ControlRow<TC extends Record<string, unknown>>({
|
|
|
305
517
|
value={read<string>()}
|
|
306
518
|
options={options}
|
|
307
519
|
onChange={write}
|
|
520
|
+
readout={autoReadout}
|
|
308
521
|
layout={layout}
|
|
309
522
|
span={segmented && wide}
|
|
310
523
|
description={description}
|
|
524
|
+
{...autoProps}
|
|
311
525
|
/>
|
|
312
526
|
);
|
|
313
527
|
}
|
|
@@ -318,21 +532,38 @@ function ControlRow<TC extends Record<string, unknown>>({
|
|
|
318
532
|
label={label}
|
|
319
533
|
value={read<string>()}
|
|
320
534
|
write={write}
|
|
535
|
+
readout={autoReadout}
|
|
321
536
|
layout={layout}
|
|
322
537
|
span={wide}
|
|
323
538
|
description={description}
|
|
539
|
+
{...autoProps}
|
|
324
540
|
/>
|
|
325
541
|
);
|
|
326
|
-
case 'color':
|
|
542
|
+
case 'color': {
|
|
543
|
+
// `ColorRow` takes alpha as a number beside a `#rrggbb` swatch, so an
|
|
544
|
+
// `#rrggbbaa` value has to be split going in and rejoined coming out —
|
|
545
|
+
// handed whole to the swatch, the browser cannot parse it and the first
|
|
546
|
+
// edit writes back black.
|
|
547
|
+
const common = {
|
|
548
|
+
label,
|
|
549
|
+
readout: autoReadout,
|
|
550
|
+
layout,
|
|
551
|
+
description,
|
|
552
|
+
...autoProps,
|
|
553
|
+
};
|
|
554
|
+
if (extra<boolean>(leaf, 'alpha') !== true)
|
|
555
|
+
return <ColorRow {...common} value={read<string>()} onChange={write} />;
|
|
556
|
+
const { rgb, alpha } = splitHexAlpha(read<string>());
|
|
327
557
|
return (
|
|
328
558
|
<ColorRow
|
|
329
|
-
|
|
330
|
-
value={
|
|
331
|
-
onChange={write}
|
|
332
|
-
|
|
333
|
-
|
|
559
|
+
{...common}
|
|
560
|
+
value={rgb}
|
|
561
|
+
onChange={(next) => write(joinHexAlpha(next, alpha))}
|
|
562
|
+
alpha={alpha}
|
|
563
|
+
onAlphaChange={(next) => write(joinHexAlpha(rgb, next))}
|
|
334
564
|
/>
|
|
335
565
|
);
|
|
566
|
+
}
|
|
336
567
|
case 'paint':
|
|
337
568
|
case 'object':
|
|
338
569
|
// Declined: a hex swatch would write a solid over a gradient, and a flat
|
|
@@ -368,9 +599,7 @@ function UnwiredRow({
|
|
|
368
599
|
);
|
|
369
600
|
}
|
|
370
601
|
|
|
371
|
-
/**
|
|
372
|
-
* keystroke, which means the row is locally controlled between commits and has
|
|
373
|
-
* to notice when the config changes underneath it. */
|
|
602
|
+
/** A text row whose writes are debounced — see {@link useDebouncedText}. */
|
|
374
603
|
function DebouncedTextRow({
|
|
375
604
|
leaf,
|
|
376
605
|
label,
|
|
@@ -379,6 +608,10 @@ function DebouncedTextRow({
|
|
|
379
608
|
layout,
|
|
380
609
|
span,
|
|
381
610
|
description,
|
|
611
|
+
readout,
|
|
612
|
+
auto,
|
|
613
|
+
onAutoChange,
|
|
614
|
+
'data-auto-path': autoPath,
|
|
382
615
|
}: {
|
|
383
616
|
leaf: PrefLeaf;
|
|
384
617
|
label: string;
|
|
@@ -387,8 +620,260 @@ function DebouncedTextRow({
|
|
|
387
620
|
layout?: PropertyRowLayout;
|
|
388
621
|
span?: boolean;
|
|
389
622
|
description?: string;
|
|
623
|
+
readout?: ReactNode;
|
|
624
|
+
auto?: boolean;
|
|
625
|
+
onAutoChange?: (next: boolean) => void;
|
|
626
|
+
'data-auto-path'?: string;
|
|
627
|
+
}) {
|
|
628
|
+
const text = useDebouncedText(
|
|
629
|
+
value,
|
|
630
|
+
write as (v: string) => void,
|
|
631
|
+
extra<number>(leaf, 'debounceMs') ?? 150,
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
return (
|
|
635
|
+
<TextRow
|
|
636
|
+
label={label}
|
|
637
|
+
readout={readout}
|
|
638
|
+
layout={layout}
|
|
639
|
+
span={span}
|
|
640
|
+
description={description}
|
|
641
|
+
auto={auto}
|
|
642
|
+
onAutoChange={onAutoChange}
|
|
643
|
+
data-auto-path={autoPath}
|
|
644
|
+
value={text.local}
|
|
645
|
+
placeholder={extra<string>(leaf, 'placeholder')}
|
|
646
|
+
maxLength={extra<number>(leaf, 'maxLength')}
|
|
647
|
+
onChange={text.type}
|
|
648
|
+
/>
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* A number leaf's field in the unit it is edited in: what the control shows,
|
|
654
|
+
* the bounds it shows them against, and what a typed number stores once it is
|
|
655
|
+
* clamped and converted back. Without a `unit` this is what the leaf declares,
|
|
656
|
+
* unchanged.
|
|
657
|
+
*/
|
|
658
|
+
function numberField(
|
|
659
|
+
leaf: PrefLeaf,
|
|
660
|
+
value: number,
|
|
661
|
+
): {
|
|
662
|
+
unit: PrefNumberUnit | undefined;
|
|
663
|
+
min: number | undefined;
|
|
664
|
+
max: number | undefined;
|
|
665
|
+
step: number | undefined;
|
|
666
|
+
shown: number;
|
|
667
|
+
store: (shown: number) => number;
|
|
668
|
+
} {
|
|
669
|
+
const unit = extra<PrefNumberUnit>(leaf, 'unit');
|
|
670
|
+
// `min`, `max` and `step` are declared in the stored unit alongside the
|
|
671
|
+
// value, so they convert with it — a leaf storing radians and showing
|
|
672
|
+
// degrees would otherwise clamp typed degrees against 0..6.28.
|
|
673
|
+
const bounds = unit
|
|
674
|
+
? prefDisplayBounds(leaf as PrefNumber)
|
|
675
|
+
: {
|
|
676
|
+
min: extra<number>(leaf, 'min'),
|
|
677
|
+
max: extra<number>(leaf, 'max'),
|
|
678
|
+
step: extra<number>(leaf, 'step'),
|
|
679
|
+
};
|
|
680
|
+
const lo = bounds.min ?? Number.NEGATIVE_INFINITY;
|
|
681
|
+
const hi = bounds.max ?? Number.POSITIVE_INFINITY;
|
|
682
|
+
return {
|
|
683
|
+
unit,
|
|
684
|
+
min: bounds.min,
|
|
685
|
+
max: bounds.max,
|
|
686
|
+
step: bounds.step,
|
|
687
|
+
shown: unit ? unit.toDisplay(value) : value,
|
|
688
|
+
store: (shown) => {
|
|
689
|
+
const clamped = Math.min(hi, Math.max(lo, shown));
|
|
690
|
+
return unit ? unit.fromDisplay(clamped) : clamped;
|
|
691
|
+
},
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa` as the `#rrggbb` an
|
|
696
|
+
* `<input type="color">` can hold and the 0..1 alpha beside it. Anything else
|
|
697
|
+
* reads as opaque black, which is what the input would show for it anyway. */
|
|
698
|
+
function splitHexAlpha(value: string): { rgb: string; alpha: number } {
|
|
699
|
+
const digits = /^#([0-9a-f]{3,8})$/i.exec(value?.trim() ?? '')?.[1];
|
|
700
|
+
const twice = (s: string): string =>
|
|
701
|
+
s
|
|
702
|
+
.split('')
|
|
703
|
+
.map((c) => c + c)
|
|
704
|
+
.join('');
|
|
705
|
+
const byte = (s: string): number => Number.parseInt(s, 16) / 255;
|
|
706
|
+
switch (digits?.length) {
|
|
707
|
+
case 3:
|
|
708
|
+
return { rgb: `#${twice(digits)}`, alpha: 1 };
|
|
709
|
+
case 4:
|
|
710
|
+
return { rgb: `#${twice(digits.slice(0, 3))}`, alpha: byte(twice(digits.slice(3))) };
|
|
711
|
+
case 6:
|
|
712
|
+
return { rgb: `#${digits}`, alpha: 1 };
|
|
713
|
+
case 8:
|
|
714
|
+
return { rgb: `#${digits.slice(0, 6)}`, alpha: byte(digits.slice(6)) };
|
|
715
|
+
default:
|
|
716
|
+
return { rgb: '#000000', alpha: 1 };
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** A `#rrggbb` and a 0..1 alpha back into the `#rrggbbaa` the config holds. */
|
|
721
|
+
function joinHexAlpha(rgb: string, alpha: number): string {
|
|
722
|
+
const byte = Math.max(0, Math.min(255, Math.round(alpha * 255)));
|
|
723
|
+
return `${rgb}${byte.toString(16).padStart(2, '0')}`;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** One cell of a paired row, with its path and the leaf it draws. */
|
|
727
|
+
interface PairCellSpec {
|
|
728
|
+
path: string;
|
|
729
|
+
leaf: PrefLeaf;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Leaves sharing a `pair` id, side by side on one row the pair names — the
|
|
734
|
+
* compact `X` / `Y` idiom `SelectionPanel` draws for the same annotation.
|
|
735
|
+
*
|
|
736
|
+
* The cells are bare controls: a row holds one label column, so each leaf's
|
|
737
|
+
* own name stays with its control as the accessible name and its description
|
|
738
|
+
* as a title. For the same reason the row carries no pin dot — a dot names one
|
|
739
|
+
* path, and this row has several.
|
|
740
|
+
*/
|
|
741
|
+
function PairedRow<TC extends Record<string, unknown>>({
|
|
742
|
+
label,
|
|
743
|
+
cells,
|
|
744
|
+
shown,
|
|
745
|
+
setConfig,
|
|
746
|
+
layout,
|
|
747
|
+
}: {
|
|
748
|
+
label: string;
|
|
749
|
+
cells: readonly PairCellSpec[];
|
|
750
|
+
shown: TC;
|
|
751
|
+
setConfig: (path: string, value: unknown) => void;
|
|
752
|
+
layout?: PropertyRowLayout;
|
|
390
753
|
}) {
|
|
391
|
-
|
|
754
|
+
return (
|
|
755
|
+
<PropertyRow group label={label} layout={layout}>
|
|
756
|
+
{cells.map(({ path, leaf }) => (
|
|
757
|
+
<PairCell
|
|
758
|
+
key={path}
|
|
759
|
+
leaf={leaf}
|
|
760
|
+
value={valueAtPath(shown, path) ?? extra<unknown>(leaf, 'default')}
|
|
761
|
+
write={(value) => setConfig(path, value)}
|
|
762
|
+
/>
|
|
763
|
+
))}
|
|
764
|
+
</PropertyRow>
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** The control a paired cell holds, without the row chrome a whole row of its
|
|
769
|
+
* own would bring. */
|
|
770
|
+
function PairCell({
|
|
771
|
+
leaf,
|
|
772
|
+
value,
|
|
773
|
+
write,
|
|
774
|
+
}: {
|
|
775
|
+
leaf: PrefLeaf;
|
|
776
|
+
value: unknown;
|
|
777
|
+
write: (value: unknown) => void;
|
|
778
|
+
}) {
|
|
779
|
+
const name = leaf.name;
|
|
780
|
+
const title = leaf.description;
|
|
781
|
+
const text = useDebouncedText(
|
|
782
|
+
typeof value === 'string' ? value : '',
|
|
783
|
+
write,
|
|
784
|
+
extra<number>(leaf, 'debounceMs') ?? 150,
|
|
785
|
+
);
|
|
786
|
+
switch (leaf.kind) {
|
|
787
|
+
case 'number': {
|
|
788
|
+
const field = numberField(leaf, typeof value === 'number' ? value : 0);
|
|
789
|
+
return (
|
|
790
|
+
<input
|
|
791
|
+
type="number"
|
|
792
|
+
aria-label={name}
|
|
793
|
+
title={title}
|
|
794
|
+
value={field.shown}
|
|
795
|
+
min={field.min}
|
|
796
|
+
max={field.max}
|
|
797
|
+
step={field.step}
|
|
798
|
+
onChange={(e) => {
|
|
799
|
+
const n = Number(e.target.value);
|
|
800
|
+
if (e.target.value !== '' && Number.isFinite(n)) write(field.store(n));
|
|
801
|
+
}}
|
|
802
|
+
/>
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
case 'boolean':
|
|
806
|
+
return extra<string>(leaf, 'control') === 'switch' ? (
|
|
807
|
+
<Switch aria-label={name} isSelected={value === true} onChange={write} />
|
|
808
|
+
) : (
|
|
809
|
+
<input
|
|
810
|
+
type="checkbox"
|
|
811
|
+
aria-label={name}
|
|
812
|
+
title={title}
|
|
813
|
+
checked={value === true}
|
|
814
|
+
onChange={(e) => write(e.target.checked)}
|
|
815
|
+
/>
|
|
816
|
+
);
|
|
817
|
+
case 'string':
|
|
818
|
+
return (
|
|
819
|
+
<input
|
|
820
|
+
type="text"
|
|
821
|
+
aria-label={name}
|
|
822
|
+
title={title}
|
|
823
|
+
value={text.local}
|
|
824
|
+
placeholder={extra<string>(leaf, 'placeholder')}
|
|
825
|
+
maxLength={extra<number>(leaf, 'maxLength')}
|
|
826
|
+
onChange={(e) => text.type(e.target.value)}
|
|
827
|
+
/>
|
|
828
|
+
);
|
|
829
|
+
case 'color': {
|
|
830
|
+
// The alpha track needs a row of its own to sit under, so a paired
|
|
831
|
+
// swatch edits the color and carries the stored alpha through untouched.
|
|
832
|
+
const { rgb, alpha } = splitHexAlpha(typeof value === 'string' ? value : '');
|
|
833
|
+
const carriesAlpha = extra<boolean>(leaf, 'alpha') === true;
|
|
834
|
+
return (
|
|
835
|
+
<input
|
|
836
|
+
type="color"
|
|
837
|
+
aria-label={name}
|
|
838
|
+
title={title}
|
|
839
|
+
value={rgb}
|
|
840
|
+
onChange={(e) =>
|
|
841
|
+
write(carriesAlpha ? joinHexAlpha(e.target.value, alpha) : e.target.value)
|
|
842
|
+
}
|
|
843
|
+
/>
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
case 'enum': {
|
|
847
|
+
const options = extra<readonly { value: string; label: string }[]>(leaf, 'options') ?? [];
|
|
848
|
+
// `Select` takes no `title`, so the cell's help hangs off a wrapper.
|
|
849
|
+
return (
|
|
850
|
+
<span title={title}>
|
|
851
|
+
<Select<string>
|
|
852
|
+
variant="bare"
|
|
853
|
+
aria-label={name}
|
|
854
|
+
options={options}
|
|
855
|
+
selectedKey={typeof value === 'string' ? value : null}
|
|
856
|
+
onSelectionChange={write}
|
|
857
|
+
/>
|
|
858
|
+
</span>
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
default:
|
|
862
|
+
// Unreachable: `pairable` admits only the kinds above.
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Live text held locally between debounced commits, so typing does not re-run
|
|
869
|
+
* the instrument on every keystroke. Locally controlled between commits, which
|
|
870
|
+
* means it has to notice the value changing underneath it.
|
|
871
|
+
*/
|
|
872
|
+
function useDebouncedText(
|
|
873
|
+
value: string,
|
|
874
|
+
write: (value: string) => void,
|
|
875
|
+
debounceMs: number,
|
|
876
|
+
): { local: string; type: (next: string) => void } {
|
|
392
877
|
const [local, setLocal] = useState(value);
|
|
393
878
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
394
879
|
const lastExternal = useRef(value);
|
|
@@ -407,25 +892,17 @@ function DebouncedTextRow({
|
|
|
407
892
|
[],
|
|
408
893
|
);
|
|
409
894
|
|
|
410
|
-
return
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
lastExternal.current = next;
|
|
424
|
-
write(next);
|
|
425
|
-
};
|
|
426
|
-
if (debounceMs === 0) commit();
|
|
427
|
-
else timer.current = setTimeout(commit, debounceMs);
|
|
428
|
-
}}
|
|
429
|
-
/>
|
|
430
|
-
);
|
|
895
|
+
return {
|
|
896
|
+
local,
|
|
897
|
+
type: (next) => {
|
|
898
|
+
setLocal(next);
|
|
899
|
+
if (timer.current) clearTimeout(timer.current);
|
|
900
|
+
const commit = () => {
|
|
901
|
+
lastExternal.current = next;
|
|
902
|
+
write(next);
|
|
903
|
+
};
|
|
904
|
+
if (debounceMs === 0) commit();
|
|
905
|
+
else timer.current = setTimeout(commit, debounceMs);
|
|
906
|
+
},
|
|
907
|
+
};
|
|
431
908
|
}
|