@weasel-js/labkit 1.5.0 → 1.5.2
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-CMMo180l.d.ts} +1 -1
- package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
- package/dist/_dts/Select.d-ZTiwzYyT.d.ts +479 -0
- package/dist/_dts/{frac-8R6UmlvN.d.ts → frac-B4hFJMiN.d.ts} +54 -6
- package/dist/_dts/{index-retnAut7.d.ts → index-8LiKe3A-.d.ts} +77 -10
- package/dist/_dts/{index-B8uu2ba7.d.ts → index-DlKolEIZ.d.ts} +20 -8
- package/dist/_dts/{types-Ca2LCnPq.d.ts → types-Db-3NNk1.d.ts} +28 -4
- package/dist/_dts/{usePanZoom-nS798rOF.d.ts → usePanZoom-SSTZe410.d.ts} +4 -47
- package/dist/_dts/{useTrialState-jcx_TdU6.d.ts → useTrialState-pnAE-UBD.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-6BUQ77ZV.js +18 -0
- package/dist/chunk-6BUQ77ZV.js.map +1 -0
- package/dist/{chunk-KJALCDWE.js → chunk-753Y6HE6.js} +9 -9
- package/dist/chunk-753Y6HE6.js.map +1 -0
- package/dist/{chunk-6ZDGOZQV.js → chunk-A2ACAGW6.js} +72 -20
- package/dist/chunk-A2ACAGW6.js.map +1 -0
- package/dist/{chunk-NS64DXMN.js → chunk-CKUKF72R.js} +44 -21
- package/dist/chunk-CKUKF72R.js.map +1 -0
- package/dist/{chunk-MUKOW3TC.js → chunk-D35PFTS7.js} +4 -4
- package/dist/{chunk-MUKOW3TC.js.map → chunk-D35PFTS7.js.map} +1 -1
- package/dist/{chunk-UU3NJO6N.js → chunk-EMXAIRUJ.js} +3 -3
- package/dist/{chunk-UU3NJO6N.js.map → chunk-EMXAIRUJ.js.map} +1 -1
- package/dist/{chunk-ZPH5WTWF.js → chunk-ERFMC6WU.js} +142 -66
- package/dist/chunk-ERFMC6WU.js.map +1 -0
- package/dist/chunk-FKLT3ITC.js +590 -0
- package/dist/chunk-FKLT3ITC.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-EKIICY6X.js → chunk-KJCUERQF.js} +37 -25
- package/dist/chunk-KJCUERQF.js.map +1 -0
- package/dist/chunk-LASCBWW7.js +13456 -0
- package/dist/chunk-LASCBWW7.js.map +1 -0
- package/dist/{chunk-67SJLMC7.js → chunk-LRI3MBGR.js} +45 -12
- package/dist/chunk-LRI3MBGR.js.map +1 -0
- package/dist/chunk-REOBGYMC.js +150 -0
- package/dist/chunk-REOBGYMC.js.map +1 -0
- package/dist/{chunk-PGETSEDK.js → chunk-TT5C5S6R.js} +11 -90
- package/dist/chunk-TT5C5S6R.js.map +1 -0
- package/dist/{chunk-NDRYLVOW.js → chunk-Z76KEZAX.js} +2 -2
- package/dist/chunk-Z76KEZAX.js.map +1 -0
- package/dist/config/index.d.ts +62 -19
- 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 +230 -62
- package/dist/index.js +1367 -1698
- 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 +57 -230
- 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 +266 -57
- 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/index.ts +0 -9
- 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 +3 -3
- package/src/chrome/regions/PaletteRegion.test.tsx +18 -0
- package/src/chrome/regions/PaletteRegion.tsx +6 -10
- package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
- package/src/chrome/regions/TitleBarRegion.tsx +1 -1
- package/src/chrome/regions/ToolbarRegion.tsx +11 -7
- package/src/chrome/types.ts +11 -2
- 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 +55 -11
- 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.less +23 -0
- package/src/controls/ControlPanel.stories.tsx +83 -1
- package/src/controls/ControlPanel.test.tsx +457 -4
- package/src/controls/ControlPanel.tsx +534 -73
- package/src/index.test.ts +0 -2
- package/src/index.ts +33 -11
- 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 +124 -16
- 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.less +1 -1
- package/src/layers/LayerList.stories.tsx +2 -2
- 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.less +6 -1
- package/src/primitives/FpsMeter.stories.tsx +1 -1
- package/src/primitives/FpsMeter.test.tsx +12 -4
- package/src/primitives/FpsMeter.tsx +6 -1
- package/src/primitives/JobProgress.stories.tsx +2 -2
- 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.less +6 -0
- 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 +31 -2
- package/src/state/useTrialState.test.tsx +85 -0
- package/src/state/useTrialState.ts +9 -1
- package/src/state/view.test.ts +4 -4
- 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 +36 -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-KJALCDWE.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/canvas/useOrbit.test.ts +0 -167
- package/src/canvas/useOrbit.ts +0 -156
- 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,19 @@ 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,
|
|
26
|
+
ToggleBar,
|
|
20
27
|
ToggleRow,
|
|
21
28
|
} from '@weasel-js/ui';
|
|
22
29
|
import { Fragment, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
|
30
|
+
import { auto as autoValue } from '../config/auto';
|
|
31
|
+
import { resolveAutoConfig } from '../config/autoConfig';
|
|
23
32
|
import { fromConfigFields } from '../config/fromConfigField';
|
|
24
33
|
import { schemaNodeAtPath, valueAtPath } from '../config/path';
|
|
25
34
|
import type { ControlRenderer, ResolvedConfig, SectionSpec } from '../config/types';
|
|
@@ -36,6 +45,24 @@ import type { ConfigField } from './types';
|
|
|
36
45
|
*/
|
|
37
46
|
export type ControlPack = 'auto' | 'pairs' | 'one-up';
|
|
38
47
|
|
|
48
|
+
/** How the rows under one heading are laid out: the panel's, or a section's own. */
|
|
49
|
+
interface Rows {
|
|
50
|
+
pack: ControlPack;
|
|
51
|
+
layout: PropertyRowLayout | undefined;
|
|
52
|
+
grid: PropertyListPack;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const gridOf = (pack: ControlPack): PropertyListPack =>
|
|
56
|
+
pack === 'one-up' ? 'auto-color' : 'pairs';
|
|
57
|
+
|
|
58
|
+
function sectionRows(section: SectionSpec, rows: Rows): Rows {
|
|
59
|
+
return {
|
|
60
|
+
pack: section.pack ?? rows.pack,
|
|
61
|
+
layout: section.layout ?? rows.layout,
|
|
62
|
+
grid: section.pack ? gridOf(section.pack) : rows.grid,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
39
66
|
export interface ControlPanelProps<TC extends Record<string, unknown>> {
|
|
40
67
|
/** The instrument's resolved config schema. */
|
|
41
68
|
schema?: ResolvedConfig;
|
|
@@ -80,11 +107,19 @@ export interface ControlPanelProps<TC extends Record<string, unknown>> {
|
|
|
80
107
|
/** A fold moved. The key is a section's label — prefixed by its group's
|
|
81
108
|
* dotted path when the section sits inside one — or a group's own path. */
|
|
82
109
|
onCollapse?: (key: string, collapsed: boolean) => void;
|
|
110
|
+
/** Dotted paths currently unpinned. A row in this set hides its control and
|
|
111
|
+
* reads out `auto`, and clicking its label writes the sentinel back through
|
|
112
|
+
* `setConfig`. Omitted, the panel keeps the set itself: the labels still
|
|
113
|
+
* toggle and the sentinel never reaches `setConfig`, which would otherwise
|
|
114
|
+
* store it as the row's value. */
|
|
115
|
+
auto?: ReadonlySet<string>;
|
|
83
116
|
/** Draw leaves marked `hidden`. */
|
|
84
117
|
showHidden?: boolean;
|
|
85
118
|
className?: string;
|
|
86
119
|
}
|
|
87
120
|
|
|
121
|
+
const NO_AUTO: ReadonlySet<string> = new Set();
|
|
122
|
+
|
|
88
123
|
/** Render an instrument's config schema as a stack of controls, each writing
|
|
89
124
|
* back through `setConfig`. Built on the property rows, so a lab's controls
|
|
90
125
|
* are the same aligned, themed rows the rest of the kit uses. */
|
|
@@ -101,12 +136,42 @@ export function ControlPanel<TC extends Record<string, unknown>>({
|
|
|
101
136
|
collapse,
|
|
102
137
|
collapsed,
|
|
103
138
|
onCollapse,
|
|
139
|
+
auto: given,
|
|
104
140
|
showHidden = false,
|
|
105
141
|
className,
|
|
106
142
|
}: ControlPanelProps<TC>) {
|
|
107
143
|
const resolved = useMemo(() => schema ?? fromConfigFields(fields ?? []), [schema, fields]);
|
|
108
144
|
|
|
109
|
-
|
|
145
|
+
// An owner that tracks the set decides what a dot means; one that does not
|
|
146
|
+
// gets a panel that decides for itself, because the alternative is handing it
|
|
147
|
+
// a sentinel it would write into the config as though it were a value.
|
|
148
|
+
const [ownAuto, setOwnAuto] = useState<ReadonlySet<string>>(NO_AUTO);
|
|
149
|
+
const auto = given ?? ownAuto;
|
|
150
|
+
const setRowAuto = (path: string, next: boolean, value: unknown): void => {
|
|
151
|
+
if (given) {
|
|
152
|
+
setConfig(path, next ? autoValue : value);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
setOwnAuto((prev) => {
|
|
156
|
+
const now = new Set(prev);
|
|
157
|
+
if (next) now.add(path);
|
|
158
|
+
else now.delete(path);
|
|
159
|
+
return now;
|
|
160
|
+
});
|
|
161
|
+
// Pinning keeps whatever the row was showing, the same value the controlled
|
|
162
|
+
// path writes back.
|
|
163
|
+
if (!next) setConfig(path, value);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// What the instrument is reading. A row draws and reports from this rather
|
|
167
|
+
// than calling its own resolver against the raw config, which would disagree
|
|
168
|
+
// with the instrument whenever a resolver depends on another auto path.
|
|
169
|
+
const shown = useMemo(
|
|
170
|
+
() => (auto && auto.size > 0 ? resolveAutoConfig(resolved, config, auto) : config),
|
|
171
|
+
[resolved, config, auto],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const gridPack = gridOf(pack);
|
|
110
175
|
// A section that declares how it opens is foldable whether or not the lab
|
|
111
176
|
// asked for folds — there is nothing else for the declaration to mean.
|
|
112
177
|
const folds =
|
|
@@ -116,8 +181,8 @@ export function ControlPanel<TC extends Record<string, unknown>>({
|
|
|
116
181
|
resolved.sections.some((s) => s.collapsed !== undefined);
|
|
117
182
|
const startsFolded = collapse === 'closed';
|
|
118
183
|
|
|
119
|
-
const fold = (key: string, declared?: boolean) => ({
|
|
120
|
-
pack:
|
|
184
|
+
const fold = (key: string, declared?: boolean, grid: PropertyListPack = gridPack) => ({
|
|
185
|
+
pack: grid,
|
|
121
186
|
collapsible: folds,
|
|
122
187
|
defaultCollapsed: declared ?? startsFolded,
|
|
123
188
|
collapsed: collapsed ? (collapsed[key] ?? declared ?? startsFolded) : undefined,
|
|
@@ -125,7 +190,7 @@ export function ControlPanel<TC extends Record<string, unknown>>({
|
|
|
125
190
|
});
|
|
126
191
|
|
|
127
192
|
/** One node, which is either a group to recurse into or a row to draw. */
|
|
128
|
-
const node = (path: string): ReactNode => {
|
|
193
|
+
const node = (path: string, rows: Rows): ReactNode => {
|
|
129
194
|
const found = schemaNodeAtPath(resolved.group, path);
|
|
130
195
|
if (!found) return null;
|
|
131
196
|
if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden)) return null;
|
|
@@ -137,40 +202,113 @@ export function ControlPanel<TC extends Record<string, unknown>>({
|
|
|
137
202
|
leaf={found}
|
|
138
203
|
resolved={resolved}
|
|
139
204
|
config={config}
|
|
205
|
+
shown={shown}
|
|
140
206
|
setConfig={setConfig}
|
|
141
207
|
renderers={renderers}
|
|
142
|
-
pack={pack}
|
|
143
|
-
layout={layout}
|
|
208
|
+
pack={rows.pack}
|
|
209
|
+
layout={rows.layout}
|
|
210
|
+
auto={auto}
|
|
211
|
+
setRowAuto={setRowAuto}
|
|
144
212
|
/>
|
|
145
213
|
);
|
|
146
214
|
}
|
|
147
215
|
// A group with no name organizes without heading it — core's rule for an
|
|
148
216
|
// empty `PrefGroup.name` — so it contributes its rows and no chrome.
|
|
149
|
-
if (found.name === '') return <Fragment key={path}>{body(found, path)}</Fragment>;
|
|
217
|
+
if (found.name === '') return <Fragment key={path}>{body(found, path, rows)}</Fragment>;
|
|
150
218
|
return (
|
|
151
|
-
<PropertyGroup
|
|
152
|
-
{
|
|
219
|
+
<PropertyGroup
|
|
220
|
+
key={path}
|
|
221
|
+
title={found.name}
|
|
222
|
+
description={found.description}
|
|
223
|
+
// A nested group is a block of rows, not a control: half of a
|
|
224
|
+
// two-column grid leaves it laying its own rows out inside a cell,
|
|
225
|
+
// which overlaps them.
|
|
226
|
+
span
|
|
227
|
+
{...fold(path, undefined, rows.grid)}
|
|
228
|
+
>
|
|
229
|
+
{body(found, path, rows)}
|
|
153
230
|
</PropertyGroup>
|
|
154
231
|
);
|
|
155
232
|
};
|
|
156
233
|
|
|
234
|
+
/**
|
|
235
|
+
* The leaf at `path` if it may share a row with its neighbours: `pair` is
|
|
236
|
+
* presentational, so a leaf whose control the lab draws itself, or one whose
|
|
237
|
+
* control needs the whole row, keeps its own row instead.
|
|
238
|
+
*/
|
|
239
|
+
const pairable = (path: string): { leaf: PrefLeaf; pair: string } | undefined => {
|
|
240
|
+
const found = schemaNodeAtPath(resolved.group, path);
|
|
241
|
+
if (!found || !isPrefLeaf(found)) return undefined;
|
|
242
|
+
const pair = extra<string>(found, 'pair');
|
|
243
|
+
if (pair === undefined) return undefined;
|
|
244
|
+
if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden))
|
|
245
|
+
return undefined;
|
|
246
|
+
if (renderers?.[path] ?? resolved.renderers[path] ?? renderers?.[found.kind]) return undefined;
|
|
247
|
+
if (!isBuiltinToolPref(found)) return undefined;
|
|
248
|
+
if (found.kind === 'paint' || found.kind === 'object' || isSliderLeaf(found)) return undefined;
|
|
249
|
+
return { leaf: found, pair };
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** A run of sibling paths as rows, merging each run of adjacent leaves
|
|
253
|
+
* sharing a `pair` id into one row named by the pair. */
|
|
254
|
+
const rowsFor = (paths: readonly string[], rows: Rows): ReactNode[] => {
|
|
255
|
+
const out: ReactNode[] = [];
|
|
256
|
+
for (let i = 0; i < paths.length; ) {
|
|
257
|
+
const head = pairable(paths[i]);
|
|
258
|
+
if (!head) {
|
|
259
|
+
out.push(node(paths[i], rows));
|
|
260
|
+
i += 1;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const { pair } = head;
|
|
264
|
+
const cells = [{ path: paths[i], leaf: head.leaf }];
|
|
265
|
+
let j = i + 1;
|
|
266
|
+
for (; j < paths.length; j += 1) {
|
|
267
|
+
const next = pairable(paths[j]);
|
|
268
|
+
if (!next || next.pair !== pair) break;
|
|
269
|
+
cells.push({ path: paths[j], leaf: next.leaf });
|
|
270
|
+
}
|
|
271
|
+
out.push(
|
|
272
|
+
<PairedRow
|
|
273
|
+
key={paths[i]}
|
|
274
|
+
label={pair}
|
|
275
|
+
cells={cells}
|
|
276
|
+
shown={shown}
|
|
277
|
+
setConfig={setConfig}
|
|
278
|
+
layout={rows.layout}
|
|
279
|
+
// A segmented cell carries every choice at once, so a pair holding
|
|
280
|
+
// one needs the whole row the way a segmented row of its own does.
|
|
281
|
+
span={cells.some(({ leaf }) => extra<string>(leaf, 'control') === 'radio')}
|
|
282
|
+
/>,
|
|
283
|
+
);
|
|
284
|
+
i = j;
|
|
285
|
+
}
|
|
286
|
+
return out;
|
|
287
|
+
};
|
|
288
|
+
|
|
157
289
|
/** One group's children: its loose nodes, then its sections. */
|
|
158
|
-
const body = (group: PrefGroup, at: string): ReactNode => {
|
|
290
|
+
const body = (group: PrefGroup, at: string, rows: Rows): ReactNode => {
|
|
159
291
|
const sections = resolved.sections.filter((s) => s.at === at);
|
|
160
292
|
const sectioned = new Set(sections.flatMap((s) => s.paths));
|
|
161
293
|
const paths = Object.keys(group.children).map((key) => (at === '' ? key : `${at}.${key}`));
|
|
162
294
|
return (
|
|
163
295
|
<>
|
|
164
|
-
{
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
296
|
+
{rowsFor(
|
|
297
|
+
paths.filter((p) => !sectioned.has(p)),
|
|
298
|
+
rows,
|
|
299
|
+
)}
|
|
300
|
+
{sections.map((section) => {
|
|
301
|
+
const inner = sectionRows(section, rows);
|
|
302
|
+
return (
|
|
303
|
+
<PropertyGroup
|
|
304
|
+
key={sectionKey(section)}
|
|
305
|
+
title={section.label}
|
|
306
|
+
{...fold(sectionKey(section), section.collapsed, inner.grid)}
|
|
307
|
+
>
|
|
308
|
+
{rowsFor(section.paths, inner)}
|
|
309
|
+
</PropertyGroup>
|
|
310
|
+
);
|
|
311
|
+
})}
|
|
174
312
|
</>
|
|
175
313
|
);
|
|
176
314
|
};
|
|
@@ -182,7 +320,7 @@ export function ControlPanel<TC extends Record<string, unknown>>({
|
|
|
182
320
|
align={align}
|
|
183
321
|
className={className ? `lk-control-panel ${className}` : 'lk-control-panel'}
|
|
184
322
|
>
|
|
185
|
-
{body(resolved.group, '')}
|
|
323
|
+
{body(resolved.group, '', { pack, layout, grid: gridPack })}
|
|
186
324
|
</PropertyList>
|
|
187
325
|
);
|
|
188
326
|
}
|
|
@@ -199,10 +337,26 @@ interface ControlRowProps<TC extends Record<string, unknown>> {
|
|
|
199
337
|
leaf: PrefLeaf;
|
|
200
338
|
resolved: ResolvedConfig;
|
|
201
339
|
config: TC;
|
|
340
|
+
/** `config` with every auto path resolved -- what the instrument reads. */
|
|
341
|
+
shown: TC;
|
|
202
342
|
setConfig: (path: string, value: unknown) => void;
|
|
203
343
|
renderers?: Record<string, ControlRenderer>;
|
|
204
344
|
pack: ControlPack;
|
|
205
345
|
layout?: PropertyRowLayout;
|
|
346
|
+
auto?: ReadonlySet<string>;
|
|
347
|
+
setRowAuto: (path: string, next: boolean, value: unknown) => void;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Whether this leaf draws as a slider, mirroring the condition the `number`
|
|
351
|
+
* arm below branches on. A slider is the one control whose value cannot be
|
|
352
|
+
* read off the control itself. */
|
|
353
|
+
function isSliderLeaf(leaf: PrefLeaf): boolean {
|
|
354
|
+
return (
|
|
355
|
+
leaf.kind === 'number' &&
|
|
356
|
+
extra<string>(leaf, 'control') === 'slider' &&
|
|
357
|
+
extra<number>(leaf, 'min') !== undefined &&
|
|
358
|
+
extra<number>(leaf, 'max') !== undefined
|
|
359
|
+
);
|
|
206
360
|
}
|
|
207
361
|
|
|
208
362
|
/** Reads a labkit-only extra off a leaf. `PrefLeaf` has no field for these,
|
|
@@ -216,21 +370,40 @@ function ControlRow<TC extends Record<string, unknown>>({
|
|
|
216
370
|
leaf,
|
|
217
371
|
resolved,
|
|
218
372
|
config,
|
|
373
|
+
shown,
|
|
219
374
|
setConfig,
|
|
220
375
|
renderers,
|
|
221
376
|
pack,
|
|
222
377
|
layout,
|
|
378
|
+
auto,
|
|
379
|
+
setRowAuto,
|
|
223
380
|
}: ControlRowProps<TC>) {
|
|
224
381
|
const write = (value: unknown): void => setConfig(path, value);
|
|
225
382
|
const fallback = extra<unknown>(leaf, 'default');
|
|
226
|
-
const
|
|
383
|
+
const pinned = valueAtPath(config, path) ?? fallback;
|
|
384
|
+
|
|
385
|
+
const isAutoRow = auto?.has(path) ?? false;
|
|
386
|
+
const canAuto = !extra<boolean>(leaf, 'manual');
|
|
387
|
+
// Read out of the resolved config rather than re-running this leaf's own
|
|
388
|
+
// resolver, so the row reports the value the instrument actually got — the
|
|
389
|
+
// two diverge as soon as a resolver reads another auto path.
|
|
390
|
+
const resolvedValue = isAutoRow ? valueAtPath(shown, path) : undefined;
|
|
391
|
+
// An auto row draws what the resolver decided, not the value underneath it —
|
|
392
|
+
// a handle sitting at the pinned number while the readout says something else
|
|
393
|
+
// reads as a rendering bug. Pinning then keeps what you were looking at.
|
|
394
|
+
const value = resolvedValue ?? pinned;
|
|
395
|
+
const setAuto = (next: boolean): void => setRowAuto(path, next, value);
|
|
396
|
+
const onAutoChange = canAuto ? setAuto : undefined;
|
|
397
|
+
// An auto row hides its control, so the word is all the row has to say.
|
|
398
|
+
const autoReadout = isAutoRow ? 'auto' : undefined;
|
|
399
|
+
const autoProps = { auto: isAutoRow, onAutoChange };
|
|
227
400
|
|
|
228
401
|
// Most specific wins, and within a tier the lab's entry beats the
|
|
229
402
|
// instrument's: controls[path] -> node .render -> controls[kind] -> built-in.
|
|
230
403
|
const custom = renderers?.[path] ?? resolved.renderers[path] ?? renderers?.[leaf.kind];
|
|
231
404
|
// A custom row places itself like any other; one that needs the full width
|
|
232
405
|
// 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 });
|
|
406
|
+
if (custom) return custom({ path, pref: leaf, value, setValue: write, auto: isAutoRow, setAuto });
|
|
234
407
|
|
|
235
408
|
const label = leaf.name;
|
|
236
409
|
const description = leaf.description;
|
|
@@ -244,57 +417,61 @@ function ControlRow<TC extends Record<string, unknown>>({
|
|
|
244
417
|
|
|
245
418
|
switch (leaf.kind) {
|
|
246
419
|
case 'number': {
|
|
247
|
-
const
|
|
248
|
-
const
|
|
249
|
-
const step = extra<number>(leaf, 'step');
|
|
250
|
-
const suffix = extra<string>(leaf, 'suffix');
|
|
420
|
+
const field = numberField(leaf, read<number>());
|
|
421
|
+
const suffix = extra<string>(leaf, 'suffix') ?? field.unit?.suffix;
|
|
251
422
|
const notation = extra<PrefNumberFormat>(leaf, 'format');
|
|
252
|
-
if (
|
|
423
|
+
if (isSliderLeaf(leaf)) {
|
|
253
424
|
return (
|
|
254
425
|
<SliderRow
|
|
255
426
|
label={label}
|
|
256
|
-
value={
|
|
257
|
-
min={min}
|
|
258
|
-
max={max}
|
|
259
|
-
step={step}
|
|
427
|
+
value={field.shown}
|
|
428
|
+
min={field.min as number}
|
|
429
|
+
max={field.max as number}
|
|
430
|
+
step={field.step}
|
|
260
431
|
notation={notation}
|
|
261
432
|
unit={suffix}
|
|
262
|
-
onChange={write}
|
|
433
|
+
onChange={(n) => write(field.store(n))}
|
|
434
|
+
readout={autoReadout}
|
|
263
435
|
layout={layout}
|
|
264
436
|
span={wide}
|
|
265
437
|
description={description}
|
|
438
|
+
{...autoProps}
|
|
266
439
|
/>
|
|
267
440
|
);
|
|
268
441
|
}
|
|
269
442
|
// NumberRow commits every keystroke and does not clamp, so the bounds a
|
|
270
443
|
// schema declares are enforced here — an instrument should never be
|
|
271
444
|
// 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
445
|
return (
|
|
275
446
|
<NumberRow
|
|
276
447
|
label={label}
|
|
277
|
-
value={
|
|
278
|
-
min={min}
|
|
279
|
-
max={max}
|
|
280
|
-
step={step}
|
|
448
|
+
value={field.shown}
|
|
449
|
+
min={field.min}
|
|
450
|
+
max={field.max}
|
|
451
|
+
step={field.step}
|
|
281
452
|
unit={suffix}
|
|
282
|
-
onChange={(n) => write(
|
|
453
|
+
onChange={(n) => write(field.store(n))}
|
|
454
|
+
readout={autoReadout}
|
|
283
455
|
layout={layout}
|
|
284
456
|
description={description}
|
|
457
|
+
{...autoProps}
|
|
285
458
|
/>
|
|
286
459
|
);
|
|
287
460
|
}
|
|
288
|
-
case 'boolean':
|
|
461
|
+
case 'boolean': {
|
|
462
|
+
const Row = extra<string>(leaf, 'control') === 'switch' ? SwitchRow : CheckboxRow;
|
|
289
463
|
return (
|
|
290
|
-
<
|
|
464
|
+
<Row
|
|
291
465
|
label={label}
|
|
292
466
|
value={read<boolean>()}
|
|
293
467
|
onChange={write}
|
|
468
|
+
readout={autoReadout}
|
|
294
469
|
layout={layout}
|
|
295
470
|
description={description}
|
|
471
|
+
{...autoProps}
|
|
296
472
|
/>
|
|
297
473
|
);
|
|
474
|
+
}
|
|
298
475
|
case 'enum': {
|
|
299
476
|
const options = extra<readonly { value: string; label: string }[]>(leaf, 'options') ?? [];
|
|
300
477
|
const segmented = extra<string>(leaf, 'control') === 'radio';
|
|
@@ -305,9 +482,11 @@ function ControlRow<TC extends Record<string, unknown>>({
|
|
|
305
482
|
value={read<string>()}
|
|
306
483
|
options={options}
|
|
307
484
|
onChange={write}
|
|
485
|
+
readout={autoReadout}
|
|
308
486
|
layout={layout}
|
|
309
487
|
span={segmented && wide}
|
|
310
488
|
description={description}
|
|
489
|
+
{...autoProps}
|
|
311
490
|
/>
|
|
312
491
|
);
|
|
313
492
|
}
|
|
@@ -318,21 +497,38 @@ function ControlRow<TC extends Record<string, unknown>>({
|
|
|
318
497
|
label={label}
|
|
319
498
|
value={read<string>()}
|
|
320
499
|
write={write}
|
|
500
|
+
readout={autoReadout}
|
|
321
501
|
layout={layout}
|
|
322
502
|
span={wide}
|
|
323
503
|
description={description}
|
|
504
|
+
{...autoProps}
|
|
324
505
|
/>
|
|
325
506
|
);
|
|
326
|
-
case 'color':
|
|
507
|
+
case 'color': {
|
|
508
|
+
// `ColorRow` takes alpha as a number beside a `#rrggbb` swatch, so an
|
|
509
|
+
// `#rrggbbaa` value has to be split going in and rejoined coming out —
|
|
510
|
+
// handed whole to the swatch, the browser cannot parse it and the first
|
|
511
|
+
// edit writes back black.
|
|
512
|
+
const common = {
|
|
513
|
+
label,
|
|
514
|
+
readout: autoReadout,
|
|
515
|
+
layout,
|
|
516
|
+
description,
|
|
517
|
+
...autoProps,
|
|
518
|
+
};
|
|
519
|
+
if (extra<boolean>(leaf, 'alpha') !== true)
|
|
520
|
+
return <ColorRow {...common} value={read<string>()} onChange={write} />;
|
|
521
|
+
const { rgb, alpha } = splitHexAlpha(read<string>());
|
|
327
522
|
return (
|
|
328
523
|
<ColorRow
|
|
329
|
-
|
|
330
|
-
value={
|
|
331
|
-
onChange={write}
|
|
332
|
-
|
|
333
|
-
|
|
524
|
+
{...common}
|
|
525
|
+
value={rgb}
|
|
526
|
+
onChange={(next) => write(joinHexAlpha(next, alpha))}
|
|
527
|
+
alpha={alpha}
|
|
528
|
+
onAlphaChange={(next) => write(joinHexAlpha(rgb, next))}
|
|
334
529
|
/>
|
|
335
530
|
);
|
|
531
|
+
}
|
|
336
532
|
case 'paint':
|
|
337
533
|
case 'object':
|
|
338
534
|
// Declined: a hex swatch would write a solid over a gradient, and a flat
|
|
@@ -368,9 +564,7 @@ function UnwiredRow({
|
|
|
368
564
|
);
|
|
369
565
|
}
|
|
370
566
|
|
|
371
|
-
/**
|
|
372
|
-
* keystroke, which means the row is locally controlled between commits and has
|
|
373
|
-
* to notice when the config changes underneath it. */
|
|
567
|
+
/** A text row whose writes are debounced — see {@link useDebouncedText}. */
|
|
374
568
|
function DebouncedTextRow({
|
|
375
569
|
leaf,
|
|
376
570
|
label,
|
|
@@ -379,6 +573,9 @@ function DebouncedTextRow({
|
|
|
379
573
|
layout,
|
|
380
574
|
span,
|
|
381
575
|
description,
|
|
576
|
+
readout,
|
|
577
|
+
auto,
|
|
578
|
+
onAutoChange,
|
|
382
579
|
}: {
|
|
383
580
|
leaf: PrefLeaf;
|
|
384
581
|
label: string;
|
|
@@ -387,8 +584,280 @@ function DebouncedTextRow({
|
|
|
387
584
|
layout?: PropertyRowLayout;
|
|
388
585
|
span?: boolean;
|
|
389
586
|
description?: string;
|
|
587
|
+
readout?: ReactNode;
|
|
588
|
+
auto?: boolean;
|
|
589
|
+
onAutoChange?: (next: boolean) => void;
|
|
590
|
+
}) {
|
|
591
|
+
const text = useDebouncedText(
|
|
592
|
+
value,
|
|
593
|
+
write as (v: string) => void,
|
|
594
|
+
extra<number>(leaf, 'debounceMs') ?? 150,
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
return (
|
|
598
|
+
<TextRow
|
|
599
|
+
label={label}
|
|
600
|
+
readout={readout}
|
|
601
|
+
layout={layout}
|
|
602
|
+
span={span}
|
|
603
|
+
description={description}
|
|
604
|
+
auto={auto}
|
|
605
|
+
onAutoChange={onAutoChange}
|
|
606
|
+
value={text.local}
|
|
607
|
+
placeholder={extra<string>(leaf, 'placeholder')}
|
|
608
|
+
maxLength={extra<number>(leaf, 'maxLength')}
|
|
609
|
+
onChange={text.type}
|
|
610
|
+
/>
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* A number leaf's field in the unit it is edited in: what the control shows,
|
|
616
|
+
* the bounds it shows them against, and what a typed number stores once it is
|
|
617
|
+
* clamped and converted back. Without a `unit` this is what the leaf declares,
|
|
618
|
+
* unchanged.
|
|
619
|
+
*/
|
|
620
|
+
function numberField(
|
|
621
|
+
leaf: PrefLeaf,
|
|
622
|
+
value: number,
|
|
623
|
+
): {
|
|
624
|
+
unit: PrefNumberUnit | undefined;
|
|
625
|
+
min: number | undefined;
|
|
626
|
+
max: number | undefined;
|
|
627
|
+
step: number | undefined;
|
|
628
|
+
shown: number;
|
|
629
|
+
store: (shown: number) => number;
|
|
630
|
+
} {
|
|
631
|
+
const unit = extra<PrefNumberUnit>(leaf, 'unit');
|
|
632
|
+
// `min`, `max` and `step` are declared in the stored unit alongside the
|
|
633
|
+
// value, so they convert with it — a leaf storing radians and showing
|
|
634
|
+
// degrees would otherwise clamp typed degrees against 0..6.28.
|
|
635
|
+
const bounds = unit
|
|
636
|
+
? prefDisplayBounds(leaf as PrefNumber)
|
|
637
|
+
: {
|
|
638
|
+
min: extra<number>(leaf, 'min'),
|
|
639
|
+
max: extra<number>(leaf, 'max'),
|
|
640
|
+
step: extra<number>(leaf, 'step'),
|
|
641
|
+
};
|
|
642
|
+
const lo = bounds.min ?? Number.NEGATIVE_INFINITY;
|
|
643
|
+
const hi = bounds.max ?? Number.POSITIVE_INFINITY;
|
|
644
|
+
return {
|
|
645
|
+
unit,
|
|
646
|
+
min: bounds.min,
|
|
647
|
+
max: bounds.max,
|
|
648
|
+
step: bounds.step,
|
|
649
|
+
shown: unit ? unit.toDisplay(value) : value,
|
|
650
|
+
store: (shown) => {
|
|
651
|
+
const clamped = Math.min(hi, Math.max(lo, shown));
|
|
652
|
+
return unit ? unit.fromDisplay(clamped) : clamped;
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa` as the `#rrggbb` an
|
|
658
|
+
* `<input type="color">` can hold and the 0..1 alpha beside it. Anything else
|
|
659
|
+
* reads as opaque black, which is what the input would show for it anyway. */
|
|
660
|
+
function splitHexAlpha(value: string): { rgb: string; alpha: number } {
|
|
661
|
+
const digits = /^#([0-9a-f]{3,8})$/i.exec(value?.trim() ?? '')?.[1];
|
|
662
|
+
const twice = (s: string): string =>
|
|
663
|
+
s
|
|
664
|
+
.split('')
|
|
665
|
+
.map((c) => c + c)
|
|
666
|
+
.join('');
|
|
667
|
+
const byte = (s: string): number => Number.parseInt(s, 16) / 255;
|
|
668
|
+
switch (digits?.length) {
|
|
669
|
+
case 3:
|
|
670
|
+
return { rgb: `#${twice(digits)}`, alpha: 1 };
|
|
671
|
+
case 4:
|
|
672
|
+
return { rgb: `#${twice(digits.slice(0, 3))}`, alpha: byte(twice(digits.slice(3))) };
|
|
673
|
+
case 6:
|
|
674
|
+
return { rgb: `#${digits}`, alpha: 1 };
|
|
675
|
+
case 8:
|
|
676
|
+
return { rgb: `#${digits.slice(0, 6)}`, alpha: byte(digits.slice(6)) };
|
|
677
|
+
default:
|
|
678
|
+
return { rgb: '#000000', alpha: 1 };
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** A `#rrggbb` and a 0..1 alpha back into the `#rrggbbaa` the config holds. */
|
|
683
|
+
function joinHexAlpha(rgb: string, alpha: number): string {
|
|
684
|
+
const byte = Math.max(0, Math.min(255, Math.round(alpha * 255)));
|
|
685
|
+
return `${rgb}${byte.toString(16).padStart(2, '0')}`;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** One cell of a paired row, with its path and the leaf it draws. */
|
|
689
|
+
interface PairCellSpec {
|
|
690
|
+
path: string;
|
|
691
|
+
leaf: PrefLeaf;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Leaves sharing a `pair` id, side by side on one row the pair names — the
|
|
696
|
+
* compact `X` / `Y` idiom `SelectionPanel` draws for the same annotation.
|
|
697
|
+
*
|
|
698
|
+
* The cells are bare controls: a row holds one label column, so each leaf's
|
|
699
|
+
* own name stays with its control as the accessible name and its description
|
|
700
|
+
* as a title. For the same reason the row's label does not toggle auto — it
|
|
701
|
+
* names one path, and this row has several.
|
|
702
|
+
*/
|
|
703
|
+
function PairedRow<TC extends Record<string, unknown>>({
|
|
704
|
+
label,
|
|
705
|
+
cells,
|
|
706
|
+
shown,
|
|
707
|
+
setConfig,
|
|
708
|
+
layout,
|
|
709
|
+
span,
|
|
710
|
+
}: {
|
|
711
|
+
label: string;
|
|
712
|
+
cells: readonly PairCellSpec[];
|
|
713
|
+
shown: TC;
|
|
714
|
+
setConfig: (path: string, value: unknown) => void;
|
|
715
|
+
layout?: PropertyRowLayout;
|
|
716
|
+
span?: boolean;
|
|
717
|
+
}) {
|
|
718
|
+
return (
|
|
719
|
+
<PropertyRow group span={span} label={label} layout={layout}>
|
|
720
|
+
{cells.map(({ path, leaf }) => (
|
|
721
|
+
// The row is named by the pair, so a cell that shares it says which
|
|
722
|
+
// knob it is — without a caption a paired switch reads only to a
|
|
723
|
+
// screen reader. A cell whose leaf is deliberately unnamed keeps none.
|
|
724
|
+
<span key={path} className="lk-pair-cell">
|
|
725
|
+
{leaf.name ? <span className="lk-pair-label">{leaf.name}</span> : null}
|
|
726
|
+
<PairCell
|
|
727
|
+
leaf={leaf}
|
|
728
|
+
value={valueAtPath(shown, path) ?? extra<unknown>(leaf, 'default')}
|
|
729
|
+
write={(value) => setConfig(path, value)}
|
|
730
|
+
/>
|
|
731
|
+
</span>
|
|
732
|
+
))}
|
|
733
|
+
</PropertyRow>
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** The control a paired cell holds, without the row chrome a whole row of its
|
|
738
|
+
* own would bring. */
|
|
739
|
+
function PairCell({
|
|
740
|
+
leaf,
|
|
741
|
+
value,
|
|
742
|
+
write,
|
|
743
|
+
}: {
|
|
744
|
+
leaf: PrefLeaf;
|
|
745
|
+
value: unknown;
|
|
746
|
+
write: (value: unknown) => void;
|
|
390
747
|
}) {
|
|
391
|
-
const
|
|
748
|
+
const name = leaf.name;
|
|
749
|
+
const title = leaf.description;
|
|
750
|
+
const text = useDebouncedText(
|
|
751
|
+
typeof value === 'string' ? value : '',
|
|
752
|
+
write,
|
|
753
|
+
extra<number>(leaf, 'debounceMs') ?? 150,
|
|
754
|
+
);
|
|
755
|
+
switch (leaf.kind) {
|
|
756
|
+
case 'number': {
|
|
757
|
+
const field = numberField(leaf, typeof value === 'number' ? value : 0);
|
|
758
|
+
return (
|
|
759
|
+
<input
|
|
760
|
+
type="number"
|
|
761
|
+
aria-label={name}
|
|
762
|
+
title={title}
|
|
763
|
+
value={field.shown}
|
|
764
|
+
min={field.min}
|
|
765
|
+
max={field.max}
|
|
766
|
+
step={field.step}
|
|
767
|
+
onChange={(e) => {
|
|
768
|
+
const n = Number(e.target.value);
|
|
769
|
+
if (e.target.value !== '' && Number.isFinite(n)) write(field.store(n));
|
|
770
|
+
}}
|
|
771
|
+
/>
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
case 'boolean':
|
|
775
|
+
return extra<string>(leaf, 'control') === 'switch' ? (
|
|
776
|
+
<Switch aria-label={name} isSelected={value === true} onChange={write} />
|
|
777
|
+
) : (
|
|
778
|
+
<input
|
|
779
|
+
type="checkbox"
|
|
780
|
+
aria-label={name}
|
|
781
|
+
title={title}
|
|
782
|
+
checked={value === true}
|
|
783
|
+
onChange={(e) => write(e.target.checked)}
|
|
784
|
+
/>
|
|
785
|
+
);
|
|
786
|
+
case 'string':
|
|
787
|
+
return (
|
|
788
|
+
<input
|
|
789
|
+
type="text"
|
|
790
|
+
aria-label={name}
|
|
791
|
+
title={title}
|
|
792
|
+
value={text.local}
|
|
793
|
+
placeholder={extra<string>(leaf, 'placeholder')}
|
|
794
|
+
maxLength={extra<number>(leaf, 'maxLength')}
|
|
795
|
+
onChange={(e) => text.type(e.target.value)}
|
|
796
|
+
/>
|
|
797
|
+
);
|
|
798
|
+
case 'color': {
|
|
799
|
+
// The alpha track needs a row of its own to sit under, so a paired
|
|
800
|
+
// swatch edits the color and carries the stored alpha through untouched.
|
|
801
|
+
const { rgb, alpha } = splitHexAlpha(typeof value === 'string' ? value : '');
|
|
802
|
+
const carriesAlpha = extra<boolean>(leaf, 'alpha') === true;
|
|
803
|
+
return (
|
|
804
|
+
<input
|
|
805
|
+
type="color"
|
|
806
|
+
aria-label={name}
|
|
807
|
+
title={title}
|
|
808
|
+
value={rgb}
|
|
809
|
+
onChange={(e) =>
|
|
810
|
+
write(carriesAlpha ? joinHexAlpha(e.target.value, alpha) : e.target.value)
|
|
811
|
+
}
|
|
812
|
+
/>
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
case 'enum': {
|
|
816
|
+
const options = extra<readonly { value: string; label: string }[]>(leaf, 'options') ?? [];
|
|
817
|
+
// A leaf that asked to be segmented stays segmented when it shares a row:
|
|
818
|
+
// the choice is the control's shape, not a whole row's worth of chrome.
|
|
819
|
+
if (extra<string>(leaf, 'control') === 'radio') {
|
|
820
|
+
return (
|
|
821
|
+
<span title={title}>
|
|
822
|
+
<ToggleBar<string>
|
|
823
|
+
ariaLabel={name}
|
|
824
|
+
size="sm"
|
|
825
|
+
value={typeof value === 'string' ? value : null}
|
|
826
|
+
onChange={(next) => next !== null && write(next)}
|
|
827
|
+
items={options.map((o) => ({ value: o.value, label: o.label }))}
|
|
828
|
+
/>
|
|
829
|
+
</span>
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
// `Select` takes no `title`, so the cell's help hangs off a wrapper.
|
|
833
|
+
return (
|
|
834
|
+
<span title={title}>
|
|
835
|
+
<Select<string>
|
|
836
|
+
variant="bare"
|
|
837
|
+
aria-label={name}
|
|
838
|
+
options={options}
|
|
839
|
+
selectedKey={typeof value === 'string' ? value : null}
|
|
840
|
+
onSelectionChange={write}
|
|
841
|
+
/>
|
|
842
|
+
</span>
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
default:
|
|
846
|
+
// Unreachable: `pairable` admits only the kinds above.
|
|
847
|
+
return null;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Live text held locally between debounced commits, so typing does not re-run
|
|
853
|
+
* the instrument on every keystroke. Locally controlled between commits, which
|
|
854
|
+
* means it has to notice the value changing underneath it.
|
|
855
|
+
*/
|
|
856
|
+
function useDebouncedText(
|
|
857
|
+
value: string,
|
|
858
|
+
write: (value: string) => void,
|
|
859
|
+
debounceMs: number,
|
|
860
|
+
): { local: string; type: (next: string) => void } {
|
|
392
861
|
const [local, setLocal] = useState(value);
|
|
393
862
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
394
863
|
const lastExternal = useRef(value);
|
|
@@ -407,25 +876,17 @@ function DebouncedTextRow({
|
|
|
407
876
|
[],
|
|
408
877
|
);
|
|
409
878
|
|
|
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
|
-
);
|
|
879
|
+
return {
|
|
880
|
+
local,
|
|
881
|
+
type: (next) => {
|
|
882
|
+
setLocal(next);
|
|
883
|
+
if (timer.current) clearTimeout(timer.current);
|
|
884
|
+
const commit = () => {
|
|
885
|
+
lastExternal.current = next;
|
|
886
|
+
write(next);
|
|
887
|
+
};
|
|
888
|
+
if (debounceMs === 0) commit();
|
|
889
|
+
else timer.current = setTimeout(commit, debounceMs);
|
|
890
|
+
},
|
|
891
|
+
};
|
|
431
892
|
}
|