@weasel-js/labkit 1.2.0 → 1.3.0-pre.0
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/dist/_dts/{DrawCommand-C_XboUpz.d.ts → DrawCommand-DBB45NfN.d.ts} +196 -52
- package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
- package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
- package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
- package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
- package/dist/_dts/{useTrialState-BMNIx3Cy.d.ts → useTrialState-CsGMjhu9.d.ts} +5 -87
- package/dist/canvas/index.d.ts +7 -3
- package/dist/canvas/index.js +2 -1
- package/dist/chrome/index.d.ts +86 -0
- package/dist/chrome/index.js +8 -0
- package/dist/chrome/index.js.map +1 -0
- package/dist/{chunk-VUU5UXHE.js → chunk-4TMMVIDM.js} +8 -5
- package/dist/chunk-4TMMVIDM.js.map +1 -0
- package/dist/{chunk-THBG7FQZ.js → chunk-HFDVGF4X.js} +15 -12
- package/dist/chunk-HFDVGF4X.js.map +1 -0
- package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
- package/dist/chunk-NS54R52R.js.map +1 -0
- package/dist/{chunk-G5TJVQQT.js → chunk-O2BULFHX.js} +5 -14
- package/dist/chunk-O2BULFHX.js.map +1 -0
- package/dist/chunk-O7NVSCLN.js +266 -0
- package/dist/chunk-O7NVSCLN.js.map +1 -0
- package/dist/chunk-PO6L3NSH.js +366 -0
- package/dist/chunk-PO6L3NSH.js.map +1 -0
- package/dist/chunk-PQJ5B2U2.js +27 -0
- package/dist/chunk-PQJ5B2U2.js.map +1 -0
- package/dist/chunk-RVNN4CHQ.js +212 -0
- package/dist/chunk-RVNN4CHQ.js.map +1 -0
- package/dist/chunk-TLGRYALP.js +6151 -0
- package/dist/chunk-TLGRYALP.js.map +1 -0
- package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
- package/dist/chunk-V7PRSIRQ.js.map +1 -0
- package/dist/{chunk-BOHF3PQO.js → chunk-Z6HJ5FEM.js} +8111 -5190
- package/dist/chunk-Z6HJ5FEM.js.map +1 -0
- package/dist/chunk-ZZAYVX4X.js +505 -0
- package/dist/chunk-ZZAYVX4X.js.map +1 -0
- package/dist/controls/index.d.ts +19 -5
- package/dist/controls/index.js +3 -1
- package/dist/dragdrop/index.d.ts +4 -2
- package/dist/index.d.ts +246 -106
- package/dist/index.js +673 -559
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +5 -3
- package/dist/layers/index.js +2 -1
- package/dist/passthrough/weasel-canvas.d.ts +69 -13
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +3799 -3672
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +102 -8
- package/dist/primitives/index.js +4 -1
- package/dist/state/index.d.ts +3 -2
- package/dist/state/index.js +2 -2
- package/dist/styles.css +542 -153
- package/dist/surface/index.js +2 -1
- package/dist/ui/layers/index.js +4 -3
- package/dist/undo/index.d.ts +4 -2
- package/package.json +16 -7
- package/src/canvas/AGENTS.md +4 -0
- package/src/canvas/CanvasStack.tsx +6 -2
- package/src/canvas/useLayerScheduler.ts +22 -15
- package/src/canvas/usePanZoom.test.ts +80 -0
- package/src/canvas/usePanZoom.ts +16 -2
- package/src/chrome/ChromeRegions.stories.tsx +106 -0
- package/src/chrome/builtins.test.ts +153 -0
- package/src/chrome/builtins.tsx +205 -0
- package/src/chrome/index.ts +25 -0
- package/src/chrome/merge.test.ts +51 -0
- package/src/chrome/merge.ts +52 -0
- package/src/chrome/regions/PaletteRegion.less +14 -0
- package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
- package/src/chrome/regions/PaletteRegion.tsx +41 -0
- package/src/chrome/regions/SidebarRegion.tsx +56 -0
- package/src/chrome/regions/StatusRegion.tsx +31 -0
- package/src/chrome/regions/TitleBarRegion.tsx +41 -0
- package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
- package/src/chrome/regions/ToolbarRegion.tsx +71 -0
- package/src/chrome/regions/ViewportRegion.less +42 -0
- package/src/chrome/regions/ViewportRegion.tsx +37 -0
- package/src/chrome/regions/regions.test.tsx +81 -0
- package/src/chrome/types.ts +127 -0
- package/src/config/builder.test.ts +87 -0
- package/src/config/builder.ts +224 -0
- package/src/config/fromConfigField.test.ts +93 -0
- package/src/config/fromConfigField.ts +74 -0
- package/src/config/index.ts +22 -0
- package/src/config/resolve.test.ts +120 -0
- package/src/config/resolve.ts +67 -0
- package/src/config/rules.ts +68 -0
- package/src/config/types.ts +108 -0
- package/src/config/useConfigSchema.ts +30 -0
- package/src/config/visible.ts +20 -0
- package/src/controls/ControlPanel.less +4 -58
- package/src/controls/ControlPanel.stories.tsx +86 -0
- package/src/controls/ControlPanel.test.tsx +223 -10
- package/src/controls/ControlPanel.tsx +202 -203
- package/src/dragdrop/DragDropRuntime.tsx +36 -18
- package/src/dragdrop/DragGhost.less +1 -1
- package/src/dragdrop/Palette.less +1 -1
- package/src/index.ts +25 -3
- package/src/instrument/SineWave.smoke.test.tsx +8 -1
- package/src/instrument/defineInstrument.test.ts +38 -1
- package/src/instrument/defineInstrument.ts +31 -4
- package/src/instrument/index.ts +1 -0
- package/src/instrument/types.ts +27 -4
- package/src/instrument/validateConfigSchema.test.ts +13 -3
- package/src/instrument/validateConfigSchema.ts +7 -10
- package/src/lab/Lab.chrome.test.tsx +65 -0
- package/src/lab/Lab.tsx +58 -15
- package/src/lab/LabContext.ts +6 -0
- package/src/lab/LabFullChrome.stories.tsx +308 -0
- package/src/lab/LabHeader.test.tsx +42 -0
- package/src/lab/LabHeader.tsx +57 -0
- package/src/lab/LabPalette.test.tsx +35 -0
- package/src/lab/LabPalette.tsx +34 -0
- package/src/lab/LabShell.less +50 -3
- package/src/lab/Workspace.less +1 -30
- package/src/lab/Workspace.test.tsx +20 -10
- package/src/lab/Workspace.tsx +5 -8
- package/src/lab/index.ts +2 -0
- package/src/layers/AGENTS.md +1 -1
- package/src/layers/LayerList.less +31 -7
- package/src/layers/LayerList.tsx +17 -4
- package/src/passthrough/weasel-ui.ts +23 -0
- package/src/primitives/DragHandleGlyph.tsx +29 -0
- package/src/primitives/FloatingPanel.less +28 -0
- package/src/primitives/FloatingPanel.stories.tsx +46 -0
- package/src/primitives/FloatingPanel.test.tsx +240 -0
- package/src/primitives/FloatingPanel.tsx +190 -0
- package/src/primitives/FpsMeter.test.tsx +44 -0
- package/src/primitives/FpsMeter.tsx +20 -9
- package/src/primitives/JobProgress.less +69 -0
- package/src/primitives/JobProgress.stories.tsx +48 -0
- package/src/primitives/JobProgress.tsx +65 -0
- package/src/primitives/Legend.less +47 -0
- package/src/primitives/Legend.stories.tsx +32 -0
- package/src/primitives/Legend.test.tsx +57 -0
- package/src/primitives/Legend.tsx +40 -0
- package/src/primitives/Sidebar.less +32 -2
- package/src/primitives/StatusBar.less +7 -0
- package/src/primitives/StatusBar.tsx +10 -3
- package/src/primitives/Toolbar.less +50 -3
- package/src/primitives/Toolbar.test.tsx +67 -1
- package/src/primitives/Toolbar.tsx +72 -6
- package/src/primitives/ZoomControl.less +31 -0
- package/src/primitives/ZoomControl.tsx +76 -0
- package/src/primitives/floating.entry.test.tsx +14 -0
- package/src/primitives/index.ts +10 -2
- package/src/primitives/useRovingTabIndex.test.ts +27 -0
- package/src/primitives/useRovingTabIndex.ts +79 -0
- package/src/state/store.ts +11 -3
- package/src/state/toolSlot.test.ts +36 -0
- package/src/state/types.ts +4 -0
- package/src/styles.less +6 -0
- package/src/surface/useTiledSurface.ts +14 -10
- package/src/test-setup.ts +26 -0
- package/src/theme/base.less +19 -2
- package/src/theme/interstellar.tokens.json +6 -2
- package/src/tools/types.ts +24 -0
- package/src/trial/Trial.config.test.tsx +113 -0
- package/src/trial/Trial.job.test.tsx +13 -15
- package/src/trial/Trial.less +115 -10
- package/src/trial/Trial.stories.tsx +1 -0
- package/src/trial/Trial.test.tsx +67 -96
- package/src/trial/Trial.tsx +80 -39
- package/src/trial/TrialChrome.tsx +99 -80
- package/src/trial/TrialDragContext.ts +10 -0
- package/src/trial/TrialTitleBar.tsx +41 -0
- package/src/trial/index.ts +1 -15
- package/src/trial/trialChrome.borders.test.ts +38 -0
- package/src/ui/layers/LayerStack.less +9 -9
- package/src/ui/layers/LayerStack.tsx +1 -13
- package/src/ui/properties/CurveField.less +2 -2
- package/src/ui/properties/CurveField.test.tsx +11 -11
- package/src/ui/properties/PropertyGroup.less +2 -2
- package/src/ui/properties/PropertyPanel.less +55 -41
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
- package/src/ui/properties/storyLayouts.tsx +2 -2
- package/dist/_dts/index-JFAYj5Tv.d.ts +0 -144
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/chunk-574LJAV4.js.map +0 -1
- package/dist/chunk-5R2ATYPJ.js +0 -201
- package/dist/chunk-5R2ATYPJ.js.map +0 -1
- package/dist/chunk-73KA7WBO.js +0 -5992
- package/dist/chunk-73KA7WBO.js.map +0 -1
- package/dist/chunk-BOHF3PQO.js.map +0 -1
- package/dist/chunk-G5TJVQQT.js.map +0 -1
- package/dist/chunk-PMAU3SEE.js +0 -126
- package/dist/chunk-PMAU3SEE.js.map +0 -1
- package/dist/chunk-SFL7NFKN.js.map +0 -1
- package/dist/chunk-THBG7FQZ.js.map +0 -1
- package/dist/chunk-VUU5UXHE.js.map +0 -1
- package/src/instrument/capabilityDetector.test.ts +0 -64
- package/src/instrument/capabilityDetector.ts +0 -20
- package/src/trial/DefaultSidebar.tsx +0 -29
- package/src/trial/DefaultStatusBar.tsx +0 -19
- package/src/trial/DefaultToolbar.tsx +0 -92
- package/src/trial/slotTypes.ts +0 -54
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Toolbar } from '../../primitives/Toolbar';
|
|
3
|
+
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
4
|
+
|
|
5
|
+
/** Props for `<ToolbarRegion>`. */
|
|
6
|
+
export interface ToolbarRegionProps {
|
|
7
|
+
contributions: readonly TrialContribution[];
|
|
8
|
+
ctx: TrialChromeContext;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Group {
|
|
12
|
+
key: string;
|
|
13
|
+
end: boolean;
|
|
14
|
+
entries: TrialContribution[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Bucket by group, preserving first-appearance order. Ungrouped
|
|
18
|
+
* contributions each become their own bucket so they stay in place. */
|
|
19
|
+
function groupsOf(contributions: readonly TrialContribution[]): Group[] {
|
|
20
|
+
const groups: Group[] = [];
|
|
21
|
+
const byKey = new Map<string, Group>();
|
|
22
|
+
for (const c of contributions) {
|
|
23
|
+
if (c.group == null) {
|
|
24
|
+
groups.push({ key: c.id, end: c.end ?? false, entries: [c] });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
let g = byKey.get(c.group);
|
|
28
|
+
if (!g) {
|
|
29
|
+
g = { key: c.group, end: c.end ?? false, entries: [] };
|
|
30
|
+
byKey.set(c.group, g);
|
|
31
|
+
groups.push(g);
|
|
32
|
+
}
|
|
33
|
+
g.entries.push(c);
|
|
34
|
+
}
|
|
35
|
+
return groups;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
|
|
39
|
+
if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
|
|
40
|
+
if (c.region !== 'toolbar' || !c.item) return null;
|
|
41
|
+
const { icon: Icon, label, shortcut, disabled, danger, showLabel } = c.item;
|
|
42
|
+
return (
|
|
43
|
+
<Toolbar.Button
|
|
44
|
+
key={c.id}
|
|
45
|
+
iconOnly={!showLabel}
|
|
46
|
+
variant={danger ? 'danger' : 'default'}
|
|
47
|
+
disabled={disabled}
|
|
48
|
+
aria-label={label}
|
|
49
|
+
title={shortcut ? `${label} (${shortcut})` : label}
|
|
50
|
+
onClick={c.item.onActivate}
|
|
51
|
+
>
|
|
52
|
+
<Icon size={16} />
|
|
53
|
+
{showLabel ? <span>{label}</span> : null}
|
|
54
|
+
</Toolbar.Button>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Lays a trial's `toolbar` contributions out, grouped by their `group`. */
|
|
59
|
+
export function ToolbarRegion({ contributions, ctx }: ToolbarRegionProps) {
|
|
60
|
+
if (contributions.length === 0) return null;
|
|
61
|
+
const groups = groupsOf(contributions);
|
|
62
|
+
return (
|
|
63
|
+
<Toolbar aria-label="Trial actions">
|
|
64
|
+
{groups.map((g) => (
|
|
65
|
+
<Toolbar.Group key={g.key} end={g.end} aria-label={g.key}>
|
|
66
|
+
{g.entries.map((c) => renderEntry(c, ctx))}
|
|
67
|
+
</Toolbar.Group>
|
|
68
|
+
))}
|
|
69
|
+
</Toolbar>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
.lk-viewport-controls {
|
|
2
|
+
position: absolute;
|
|
3
|
+
right: var(--wzl-space-sm);
|
|
4
|
+
bottom: var(--wzl-space-sm);
|
|
5
|
+
z-index: var(--wzl-z-overlay);
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-wrap: wrap;
|
|
8
|
+
justify-content: flex-end;
|
|
9
|
+
// Bounded by the well it floats in, so a wide control cluster wraps into the
|
|
10
|
+
// corner rather than running out under the sidebar.
|
|
11
|
+
max-width: calc(100% - 2 * var(--wzl-space-sm));
|
|
12
|
+
gap: var(--wzl-space-xs);
|
|
13
|
+
padding: var(--wzl-space-xs);
|
|
14
|
+
border: var(--wzl-border-w) solid var(--wzl-border);
|
|
15
|
+
border-radius: var(--wzl-radius-md);
|
|
16
|
+
background: var(--wzl-surface-raised);
|
|
17
|
+
backdrop-filter: blur(var(--wzl-glass-blur));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.lk-viewport-controls__button {
|
|
21
|
+
display: inline-flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
justify-content: center;
|
|
24
|
+
width: var(--wzl-control-h);
|
|
25
|
+
height: var(--wzl-control-h);
|
|
26
|
+
padding: 0;
|
|
27
|
+
border: 0;
|
|
28
|
+
border-radius: var(--wzl-radius-sm);
|
|
29
|
+
background: transparent;
|
|
30
|
+
color: var(--wzl-fg-muted);
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
|
|
33
|
+
&:hover:not(:disabled) {
|
|
34
|
+
background: var(--wzl-surface-hover);
|
|
35
|
+
color: var(--wzl-fg);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
&:disabled {
|
|
39
|
+
opacity: 0.4;
|
|
40
|
+
cursor: default;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
2
|
+
|
|
3
|
+
/** Props for `<ViewportRegion>`. */
|
|
4
|
+
export interface ViewportRegionProps {
|
|
5
|
+
contributions: readonly TrialContribution[];
|
|
6
|
+
ctx: TrialChromeContext;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Controls acting on the view of a trial. Anchored inside the content well
|
|
11
|
+
* rather than in the toolbar, which acts on the trial itself.
|
|
12
|
+
*/
|
|
13
|
+
export function ViewportRegion({ contributions, ctx }: ViewportRegionProps) {
|
|
14
|
+
if (contributions.length === 0) return null;
|
|
15
|
+
return (
|
|
16
|
+
<div className="lk-viewport-controls" role="toolbar" aria-label="View">
|
|
17
|
+
{contributions.map((c) => {
|
|
18
|
+
if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
|
|
19
|
+
if (c.region !== 'viewport' || !c.item) return null;
|
|
20
|
+
const { icon: Icon, label, disabled } = c.item;
|
|
21
|
+
return (
|
|
22
|
+
<button
|
|
23
|
+
key={c.id}
|
|
24
|
+
type="button"
|
|
25
|
+
className="lk-viewport-controls__button"
|
|
26
|
+
aria-label={label}
|
|
27
|
+
title={label}
|
|
28
|
+
disabled={disabled}
|
|
29
|
+
onClick={c.item.onActivate}
|
|
30
|
+
>
|
|
31
|
+
<Icon size={16} />
|
|
32
|
+
</button>
|
|
33
|
+
);
|
|
34
|
+
})}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
4
|
+
import { SidebarRegion } from './SidebarRegion';
|
|
5
|
+
import { StatusRegion } from './StatusRegion';
|
|
6
|
+
import { ViewportRegion } from './ViewportRegion';
|
|
7
|
+
|
|
8
|
+
const Glyph = () => <svg />;
|
|
9
|
+
const ctx = { trialId: 't1' } as unknown as TrialChromeContext;
|
|
10
|
+
|
|
11
|
+
describe('SidebarRegion', () => {
|
|
12
|
+
it('renders nothing when empty', () => {
|
|
13
|
+
const { container } = render(<SidebarRegion contributions={[]} ctx={ctx} />);
|
|
14
|
+
expect(container).toBeEmptyDOMElement();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('renders each section with its title and body', () => {
|
|
18
|
+
const contributions: TrialContribution[] = [
|
|
19
|
+
{ id: 'settings', region: 'sidebar', item: { title: 'Settings', body: <p>fields</p> } },
|
|
20
|
+
{ id: 'layers', region: 'sidebar', item: { title: 'Layers', body: <p>list</p> } },
|
|
21
|
+
];
|
|
22
|
+
render(<SidebarRegion contributions={contributions} ctx={ctx} />);
|
|
23
|
+
expect(screen.getByText('Settings')).toBeInTheDocument();
|
|
24
|
+
expect(screen.getByText('fields')).toBeInTheDocument();
|
|
25
|
+
expect(screen.getByText('Layers')).toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('starts a section collapsed when it asks to', () => {
|
|
29
|
+
render(
|
|
30
|
+
<SidebarRegion
|
|
31
|
+
contributions={[
|
|
32
|
+
{
|
|
33
|
+
id: 's',
|
|
34
|
+
region: 'sidebar',
|
|
35
|
+
item: { title: 'S', defaultCollapsed: true, body: <p>hidden</p> },
|
|
36
|
+
},
|
|
37
|
+
]}
|
|
38
|
+
ctx={ctx}
|
|
39
|
+
/>,
|
|
40
|
+
);
|
|
41
|
+
expect(screen.queryByText('hidden')).not.toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('StatusRegion', () => {
|
|
46
|
+
it('renders nothing when empty', () => {
|
|
47
|
+
const { container } = render(<StatusRegion contributions={[]} ctx={ctx} />);
|
|
48
|
+
expect(container).toBeEmptyDOMElement();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('renders each readout as text', () => {
|
|
52
|
+
render(
|
|
53
|
+
<StatusRegion
|
|
54
|
+
contributions={[{ id: 'zoom', region: 'status', item: { text: '150%' } }]}
|
|
55
|
+
ctx={ctx}
|
|
56
|
+
/>,
|
|
57
|
+
);
|
|
58
|
+
expect(screen.getByText('150%')).toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('ViewportRegion', () => {
|
|
63
|
+
it('renders nothing when empty', () => {
|
|
64
|
+
const { container } = render(<ViewportRegion contributions={[]} ctx={ctx} />);
|
|
65
|
+
expect(container).toBeEmptyDOMElement();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('fires a control on click', () => {
|
|
69
|
+
const onActivate = vi.fn();
|
|
70
|
+
render(
|
|
71
|
+
<ViewportRegion
|
|
72
|
+
contributions={[
|
|
73
|
+
{ id: 'fit', region: 'viewport', item: { icon: Glyph, label: 'Fit', onActivate } },
|
|
74
|
+
]}
|
|
75
|
+
ctx={ctx}
|
|
76
|
+
/>,
|
|
77
|
+
);
|
|
78
|
+
screen.getByRole('button', { name: 'Fit' }).click();
|
|
79
|
+
expect(onActivate).toHaveBeenCalledOnce();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
2
|
+
import type { ResolvedConfig } from '../config/types';
|
|
3
|
+
import type { ConfigField } from '../controls/types';
|
|
4
|
+
import type { SavedSnapshot } from '../state/types';
|
|
5
|
+
|
|
6
|
+
/** A named position in a trial's chrome. Content is not a region — that is
|
|
7
|
+
* the instrument. */
|
|
8
|
+
export type TrialRegion = 'titlebar' | 'toolbar' | 'palette' | 'sidebar' | 'viewport' | 'status';
|
|
9
|
+
|
|
10
|
+
/** An icon component taking a pixel size, as `@weasel-js/ui` glyphs do. */
|
|
11
|
+
export type IconComponent = ComponentType<{ size?: number }>;
|
|
12
|
+
|
|
13
|
+
/** A button in the trial toolbar. */
|
|
14
|
+
export interface ToolbarItem {
|
|
15
|
+
icon: IconComponent;
|
|
16
|
+
label: string;
|
|
17
|
+
/** Shown in the tooltip. Not bound here — the trial owns its keymap. */
|
|
18
|
+
shortcut?: string;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
/** Reddens on hover. For actions that discard work. */
|
|
21
|
+
danger?: boolean;
|
|
22
|
+
/** Render the label beside the glyph rather than only in the tooltip. */
|
|
23
|
+
showLabel?: boolean;
|
|
24
|
+
onActivate: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A selectable tool in the palette region. */
|
|
28
|
+
export interface ToolItem {
|
|
29
|
+
icon: IconComponent;
|
|
30
|
+
label: string;
|
|
31
|
+
shortcut?: string;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A titled block in the sidebar. */
|
|
36
|
+
export interface SidebarSection {
|
|
37
|
+
title: string;
|
|
38
|
+
/** Starts collapsed. The open/closed state itself is the region's. */
|
|
39
|
+
defaultCollapsed?: boolean;
|
|
40
|
+
body: ReactNode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A control acting on the view of the trial, not on the trial. */
|
|
44
|
+
export interface ViewportControl {
|
|
45
|
+
icon: IconComponent;
|
|
46
|
+
label: string;
|
|
47
|
+
disabled?: boolean;
|
|
48
|
+
onActivate: () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A readout in the status bar. */
|
|
52
|
+
export interface StatusReadout {
|
|
53
|
+
/** Short enough for a status bar. Rendered as text. */
|
|
54
|
+
text: string;
|
|
55
|
+
/** Tooltip. */
|
|
56
|
+
title?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What every contribution shares. */
|
|
60
|
+
interface ContributionBase {
|
|
61
|
+
id: string;
|
|
62
|
+
/** Groups sort by first appearance; items sort within a group by
|
|
63
|
+
* declaration order. Contributions with no group sort after grouped ones. */
|
|
64
|
+
group?: string;
|
|
65
|
+
/** Pushes this contribution, and its group, to the far end of the region. */
|
|
66
|
+
end?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A contribution is data the chrome renders, keyed to a region. Supplying
|
|
71
|
+
* `render` instead of `item` opts out of the chrome's layout — deliberate,
|
|
72
|
+
* and visible in the declaration.
|
|
73
|
+
*/
|
|
74
|
+
export type TrialContribution =
|
|
75
|
+
| (ContributionBase & { region: 'titlebar'; item: ToolbarItem; render?: never })
|
|
76
|
+
| (ContributionBase & { region: 'toolbar'; item: ToolbarItem; render?: never })
|
|
77
|
+
| (ContributionBase & { region: 'palette'; item: ToolItem; render?: never })
|
|
78
|
+
| (ContributionBase & { region: 'sidebar'; item: SidebarSection; render?: never })
|
|
79
|
+
| (ContributionBase & { region: 'viewport'; item: ViewportControl; render?: never })
|
|
80
|
+
| (ContributionBase & { region: 'status'; item: StatusReadout; render?: never })
|
|
81
|
+
| (ContributionBase & {
|
|
82
|
+
region: TrialRegion;
|
|
83
|
+
item?: never;
|
|
84
|
+
render: (ctx: TrialChromeContext) => ReactNode;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Everything a contribution can read about the trial it is being rendered
|
|
89
|
+
* into. Replaces the three separate slot contexts, which each carried a
|
|
90
|
+
* hand-picked subset.
|
|
91
|
+
*/
|
|
92
|
+
export interface TrialChromeContext {
|
|
93
|
+
trialId: string;
|
|
94
|
+
instrumentName: string;
|
|
95
|
+
isLastTrial: boolean;
|
|
96
|
+
|
|
97
|
+
/** Null when the trial holds a view that is not the 2D one. */
|
|
98
|
+
zoom: number | null;
|
|
99
|
+
setZoom: (z: number) => void;
|
|
100
|
+
|
|
101
|
+
canUndo: boolean;
|
|
102
|
+
canRedo: boolean;
|
|
103
|
+
undo: () => void;
|
|
104
|
+
redo: () => void;
|
|
105
|
+
|
|
106
|
+
/** The instrument's controls, resolved against the lab's rules. Always
|
|
107
|
+
* populated: a legacy `configSchema()` is adapted into the same shape. */
|
|
108
|
+
configSchema: ResolvedConfig;
|
|
109
|
+
/** @deprecated Read `configSchema`. Empty for an instrument declaring
|
|
110
|
+
* `config`, since a builder schema has no `ConfigField[]` form. */
|
|
111
|
+
configFields: ConfigField[];
|
|
112
|
+
config: unknown;
|
|
113
|
+
setConfig: (key: string, value: unknown) => void;
|
|
114
|
+
|
|
115
|
+
savedSnapshots: SavedSnapshot[];
|
|
116
|
+
saveSnapshot: (name?: string) => void;
|
|
117
|
+
loadSnapshot: (snapshotId: string) => void;
|
|
118
|
+
|
|
119
|
+
clone: () => void;
|
|
120
|
+
reset: () => void;
|
|
121
|
+
close: () => void;
|
|
122
|
+
|
|
123
|
+
/** Resolved active tool: the trial's slot, or the lab's when the trial has
|
|
124
|
+
* none. Null when neither holds one. */
|
|
125
|
+
activeToolId: string | null;
|
|
126
|
+
setActiveTool: (id: string) => void;
|
|
127
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { f } from './builder';
|
|
3
|
+
|
|
4
|
+
describe('builder', () => {
|
|
5
|
+
it('carries kind and default', () => {
|
|
6
|
+
const n = f.number(20);
|
|
7
|
+
expect(n.kind).toBe('number');
|
|
8
|
+
expect(n.default).toBe(20);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('chains annotations immutably', () => {
|
|
12
|
+
const base = f.number(20);
|
|
13
|
+
const a = base.label('A');
|
|
14
|
+
const b = base.label('B');
|
|
15
|
+
expect(a.annotations.name).toBe('A');
|
|
16
|
+
expect(b.annotations.name).toBe('B');
|
|
17
|
+
expect(base.annotations.name).toBeUndefined();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('keeps the subclass across a chain, so kind-specific methods survive', () => {
|
|
21
|
+
const n = f.number(20).label('A').range(5, 80).describe('help').step(5);
|
|
22
|
+
expect(n.kind).toBe('number');
|
|
23
|
+
expect(n.annotations).toMatchObject({ name: 'A', description: 'help', min: 5, max: 80, step: 5 });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('keeps a custom node kind across a chain', () => {
|
|
27
|
+
const n = f.custom('vector2', { x: 0 }).label('Offset');
|
|
28
|
+
expect(n.kind).toBe('vector2');
|
|
29
|
+
expect(n.annotations.name).toBe('Offset');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('expands a bare enum option list', () => {
|
|
33
|
+
const n = f.enum('fast', ['fast', 'accurate']);
|
|
34
|
+
expect(n.annotations.options).toEqual([
|
|
35
|
+
{ value: 'fast', label: 'fast' },
|
|
36
|
+
{ value: 'accurate', label: 'accurate' },
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('takes labeled enum options as given', () => {
|
|
41
|
+
const n = f.enum('fast', [{ value: 'fast', label: 'Fast' }]);
|
|
42
|
+
expect(n.annotations.options).toEqual([{ value: 'fast', label: 'Fast' }]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('f.value carries no kind', () => {
|
|
46
|
+
expect(f.value(3).kind).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('holds section, showIf and render off the annotation bag', () => {
|
|
50
|
+
const n = f
|
|
51
|
+
.number(1)
|
|
52
|
+
.section('Advanced')
|
|
53
|
+
.showIf((c) => c.showGrid === true)
|
|
54
|
+
.render(() => null);
|
|
55
|
+
expect(n.options.section).toBe('Advanced');
|
|
56
|
+
expect(n.options.showIf?.({ showGrid: true })).toBe(true);
|
|
57
|
+
expect(n.options.render).toBeTypeOf('function');
|
|
58
|
+
expect(n.annotations).toEqual({});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('schema.defaults() collects every default', () => {
|
|
62
|
+
const s = f.schema({ showGrid: f.boolean(true), cellSize: f.number(20) });
|
|
63
|
+
expect(s.defaults()).toEqual({ showGrid: true, cellSize: 20 });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('builder / node option collisions', () => {
|
|
68
|
+
// `options` is the node's extras bag AND was briefly an EnumNode method, so
|
|
69
|
+
// section/showIf/render silently vanished on every enum leaf. tsc caught it;
|
|
70
|
+
// this keeps a runtime witness.
|
|
71
|
+
it('every node kind exposes options as the extras bag, not a method', () => {
|
|
72
|
+
const nodes = [
|
|
73
|
+
f.number(1),
|
|
74
|
+
f.boolean(true),
|
|
75
|
+
f.string(''),
|
|
76
|
+
f.color('#fff'),
|
|
77
|
+
f.enum('a', ['a', 'b']),
|
|
78
|
+
f.value(1),
|
|
79
|
+
f.custom('vector2', 0),
|
|
80
|
+
];
|
|
81
|
+
for (const node of nodes) {
|
|
82
|
+
expect(typeof node.options).toBe('object');
|
|
83
|
+
expect(node.section('S').options.section).toBe('S');
|
|
84
|
+
expect(node.render(() => null).options.render).toBeTypeOf('function');
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { PrefLeaf } from '@weasel-js/ui';
|
|
2
|
+
import type {
|
|
3
|
+
Annotations,
|
|
4
|
+
ConfigNode,
|
|
5
|
+
ConfigOption,
|
|
6
|
+
ConfigSchema,
|
|
7
|
+
ControlRenderer,
|
|
8
|
+
InferConfig,
|
|
9
|
+
NodeOptions,
|
|
10
|
+
} from './types';
|
|
11
|
+
|
|
12
|
+
/** Shared chaining surface. Every method clones, so a node can be reused as a
|
|
13
|
+
* base for several leaves without one bleeding into the next. */
|
|
14
|
+
abstract class BaseNode<T> implements ConfigNode<T> {
|
|
15
|
+
abstract readonly kind: string | null;
|
|
16
|
+
|
|
17
|
+
// `default` is a reserved word in a parameter position, so the field is
|
|
18
|
+
// declared rather than taken as a parameter property.
|
|
19
|
+
readonly default: T;
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
defaultValue: T,
|
|
23
|
+
readonly annotations: Readonly<Annotations> = {},
|
|
24
|
+
readonly options: Readonly<NodeOptions> = {},
|
|
25
|
+
) {
|
|
26
|
+
this.default = defaultValue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Clone into the same subclass. Subclasses with extra constructor
|
|
30
|
+
* arguments override this. */
|
|
31
|
+
protected with(annotations: Annotations, options: NodeOptions): this {
|
|
32
|
+
const Ctor = this.constructor as new (
|
|
33
|
+
d: T,
|
|
34
|
+
a: Annotations,
|
|
35
|
+
o: NodeOptions,
|
|
36
|
+
) => this;
|
|
37
|
+
return new Ctor(this.default, annotations, options);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
protected ann(patch: Annotations): this {
|
|
41
|
+
return this.with({ ...this.annotations, ...patch }, this.options);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
protected opt(patch: NodeOptions): this {
|
|
45
|
+
return this.with(this.annotations, { ...this.options, ...patch });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Human-readable label. Defaults to the key, title-cased. */
|
|
49
|
+
label(name: string): this {
|
|
50
|
+
return this.ann({ name });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Longer help text, surfaced as a tooltip on the row label. */
|
|
54
|
+
describe(description: string): this {
|
|
55
|
+
return this.ann({ description });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Omit from the panel unless it is asked to show hidden leaves. */
|
|
59
|
+
hidden(): this {
|
|
60
|
+
return this.ann({ hidden: true });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Pair with a sibling sharing this id, side-by-side on one row. */
|
|
64
|
+
pair(pair: string): this {
|
|
65
|
+
return this.ann({ pair });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Render under a named section heading. */
|
|
69
|
+
section(section: string): this {
|
|
70
|
+
return this.opt({ section });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Show this row only while the predicate holds. Presentational — the value
|
|
74
|
+
* stays in config and the instrument still reads it. */
|
|
75
|
+
showIf(predicate: (config: Record<string, unknown>) => boolean): this {
|
|
76
|
+
return this.opt({ showIf: predicate });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Draw this one row yourself, keeping the kind, default and validation. */
|
|
80
|
+
render(renderer: ControlRenderer): this {
|
|
81
|
+
return this.opt({ render: renderer });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class NumberNode extends BaseNode<number> {
|
|
86
|
+
readonly kind = 'number';
|
|
87
|
+
|
|
88
|
+
/** Bound the value. A number with both bounds renders as a slider. */
|
|
89
|
+
range(min: number, max: number): this {
|
|
90
|
+
return this.ann({ min, max });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
step(step: number): this {
|
|
94
|
+
return this.ann({ step });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Force a slider even without both bounds. */
|
|
98
|
+
slider(): this {
|
|
99
|
+
return this.ann({ control: 'slider' });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Force a typed input even when both bounds are set. */
|
|
103
|
+
input(): this {
|
|
104
|
+
return this.ann({ control: 'input' });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
class BooleanNode extends BaseNode<boolean> {
|
|
109
|
+
readonly kind = 'boolean';
|
|
110
|
+
|
|
111
|
+
/** Ask for a switch. `ControlPanel` still draws a checkbox; weasel-ui's
|
|
112
|
+
* `PrefsForm` honors the distinction. */
|
|
113
|
+
toggle(): this {
|
|
114
|
+
return this.ann({ control: 'switch' });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
class StringNode extends BaseNode<string> {
|
|
119
|
+
readonly kind = 'string';
|
|
120
|
+
|
|
121
|
+
placeholder(placeholder: string): this {
|
|
122
|
+
return this.ann({ placeholder });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
maxLength(maxLength: number): this {
|
|
126
|
+
return this.ann({ maxLength });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Milliseconds to debounce live writes. 0 commits every keystroke. */
|
|
130
|
+
debounce(debounceMs: number): this {
|
|
131
|
+
return this.ann({ debounceMs });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
class ColorNode extends BaseNode<string> {
|
|
136
|
+
readonly kind = 'color';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
class EnumNode<T extends string> extends BaseNode<T> {
|
|
140
|
+
readonly kind = 'enum';
|
|
141
|
+
|
|
142
|
+
/** Render as a segmented control rather than a select. */
|
|
143
|
+
radio(): this {
|
|
144
|
+
return this.ann({ control: 'radio' });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Relabel the choices. Named `labels` rather than `options` because
|
|
148
|
+
* `options` is the node's own extras bag. */
|
|
149
|
+
labels(options: readonly ConfigOption[]): this {
|
|
150
|
+
return this.ann({ options });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A leaf whose kind the rule chain decides. */
|
|
155
|
+
class ValueNode<T> extends BaseNode<T> {
|
|
156
|
+
readonly kind = null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A leaf of a kind labkit does not ship a row for. */
|
|
160
|
+
class CustomNode<T> extends BaseNode<T> {
|
|
161
|
+
constructor(
|
|
162
|
+
readonly kind: string,
|
|
163
|
+
defaultValue: T,
|
|
164
|
+
annotations: Readonly<Annotations> = {},
|
|
165
|
+
options: Readonly<NodeOptions> = {},
|
|
166
|
+
) {
|
|
167
|
+
super(defaultValue, annotations, options);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
protected override with(annotations: Annotations, options: NodeOptions): this {
|
|
171
|
+
return new CustomNode(this.kind, this.default, annotations, options) as this;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const expand = <T extends string>(
|
|
176
|
+
options: readonly T[] | readonly ConfigOption[],
|
|
177
|
+
): readonly ConfigOption[] =>
|
|
178
|
+
options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Builds an instrument's config: the values, their types, and how each is
|
|
182
|
+
* edited, in one declaration.
|
|
183
|
+
*
|
|
184
|
+
* ```ts
|
|
185
|
+
* const config = f.schema({
|
|
186
|
+
* showGrid: f.boolean(true),
|
|
187
|
+
* cellSize: f.number(20).range(5, 80).step(5).label('Grid spacing'),
|
|
188
|
+
* })
|
|
189
|
+
* ```
|
|
190
|
+
*/
|
|
191
|
+
export const f = {
|
|
192
|
+
number: (def: number): NumberNode => new NumberNode(def),
|
|
193
|
+
boolean: (def: boolean): BooleanNode => new BooleanNode(def),
|
|
194
|
+
string: (def: string): StringNode => new StringNode(def),
|
|
195
|
+
color: (def: string): ColorNode => new ColorNode(def),
|
|
196
|
+
|
|
197
|
+
/** A fixed set of choices. The default's literal type flows into the config
|
|
198
|
+
* type, so `f.enum('fast', ['fast', 'accurate'])` gives
|
|
199
|
+
* `'fast' | 'accurate'` with no `as const`. */
|
|
200
|
+
enum: <const T extends string>(
|
|
201
|
+
def: T,
|
|
202
|
+
options: readonly T[] | readonly ConfigOption[],
|
|
203
|
+
): EnumNode<T> => new EnumNode<T>(def, { options: expand(options) }),
|
|
204
|
+
|
|
205
|
+
/** A leaf with no declared kind: the rule chain decides, which is how a lab
|
|
206
|
+
* states a convention like "every `*Color` key is a color picker". */
|
|
207
|
+
value: <T>(def: T): ValueNode<T> => new ValueNode(def),
|
|
208
|
+
|
|
209
|
+
/** A leaf of a kind a lab supplies the control for, through `controls`. */
|
|
210
|
+
custom: <T>(kind: string, def: T, validate?: (leaf: PrefLeaf) => string[]): CustomNode<T> =>
|
|
211
|
+
new CustomNode(kind, def, {}, validate ? { validate } : {}),
|
|
212
|
+
|
|
213
|
+
/** Collect leaves into an instrument's config. */
|
|
214
|
+
schema<S extends Record<string, ConfigNode>>(nodes: S): ConfigSchema<InferConfig<S>> {
|
|
215
|
+
return {
|
|
216
|
+
nodes,
|
|
217
|
+
defaults: () => {
|
|
218
|
+
const out: Record<string, unknown> = {};
|
|
219
|
+
for (const [key, node] of Object.entries(nodes)) out[key] = node.default;
|
|
220
|
+
return out as InferConfig<S>;
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
};
|