@vestra-player/sdk 1.5.43 → 1.5.44

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.
@@ -86,6 +86,7 @@ import { nodeProblem, VIDEO_PROCESSING_LIMITS } from "../processing.js";
86
86
  import { shaderIdProblem, shaderListProblem } from "./shaders.js";
87
87
  import { RENDER_DEFAULTS, renderProblem, VIEW_DEFAULTS, viewProblem, } from "./view.js";
88
88
  import { createPlugin } from "./index.js";
89
+ import { applyOps, PATCH_FEATURE, PATCH_OPS } from "./patch.js";
89
90
  import { evaluate } from "./when.js";
90
91
  import { WAKE_EVENTS } from "./runtime.js";
91
92
  import { THEMES } from "./appearance.js";
@@ -929,6 +930,54 @@ export class TestHost {
929
930
  if (reason !== null && reason !== undefined)
930
931
  this.#declared(reason, "reason");
931
932
  }
933
+ /**
934
+ * Row patches (player 0.6.0): the `seq` of each property's last write, by
935
+ * view and property (none: 0, the initial properties).
936
+ */
937
+ #writes = new Map();
938
+ #resetWrites(view) {
939
+ for (const slot of [...this.#writes.keys()])
940
+ if (slot.startsWith(`${view}\u0000`))
941
+ this.#writes.delete(slot);
942
+ }
943
+ /**
944
+ * A property write as the player takes it, returning the value the
945
+ * property shows after it: a whole `value`, or a `patch` of the array
946
+ * `current` applying on the write numbered `base` (code `stale`
947
+ * otherwise). The result is checked against the value limits whole: the
948
+ * player counts the rows a patch keeps too.
949
+ */
950
+ #write(view, property, p, current) {
951
+ const slot = `${view}\u0000${property}`;
952
+ const patch = p.patch;
953
+ if (patch === undefined) {
954
+ const value = this.#shownValues(p.value);
955
+ this.#checkValues({ [property]: value });
956
+ this.#seeImages([value]);
957
+ this.#writes.set(slot, typeof p.seq === "number" ? p.seq : 0);
958
+ return value;
959
+ }
960
+ const { base, seq } = patch;
961
+ if (!Number.isInteger(base) ||
962
+ !Number.isInteger(seq) ||
963
+ base < 0 ||
964
+ seq < 1)
965
+ throw new HostError("invalid", "patch.base and patch.seq require non-negative integers (seq ≥ 1)");
966
+ const stale = () => new HostError("stale", "the property changed since the write this patch applies on; send the whole value");
967
+ if ((this.#writes.get(slot) ?? 0) !== base || !Array.isArray(current))
968
+ throw stale();
969
+ const ops = patchOps(patch.ops, current.length);
970
+ const resolved = ops.map((op) => op.op === "set"
971
+ ? { ...op, row: this.#shownValues(op.row) }
972
+ : op.op === "insert"
973
+ ? { ...op, rows: op.rows.map((row) => this.#shownValues(row)) }
974
+ : op);
975
+ const value = applyOps(current, resolved);
976
+ this.#checkValues({ [property]: value });
977
+ this.#seeImages(resolved.flatMap((op) => op.op === "set" ? [op.row] : op.op === "insert" ? op.rows : []));
978
+ this.#writes.set(slot, seq);
979
+ return value;
980
+ }
932
981
  /**
933
982
  * Refuse property values over the player's limits ({@link UI_VALUE_LIMITS})
934
983
  * with `limit`, naming the property, as the player does.
@@ -3816,6 +3865,7 @@ export class TestHost {
3816
3865
  const id = String(p.id);
3817
3866
  if (this.windows.has(id))
3818
3867
  throw new HostError("conflict", "view already exists");
3868
+ this.#resetWrites(`w:${id}`);
3819
3869
  const properties = this.#shownValues(p.properties);
3820
3870
  this.#checkValues(properties);
3821
3871
  this.#seeImages(properties);
@@ -3841,10 +3891,8 @@ export class TestHost {
3841
3891
  const window = this.windows.get(String(p.id));
3842
3892
  if (!window)
3843
3893
  throw new HostError("missing", "view not found");
3844
- const value = this.#shownValues(p.value);
3845
- this.#checkValues({ [String(p.property)]: value });
3846
- this.#seeImages([value]);
3847
- window.props[String(p.property)] = value;
3894
+ const property = String(p.property);
3895
+ window.props[property] = this.#write(`w:${String(p.id)}`, property, p, window.props[property]);
3848
3896
  },
3849
3897
  "ui.show": (p) => {
3850
3898
  const window = this.windows.get(String(p.id));
@@ -3878,6 +3926,7 @@ export class TestHost {
3878
3926
  ]));
3879
3927
  const properties = this.#initialValues(this.#shownValues(p.properties));
3880
3928
  this.#seeImages(properties);
3929
+ this.#resetWrites("$mount");
3881
3930
  const host = this;
3882
3931
  this.panel = {
3883
3932
  slot: String(p.slot),
@@ -4202,11 +4251,9 @@ export class TestHost {
4202
4251
  this.emit("sequence.step", { direction });
4203
4252
  },
4204
4253
  "ui.setMount": (p) => {
4205
- this.mounted();
4206
- const value = this.#shownValues(p.value);
4207
- this.#checkValues({ [String(p.property)]: value });
4208
- this.#seeImages([value]);
4209
- this.mounted().props[String(p.property)] = value;
4254
+ const panel = this.mounted();
4255
+ const property = String(p.property);
4256
+ panel.props[property] = this.#write("$mount", property, p, panel.props[property]);
4210
4257
  },
4211
4258
  "timeline.marks": () => ({ marks: this.timeline.marks }),
4212
4259
  "drops.provide": (p) => {
@@ -4245,6 +4292,7 @@ export class TestHost {
4245
4292
  throw new HostError("invalid", "a plugin has at most 8 settings pages");
4246
4293
  const properties = this.#initialValues(this.#shownValues(p.properties));
4247
4294
  this.#seeImages(properties);
4295
+ this.#resetWrites(`$settings:${id}`);
4248
4296
  const host = this;
4249
4297
  this.settings.pages.set(id, {
4250
4298
  category: String(p.category),
@@ -4267,10 +4315,8 @@ export class TestHost {
4267
4315
  const page = this.settings.pages.get(String(p.id));
4268
4316
  if (!page)
4269
4317
  throw new HostError("missing", "page not registered");
4270
- const value = this.#shownValues(p.value);
4271
- this.#checkValues({ [String(p.property)]: value });
4272
- this.#seeImages([value]);
4273
- page.props[String(p.property)] = value;
4318
+ const property = String(p.property);
4319
+ page.props[property] = this.#write(`$settings:${String(p.id)}`, property, p, page.props[property]);
4274
4320
  },
4275
4321
  "settings.unregister": (p) => {
4276
4322
  this.settings.pages.delete(String(p.id));
@@ -5261,7 +5307,10 @@ export async function startPlugin(activate, options = {}) {
5261
5307
  mkdirSync(data, { recursive: true });
5262
5308
  if (host.fileRoots.length === 0)
5263
5309
  host.fileRoots = [directory, data];
5264
- const instance = await createPlugin(host, manifest, directory, data, host.app);
5310
+ const instance = await createPlugin(host, manifest, directory, data,
5311
+ // Like the player: protocol additions it takes (`app.features` set on
5312
+ // the host replaces them, e.g. `[]` for an older player's behaviour).
5313
+ { features: [PATCH_FEATURE], ...host.app });
5265
5314
  await activate(instance.plugin);
5266
5315
  // Like the player: a position or resolver another plugin holds is not an
5267
5316
  // activation failure.
@@ -5290,3 +5339,72 @@ export async function startPlugin(activate, options = {}) {
5290
5339
  },
5291
5340
  };
5292
5341
  }
5342
+ /**
5343
+ * A patch's ops checked against an array of `length` rows, as the player
5344
+ * checks them (`ui_patch.rs`): in order, each on the result of the ones
5345
+ * before; at most {@link PATCH_OPS}; the result at most
5346
+ * `UI_VALUE_LIMITS.arrayRows` rows.
5347
+ */
5348
+ function patchOps(ops, length) {
5349
+ if (!Array.isArray(ops))
5350
+ throw new HostError("invalid", "patch.ops requires an array");
5351
+ if (ops.length > PATCH_OPS)
5352
+ throw new HostError("limit", `a patch carries at most ${PATCH_OPS} operations`);
5353
+ const index = (op, key) => {
5354
+ const value = op[key];
5355
+ if (!Number.isInteger(value) || value < 0)
5356
+ throw new HostError("invalid", `patch op ${key} requires a non-negative integer`);
5357
+ return value;
5358
+ };
5359
+ const count = (op) => {
5360
+ const value = index(op, "count");
5361
+ if (value === 0)
5362
+ throw new HostError("invalid", "patch op count must be at least 1");
5363
+ return value;
5364
+ };
5365
+ const past = (what) => new HostError("invalid", `${what} is past the end of the list`);
5366
+ let len = length;
5367
+ return ops.map((raw) => {
5368
+ const op = (raw ?? {});
5369
+ switch (op.op) {
5370
+ case "set": {
5371
+ const at = index(op, "at");
5372
+ if (at >= len)
5373
+ throw past(`set at ${at}`);
5374
+ if (!("row" in op))
5375
+ throw new HostError("invalid", "patch op set requires row");
5376
+ return { op: "set", at, row: op.row };
5377
+ }
5378
+ case "insert": {
5379
+ const at = index(op, "at");
5380
+ const rows = op.rows;
5381
+ if (!Array.isArray(rows) || rows.length === 0)
5382
+ throw new HostError("invalid", "patch op insert requires rows");
5383
+ if (at > len)
5384
+ throw past(`insert at ${at}`);
5385
+ len += rows.length;
5386
+ if (len > UI_VALUE_LIMITS.arrayRows)
5387
+ throw new HostError("limit", `UI array exceeds ${UI_VALUE_LIMITS.arrayRows} rows`);
5388
+ return { op: "insert", at, rows: rows };
5389
+ }
5390
+ case "remove": {
5391
+ const at = index(op, "at");
5392
+ const n = count(op);
5393
+ if (at + n > len)
5394
+ throw past(`remove ${n} at ${at}`);
5395
+ len -= n;
5396
+ return { op: "remove", at, count: n };
5397
+ }
5398
+ case "move": {
5399
+ const from = index(op, "from");
5400
+ const n = count(op);
5401
+ const to = index(op, "to");
5402
+ if (from + n > len || to + n > len)
5403
+ throw past(`move ${n} from ${from} to ${to}`);
5404
+ return { op: "move", from, count: n, to };
5405
+ }
5406
+ default:
5407
+ throw new HostError("invalid", "patch op must be set, insert, remove or move");
5408
+ }
5409
+ });
5410
+ }
@@ -1,4 +1,4 @@
1
- import type { Json } from "../wire.ts";
1
+ import type { Json, PropertyWrite } from "../wire.ts";
2
2
  import type { ScopeImpl } from "./scope.ts";
3
3
  import type { Dialogs, Disposable, ImageOptions, ImageRef, Panels, PropValue, Props, Ui, View, Windows } from "./types.ts";
4
4
  /** Slint names are kebab-case; plugin code may use camelCase. */
@@ -29,7 +29,7 @@ export declare abstract class ViewBase implements View {
29
29
  protected closed: boolean;
30
30
  constructor(scope: ScopeImpl);
31
31
  protected abstract live(): boolean;
32
- protected abstract write(property: string, value: Json): Promise<void>;
32
+ protected abstract write(property: string, write: PropertyWrite): Promise<void>;
33
33
  /**
34
34
  * Initial properties in wire form. A released image is left out (the
35
35
  * property keeps its default) and reported, like a failed write.
package/dist/plugin/ui.js CHANGED
@@ -6,6 +6,8 @@ import { basename, extname, resolve } from "node:path";
6
6
  import { failure, Latest, quiet } from "./runtime.js";
7
7
  import { dropsOf } from "./drops.js";
8
8
  import { MarkdownHandle, markdown, remoteImageFile } from "./markdown.js";
9
+ import { HostError } from "../wire.js";
10
+ import { countRow, diff, keyOf, PATCH_FEATURE, replay, withinLimits, } from "./patch.js";
9
11
  /** Slint names are kebab-case; plugin code may use camelCase. */
10
12
  export const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
11
13
  const encode = (props) => Object.fromEntries(Object.entries(props).map(([k, v]) => [kebab(k), v]));
@@ -62,8 +64,9 @@ function wireValue(value) {
62
64
  throw failure("invalid", `image ${value.path} was released with the scope that created it`);
63
65
  return value.toJSON();
64
66
  }
67
+ // Array.from: a hole is `null`, as `JSON.stringify` writes it.
65
68
  if (Array.isArray(value))
66
- return value.map((item) => wireValue(item));
69
+ return Array.from(value, (item) => wireValue(item ?? null));
67
70
  if (value !== null && typeof value === "object") {
68
71
  const out = {};
69
72
  for (const [key, item] of Object.entries(value))
@@ -72,6 +75,89 @@ function wireValue(value) {
72
75
  }
73
76
  return value;
74
77
  }
78
+ /** Scalars compared by value. Non-finite numbers are JSON `null`: compared as JSON. */
79
+ const isPrimitive = (value) => value === null ||
80
+ typeof value === "string" ||
81
+ typeof value === "boolean" ||
82
+ (typeof value === "number" && Number.isFinite(value));
83
+ /** A row as `JSON.stringify` writes it inside an array. */
84
+ const encodeRow = (row) => JSON.stringify(row) ?? "null";
85
+ /** Each row of an array as `JSON.stringify` writes it; a hole is `null`. */
86
+ const encodeRows = (rows) => Array.from(rows, encodeRow);
87
+ function sameRows(a, b) {
88
+ if (a.length !== b.length)
89
+ return false;
90
+ for (let i = 0; i < a.length; i++)
91
+ if (a[i] !== b[i])
92
+ return false;
93
+ return true;
94
+ }
95
+ /** An initial property (wire form) as sent. */
96
+ function sentOf(value, patches) {
97
+ if (isPrimitive(value))
98
+ return { kind: "value", value };
99
+ if (Array.isArray(value) && patches) {
100
+ const keyed = keyOf(value);
101
+ return {
102
+ kind: "rows",
103
+ rows: {
104
+ enc: encodeRows(value),
105
+ field: keyed?.field ?? null,
106
+ keys: keyed?.keys ?? null,
107
+ counts: [],
108
+ },
109
+ };
110
+ }
111
+ return { kind: "json", enc: JSON.stringify(value) };
112
+ }
113
+ /**
114
+ * The row patch from `sent` to `next` (the bound `rows`), in wire form, and
115
+ * the new rows' limit counts; `null` when the whole value is to be sent:
116
+ * nothing to type the rows by (no rows sent), a Markdown document in a row
117
+ * (the player merges documents in place), too many operations, a value over
118
+ * the limits (the player refuses it whole, as before), or no change (an
119
+ * explicit `set()` of the same value).
120
+ */
121
+ function rowPatch(sent, next, rows) {
122
+ if (sent.enc.length === 0)
123
+ return null;
124
+ if (next.enc.some((row) => row.includes('"$vestraMarkdown"')))
125
+ return null;
126
+ const nextKeyed = sent.field === null ? null : next.keyed();
127
+ const keyed = nextKeyed !== null && nextKeyed.field === sent.field;
128
+ const plan = diff({ enc: sent.enc, keys: keyed ? sent.keys : null }, { enc: next.enc, keys: keyed ? nextKeyed.keys : null });
129
+ if (!plan || plan.ops.length === 0)
130
+ return null;
131
+ // A released image is refused wherever it is, as in a whole write: the
132
+ // rows the patch does not carry are checked too (those holding images).
133
+ for (let j = 0; j < rows.length; j++)
134
+ if (plan.from[j] >= 0 && next.enc[j].includes('"$vestraImage"'))
135
+ wireValue(rows[j] ?? null);
136
+ const counts = next.enc.map((_, j) => {
137
+ const from = plan.from[j];
138
+ return (from >= 0 ? sent.counts[from] : undefined) ?? countRow(rows[j]);
139
+ });
140
+ if (!withinLimits(counts))
141
+ return null;
142
+ // The ops must give exactly the new rows (a guard against this diff's bugs).
143
+ if (!sameRows(replay(sent.enc, plan.ops, next.enc), next.enc))
144
+ return null;
145
+ const ops = plan.ops.map((op) => {
146
+ switch (op.op) {
147
+ case "set":
148
+ return { op: "set", at: op.at, row: wireValue(rows[op.row] ?? null) };
149
+ case "insert":
150
+ return {
151
+ op: "insert",
152
+ at: op.at,
153
+ rows: op.rows.map((j) => wireValue(rows[j] ?? null)),
154
+ };
155
+ default:
156
+ return op;
157
+ }
158
+ });
159
+ return { ops, counts };
160
+ }
75
161
  export const ui = (scope) => ({
76
162
  image(path, options) {
77
163
  if (typeof path !== "string" || !path)
@@ -96,6 +182,18 @@ export class ViewBase {
96
182
  #binders = new Map();
97
183
  /** What the host currently shows, as far as this side knows. */
98
184
  #sent = new Map();
185
+ /**
186
+ * Row patches (player feature `ui.patch`): each property's last write the
187
+ * player confirmed, by `seq` (0: the initial properties). A patch applies
188
+ * on it; a property without one is sent whole.
189
+ */
190
+ #bases = new Map();
191
+ /**
192
+ * Patches of a property answered `stale` in a row: after two, the
193
+ * property is sent whole until the view shows its initial properties again.
194
+ */
195
+ #stales = new Map();
196
+ #seq = 0;
99
197
  /** Written by `set()`: sent even if equal to what was sent before. */
100
198
  #forced = new Set();
101
199
  /** The last value of each property whose write failed; not retried until it changes. */
@@ -111,6 +209,10 @@ export class ViewBase {
111
209
  this.scope = scope;
112
210
  this.#binding = scope.runtime.bind(() => this.evaluate());
113
211
  }
212
+ /** The player takes row patches (and `seq` on whole writes). */
213
+ get #patches() {
214
+ return this.scope.runtime.features.has(PATCH_FEATURE);
215
+ }
114
216
  /**
115
217
  * Initial properties in wire form. A released image is left out (the
116
218
  * property keeps its default) and reported, like a failed write.
@@ -173,11 +275,107 @@ export class ViewBase {
173
275
  }
174
276
  /** The host already shows these values (e.g. sent as initial properties). */
175
277
  shown(values) {
176
- this.#sent = new Map(Object.entries(values).map(([k, v]) => [k, JSON.stringify(v)]));
278
+ const patches = this.#patches;
279
+ this.#sent = new Map(Object.entries(values).map(([k, v]) => [k, sentOf(v, patches)]));
280
+ this.#bases = new Map(patches ? Object.keys(values).map((key) => [key, 0]) : []);
281
+ this.#stales.clear();
177
282
  this.#forced.clear();
178
283
  for (const key of Object.keys(values))
179
284
  this.#failed.delete(key);
180
285
  }
286
+ /** `value` encoded, and whether it differs from what was sent. */
287
+ #encode(key, value) {
288
+ const sent = this.#sent.get(key);
289
+ if (isPrimitive(value)) {
290
+ // Compared as it is: no `JSON.stringify` for the scalars of a pass.
291
+ const changed = sent?.kind === "value"
292
+ ? sent.value !== value
293
+ : sent?.kind !== "json" || sent.enc !== JSON.stringify(value);
294
+ return {
295
+ key,
296
+ value,
297
+ changed,
298
+ signature: () => JSON.stringify(value),
299
+ sent: () => ({ kind: "value", value }),
300
+ };
301
+ }
302
+ if (Array.isArray(value) && this.#patches) {
303
+ // Row by row: the same work as one `JSON.stringify`, and the rows to
304
+ // diff against those last sent.
305
+ const enc = encodeRows(value);
306
+ const signature = () => `[${enc.join(",")}]`;
307
+ const changed = sent?.kind === "rows"
308
+ ? !sameRows(sent.rows.enc, enc)
309
+ : sent?.kind !== "json" || sent.enc !== signature();
310
+ // Keys only for a write: an unchanged array costs its encoding alone.
311
+ let keys;
312
+ const keyed = () => (keys === undefined ? (keys = keyOf(value)) : keys);
313
+ return {
314
+ key,
315
+ value,
316
+ changed,
317
+ signature,
318
+ rows: { enc, keyed },
319
+ sent: (counts = []) => ({
320
+ kind: "rows",
321
+ rows: {
322
+ enc,
323
+ field: keyed()?.field ?? null,
324
+ keys: keyed()?.keys ?? null,
325
+ counts,
326
+ },
327
+ }),
328
+ };
329
+ }
330
+ const enc = JSON.stringify(value);
331
+ return {
332
+ key,
333
+ value,
334
+ changed: sent?.kind !== "json" || sent.enc !== enc,
335
+ signature: () => enc,
336
+ sent: () => ({ kind: "json", enc }),
337
+ };
338
+ }
339
+ /**
340
+ * Write one property: a row patch when the player takes them and one
341
+ * applies (else the whole value); a `stale` patch is followed by the whole
342
+ * value at once. The base is forgotten on any failure, so the next write
343
+ * of the property is whole.
344
+ */
345
+ async #send(pending) {
346
+ const { key, value } = pending;
347
+ if (!this.#patches) {
348
+ await this.write(key, { value: wireValue(value) });
349
+ return pending.sent();
350
+ }
351
+ const base = this.#bases.get(key);
352
+ this.#bases.delete(key);
353
+ const sent = this.#sent.get(key);
354
+ const plan = base !== undefined &&
355
+ sent?.kind === "rows" &&
356
+ pending.rows &&
357
+ (this.#stales.get(key) ?? 0) < 2
358
+ ? rowPatch(sent.rows, pending.rows, value)
359
+ : null;
360
+ if (plan) {
361
+ const seq = ++this.#seq;
362
+ try {
363
+ await this.write(key, { patch: { base: base, seq, ops: plan.ops } });
364
+ this.#bases.set(key, seq);
365
+ this.#stales.delete(key);
366
+ return pending.sent(plan.counts);
367
+ }
368
+ catch (error) {
369
+ if (!(error instanceof HostError && error.code === "stale"))
370
+ throw error;
371
+ this.#stales.set(key, (this.#stales.get(key) ?? 0) + 1);
372
+ }
373
+ }
374
+ const seq = ++this.#seq;
375
+ await this.write(key, { value: wireValue(value), seq });
376
+ this.#bases.set(key, seq);
377
+ return pending.sent();
378
+ }
181
379
  /**
182
380
  * Send what changed. A bound value is sent when it differs from the last one
183
381
  * sent; a `set()` value is always sent once. Nothing is resent merely
@@ -198,20 +396,22 @@ export class ViewBase {
198
396
  try {
199
397
  let pending;
200
398
  do {
201
- pending = [...this.#values].filter(([key, value]) => {
202
- const encoded = JSON.stringify(value);
203
- if (this.#failed.get(key) === encoded)
204
- return false;
205
- return this.#forced.has(key) || this.#sent.get(key) !== encoded;
206
- });
207
- for (const [key, value] of pending) {
399
+ pending = [];
400
+ for (const [key, value] of this.#values) {
401
+ const encoded = this.#encode(key, value);
402
+ const failedAs = this.#failed.get(key);
403
+ if (failedAs !== undefined && failedAs === encoded.signature())
404
+ continue;
405
+ if (this.#forced.has(key) || encoded.changed)
406
+ pending.push(encoded);
407
+ }
408
+ for (const encoded of pending) {
208
409
  if (!this.live())
209
410
  return;
210
- const encoded = JSON.stringify(value);
411
+ const key = encoded.key;
211
412
  this.#forced.delete(key);
212
413
  try {
213
- await this.write(key, wireValue(value));
214
- this.#sent.set(key, encoded);
414
+ this.#sent.set(key, await this.#send(encoded));
215
415
  this.#failed.delete(key);
216
416
  this.#backoff = 0;
217
417
  this.#broken = false;
@@ -222,7 +422,7 @@ export class ViewBase {
222
422
  if (!this.#broken)
223
423
  failure ??= error;
224
424
  this.#broken = true;
225
- this.#failed.set(key, encoded);
425
+ this.#failed.set(key, encoded.signature());
226
426
  }
227
427
  }
228
428
  } while (pending.length && !failed && this.live());
@@ -294,8 +494,8 @@ class WindowHandle extends ViewBase {
294
494
  live() {
295
495
  return this.#opened && !this.closed;
296
496
  }
297
- write(property, value) {
298
- return this.scope.runtime.host.ui.set(this.#id, property, value);
497
+ write(property, write) {
498
+ return this.scope.runtime.host.ui.set(this.#id, property, write);
299
499
  }
300
500
  on(callback, handler) {
301
501
  // The host forwards only callbacks named when the window opens.
@@ -400,8 +600,8 @@ class PanelHandle extends ViewBase {
400
600
  live() {
401
601
  return this.#mounted && !this.closed;
402
602
  }
403
- write(property, value) {
404
- return this.scope.runtime.host.ui.setMount(property, value);
603
+ write(property, write) {
604
+ return this.scope.runtime.host.ui.setMount(property, write);
405
605
  }
406
606
  get state() {
407
607
  const service = layoutOf(this.scope.runtime);
package/dist/wire.d.ts CHANGED
@@ -56,6 +56,45 @@ export interface UiCallback {
56
56
  callback: string;
57
57
  args: Json[];
58
58
  }
59
+ /**
60
+ * One row operation of a patch, by index, applied to the result of the
61
+ * ones before (player 0.6.0, feature `ui.patch`). `move` takes `count` rows
62
+ * out at `from` and puts them back at `to`, an index of the list without
63
+ * them.
64
+ */
65
+ export type RowOp = {
66
+ op: "set";
67
+ at: number;
68
+ row: Json;
69
+ } | {
70
+ op: "insert";
71
+ at: number;
72
+ rows: Json[];
73
+ } | {
74
+ op: "remove";
75
+ at: number;
76
+ count: number;
77
+ } | {
78
+ op: "move";
79
+ from: number;
80
+ count: number;
81
+ to: number;
82
+ };
83
+ /**
84
+ * A property write: a whole value (with its `seq` to players that take
85
+ * patches), or a row patch of an array applying on the write numbered
86
+ * `base` (0: the initial properties).
87
+ */
88
+ export type PropertyWrite = {
89
+ value: Json;
90
+ seq?: number;
91
+ } | {
92
+ patch: {
93
+ base: number;
94
+ seq: number;
95
+ ops: RowOp[];
96
+ };
97
+ };
59
98
  /**
60
99
  * A definite refusal from the player (or the plugin runner), with a stable
61
100
  * `code`. Plugin code sees it as a `PluginError` (an `Error` with `code`);
@@ -481,7 +520,7 @@ export declare function createWire(transport: Transport): {
481
520
  global: string;
482
521
  properties: Record<string, Json>;
483
522
  }) => Promise<void>;
484
- set: (id: string, property: string, value: Json) => Promise<void>;
523
+ set: (id: string, property: string, write: PropertyWrite) => Promise<void>;
485
524
  unregister: (id: string) => Promise<void>;
486
525
  show: (id?: string) => Promise<void>;
487
526
  };
@@ -668,7 +707,7 @@ export declare function createWire(transport: Transport): {
668
707
  prepareMs: number;
669
708
  shellGeneration: number;
670
709
  }>;
671
- setMount: (property: string, value: Json) => Promise<void>;
710
+ setMount: (property: string, write: PropertyWrite) => Promise<void>;
672
711
  interaction: (locks: {
673
712
  focused: boolean;
674
713
  dragging: boolean;
@@ -677,7 +716,7 @@ export declare function createWire(transport: Transport): {
677
716
  unmount: () => Promise<void>;
678
717
  menu: (items: (string | null)[], x: number, y: number) => Promise<void>;
679
718
  open: (id: string, file: string, properties?: Record<string, Json>, callbacks?: string[]) => Promise<void>;
680
- set: (id: string, property: string, value: Json) => Promise<void>;
719
+ set: (id: string, property: string, write: PropertyWrite) => Promise<void>;
681
720
  show: (id: string, visible: boolean) => Promise<void>;
682
721
  close: (id: string) => Promise<void>;
683
722
  };
package/dist/wire.js CHANGED
@@ -125,7 +125,7 @@ export function createWire(transport) {
125
125
  /** Settings pages in the player's options window (`ui.settings`). */
126
126
  settings: {
127
127
  register: (options) => transport.request("settings.register", options),
128
- set: (id, property, value) => transport.request("settings.set", { id, property, value }),
128
+ set: (id, property, write) => transport.request("settings.set", { id, property, ...write }),
129
129
  unregister: (id) => transport.request("settings.unregister", { id }),
130
130
  show: (id) => transport.request("settings.show", id === undefined ? {} : { id }),
131
131
  },
@@ -321,12 +321,12 @@ export function createWire(transport) {
321
321
  base,
322
322
  }),
323
323
  mount: (options) => transport.request("ui.mount", options),
324
- setMount: (property, value) => transport.request("ui.setMount", { property, value }),
324
+ setMount: (property, write) => transport.request("ui.setMount", { property, ...write }),
325
325
  interaction: (locks) => transport.request("ui.interaction", locks),
326
326
  unmount: () => transport.request("ui.unmount"),
327
327
  menu: (items, x, y) => transport.request("ui.menu", { items, x, y }),
328
328
  open: (id, file, properties = {}, callbacks = []) => transport.request("ui.open", { id, file, properties, callbacks }),
329
- set: (id, property, value) => transport.request("ui.set", { id, property, value }),
329
+ set: (id, property, write) => transport.request("ui.set", { id, property, ...write }),
330
330
  show: (id, visible) => transport.request("ui.show", { id, visible }),
331
331
  close: (id) => transport.request("ui.close", { id }),
332
332
  },