@m4l-jweb/surface 0.6.5 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/surface",
3
- "version": "0.6.5",
3
+ "version": "0.7.0",
4
4
  "description": "m4l-jweb: declare a device's Live parameters as code - the surface Push actually sees - plus a mocked-Live dev harness.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "peerDependencies": {
26
26
  "react": ">=18",
27
- "@m4l-jweb/bridge": "0.6.5"
27
+ "@m4l-jweb/bridge": "0.7.0"
28
28
  },
29
29
  "peerDependenciesMeta": {
30
30
  "react": {
package/src/index.ts CHANGED
@@ -92,10 +92,32 @@ export interface MenuSpec<O extends string = string> {
92
92
  short: string;
93
93
  }
94
94
 
95
- export type ParamSpec = DialSpec | ToggleSpec | MenuSpec;
95
+ /**
96
+ * A LABELLED toggle button - a `live.text` in toggle mode, which a bare
97
+ * `live.toggle` cannot be: it carries visible text. The on/off value is the same as
98
+ * a toggle; `label` is what the button reads. Handy as a native view switch (a
99
+ * "Back" button) where the plain orange square of a toggle says nothing.
100
+ */
101
+ export interface ButtonSpec {
102
+ kind: "button";
103
+ default: boolean;
104
+ /** The text on the button. */
105
+ label: string;
106
+ short: string;
107
+ }
108
+
109
+ export type ParamSpec = DialSpec | ToggleSpec | MenuSpec | ButtonSpec;
96
110
 
97
111
  /** The value type a given parameter carries. `useParam` will be typed by this. */
98
- export type ParamValue<P extends ParamSpec> = P extends DialSpec ? number : P extends ToggleSpec ? boolean : P extends MenuSpec<infer O> ? O : never;
112
+ export type ParamValue<P extends ParamSpec> = P extends DialSpec
113
+ ? number
114
+ : P extends ToggleSpec
115
+ ? boolean
116
+ : P extends ButtonSpec
117
+ ? boolean
118
+ : P extends MenuSpec<infer O>
119
+ ? O
120
+ : never;
99
121
 
100
122
  /* ------------------------------------------------------------------ *
101
123
  * Windows and State
@@ -160,6 +182,11 @@ export const toggle = (spec: Omit<ToggleSpec, "kind">): ToggleSpec => ({
160
182
  kind: "toggle",
161
183
  ...spec,
162
184
  });
185
+ /** A labelled toggle button (`live.text`). Same on/off value as a toggle, with visible text. */
186
+ export const button = (spec: Omit<ButtonSpec, "kind">): ButtonSpec => ({
187
+ kind: "button",
188
+ ...spec,
189
+ });
163
190
  /**
164
191
  * The options are spelled out rather than written as `Omit<MenuSpec<O>, "kind">`,
165
192
  * and that is not a style choice: TypeScript cannot infer `O` THROUGH an `Omit`,
@@ -184,6 +211,47 @@ export interface Bank<K extends string> {
184
211
  params: readonly K[];
185
212
  }
186
213
 
214
+ /**
215
+ * Which parameters render as NATIVE `live.*` objects in the device view, and how
216
+ * they are laid out.
217
+ *
218
+ * The compiler ALREADY generates a `live.dial` / `live.toggle` / `live.menu` for
219
+ * every declared parameter; they are invisible today only because they carry no
220
+ * `presentation` attribute, and Live shows the presentation view. Naming a
221
+ * parameter here makes the SAME object visible - a presentation overlay on codegen
222
+ * that already exists, with no wiring change: it is the same parameter, the same
223
+ * fan-out graph, `useParam()` still reads it, now drawn by Max instead of React.
224
+ */
225
+ export interface NativeLayout<K extends string = string> {
226
+ /**
227
+ * In display order: fills rows top-to-bottom, then overflows into the next
228
+ * column (column-major, so adding a parameter does not reshuffle the rest).
229
+ */
230
+ params: readonly K[];
231
+ /**
232
+ * Max rows per column. Default 3 - the device view is a fixed ~169 px tall and a
233
+ * `live.dial` needs a 56 px pitch, so only 3 fit vertically.
234
+ */
235
+ rows?: number;
236
+ /**
237
+ * LAYERED "two screens" instead of side-by-side. When true, `[jweb]` is built
238
+ * full-width and the dials OVERLAP its left, and the app flips between them with
239
+ * `useNativePanel` (hide one layer, show the other) - because runtime reposition
240
+ * of native objects does not work in a frozen M4L device, only hide/show does.
241
+ * When false (the default), the dials sit BESIDE a right-shifted `[jweb]`, both
242
+ * visible at once.
243
+ */
244
+ panel?: boolean;
245
+ /**
246
+ * A parameter that is the VIEW SWITCH, not a grid dial: pinned to the top-right
247
+ * (over the web UI's own switch button, so the control stays in one place across
248
+ * both views), kept out of the `params` grid, and shown in both modes. Meant for a
249
+ * toggle in a `panel` layout - the way back from the native panel, since the web
250
+ * UI is hidden there.
251
+ */
252
+ switch?: K;
253
+ }
254
+
187
255
  /**
188
256
  * The declaration. `P`, `S` and `W` are inferred from what you write - they exist so
189
257
  * that `useParam`, `useStateSync` and `useWindow` are typed against THIS surface: a
@@ -199,6 +267,8 @@ export interface SurfaceDef<
199
267
  banks?: readonly Bank<Extract<keyof P, string>>[];
200
268
  windows?: W;
201
269
  state?: S;
270
+ /** Which parameters render as native Max objects in the device view. */
271
+ layout?: { native?: NativeLayout<Extract<keyof P, string>> };
202
272
  }
203
273
 
204
274
  export interface Surface<
@@ -265,9 +335,39 @@ export function defineSurface<
265
335
  }
266
336
  }
267
337
 
338
+ // A native layout may only name parameters that exist, and may not ask for more
339
+ // rows than the device view holds. Both throw here, at build time, for the same
340
+ // reason the bank checks do: a typo would otherwise generate a cord from a box
341
+ // that never gets a presentation rect, or overflow a 169 px view silently.
342
+ const native = def.layout?.native;
343
+ if (native) {
344
+ for (const id of native.params) {
345
+ if (!def.params[id]) throw new Error(`surface: layout.native names "${id}", which is not a declared parameter`);
346
+ }
347
+ const rows = native.rows ?? 3;
348
+ if (rows < 1 || rows > 3) throw new Error(`surface: layout.native.rows must be 1..3 - the device view is 169 px tall`);
349
+ if (native.switch !== undefined && !def.params[native.switch]) {
350
+ throw new Error(`surface: layout.native.switch names "${native.switch}", which is not a declared parameter`);
351
+ }
352
+ }
353
+
268
354
  return { ...def, ids };
269
355
  }
270
356
 
357
+ /**
358
+ * Does this parameter render as a native Max object? App code uses it to stop
359
+ * drawing an HTML control the device view now owns. Cheap and honest: a parameter
360
+ * that is not in `layout.native` is still an HTML control, so `useParam()` stays
361
+ * the source of truth either way.
362
+ */
363
+ export const isNative = (surface: Surface, id: string): boolean => !!surface.layout?.native?.params.includes(id as never);
364
+
365
+ /**
366
+ * The scripting name the build gives `[jweb]` when a surface declares native
367
+ * layout, so the app can hide/show it at runtime (see `useNativePanel`).
368
+ */
369
+ export const JWEB_VARNAME = "obj-jweb";
370
+
271
371
  /** The default value of every parameter. The app's initial state, before Live replies. */
272
372
  export function defaults<P extends Record<string, ParamSpec>>(surface: Surface<P>): { [K in keyof P]: ParamValue<P[K]> } {
273
373
  const out = {} as { [K in keyof P]: ParamValue<P[K]> };
@@ -277,7 +377,7 @@ export function defaults<P extends Record<string, ParamSpec>>(surface: Surface<P
277
377
 
278
378
  /** How a value is displayed - the parameter's own `format`, or a sane default. */
279
379
  export function formatValue(spec: ParamSpec, value: unknown): string {
280
- if (spec.kind === "toggle") return value ? "on" : "off";
380
+ if (spec.kind === "toggle" || spec.kind === "button") return value ? "on" : "off";
281
381
  if (spec.kind === "menu") return String(value);
282
382
  if (spec.format) return spec.format(Number(value));
283
383
  const n = Number(value);
package/src/react.tsx CHANGED
@@ -19,6 +19,7 @@
19
19
  import { useCallback, useMemo, useSyncExternalStore } from "react";
20
20
  import { outlet } from "@m4l-jweb/bridge";
21
21
  import type { ParamSpec, ParamValue, StateSpec, StateValue, Surface, WindowSpec } from "./index";
22
+ import { JWEB_VARNAME } from "./index";
22
23
  import { paramStore, stateStore } from "./store";
23
24
 
24
25
  /**
@@ -67,6 +68,61 @@ export function useWindow<
67
68
  );
68
69
  }
69
70
 
71
+ /**
72
+ * Show or hide a NATIVE dial in the device view at runtime.
73
+ *
74
+ * `layout.native` makes a parameter a native `live.*` object, but its presentation
75
+ * is STATIC - the dial is always visible. This is the runtime override: the app says
76
+ * which native params should be shown, and a `[thispatcher]` runs `script show`/
77
+ * `script hide` on the object by its scripting name (`param-<id>`, see
78
+ * `applyNativeControl` in @m4l-jweb/build). The parameter itself is untouched - a
79
+ * hidden dial still automates, MIDI-maps and reaches Push; only visibility changes.
80
+ *
81
+ * Returns a stable `(id, visible) => void`. Drive it from an effect that mirrors the
82
+ * app's own "which stages are active" state, e.g. `useEffect` over the shown set.
83
+ */
84
+ export function useNativeVisibility<P extends Record<string, ParamSpec>>(
85
+ _surface: Surface<P>,
86
+ ): (id: Extract<keyof P, string>, visible: boolean) => void {
87
+ return useCallback((id: Extract<keyof P, string>, visible: boolean) => {
88
+ // The varname applySurface() gave the object is `param-<id>`. Keep this prefix
89
+ // in step with that codegen - it is the one string both sides must agree on.
90
+ outlet(visible ? "native_show" : "native_hide", `param-${id}`);
91
+ }, []);
92
+ }
93
+
94
+ /**
95
+ * Flip the device view between the WEB UI and a NATIVE control panel - the "two
96
+ * screens" model. Runtime reposition/resize of presentation objects does NOT work in
97
+ * a frozen M4L device (measured: `presentation_rect` writes are stored but never
98
+ * redrawn), but `hidden` DOES. So instead of reflowing dials, we layer them:
99
+ *
100
+ * "web" - show [jweb] (full width), hide every native dial and the switch. The
101
+ * web UI paints its own switch button; the native one would only fight
102
+ * it for the same top-right spot.
103
+ * "native" - hide [jweb], show every native dial AND the switch - which is the way
104
+ * back, since the web UI is hidden here.
105
+ *
106
+ * Only `hidden` is used, so this actually works where reflow could not, and no layer
107
+ * is ever visible at the same time as another - so z-order never matters.
108
+ */
109
+ export function useNativePanel<P extends Record<string, ParamSpec>>(
110
+ surface: Surface<P>,
111
+ ): (mode: "web" | "native") => void {
112
+ return useCallback(
113
+ (mode: "web" | "native") => {
114
+ const native = surface.layout?.native;
115
+ if (!native) return;
116
+ const web = mode === "web";
117
+ const toggle = (varname: string) => outlet(web ? "native_hide" : "native_show", varname);
118
+ outlet(web ? "native_show" : "native_hide", JWEB_VARNAME);
119
+ for (const id of native.params as readonly string[]) toggle(`param-${id}`);
120
+ if (native.switch) toggle(`param-${native.switch}`);
121
+ },
122
+ [surface],
123
+ );
124
+ }
125
+
70
126
  /**
71
127
  * A two-way binding to a JSON state slot, persisted in the Live SET.
72
128
  *
package/src/store.ts CHANGED
@@ -40,7 +40,7 @@ const EPSILON = 1e-6;
40
40
 
41
41
  /** Max stores every parameter as a NUMBER. A menu is an index into its options; a toggle is 0/1. */
42
42
  export function toWire(spec: ParamSpec, value: unknown): number {
43
- if (spec.kind === "toggle") return value ? 1 : 0;
43
+ if (spec.kind === "toggle" || spec.kind === "button") return value ? 1 : 0;
44
44
  if (spec.kind === "menu") {
45
45
  const i = spec.options.indexOf(String(value));
46
46
  return i < 0 ? 0 : i;
@@ -50,7 +50,7 @@ export function toWire(spec: ParamSpec, value: unknown): number {
50
50
 
51
51
  /** ...and back. An out-of-range menu index falls back to the default rather than `undefined`. */
52
52
  export function fromWire(spec: ParamSpec, wire: number): unknown {
53
- if (spec.kind === "toggle") return wire >= 0.5;
53
+ if (spec.kind === "toggle" || spec.kind === "button") return wire >= 0.5;
54
54
  if (spec.kind === "menu") return spec.options[Math.round(wire)] ?? spec.default;
55
55
  return wire;
56
56
  }