@vestra-player/sdk 1.5.45 → 1.5.47

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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.47
4
+
5
+ **Who must change: nobody.** No changes to the TypeScript API, `apiVersion` stays `vestra/1`, no new capability, no player version needed. The built-in command table follows player 0.6.0's new default keys: a plugin whose documentation or tests list the player's default keys (from `BUILTIN_COMMANDS`, or Escape, Enter, F11 and the side mouse buttons by hand) should update them.
6
+
7
+ - **Player 0.6.0 default keys** (`BUILTIN_COMMANDS`, the generated key tables, `HostAction`): **Toggle Fullscreen** (`host/fullscreen`) is `Escape` only (up to 0.5.x `Enter`, `Alt+Enter`, `F11`); **Leave Fullscreen** (`host/exit-fullscreen`) has no default key (up to 0.5.x `Escape`); the new **Enter Fullscreen** (`host/enter-fullscreen`) has no default key and no menu row and is enabled only when windowed (Leave Fullscreen only in fullscreen); **Previous** is `PageUp`, `Mouse:X2` and **Next** `PageDown`, `Mouse:X1` (the side buttons swapped: back is next, forward is previous). The side buttons now fire anywhere in the main window or the floating sidebar window, except while a menu is open, not only over the video. `host.run` takes the same actions as before (`fullscreen` toggles). `TestHost`'s core actions use the new Previous / Next bindings.
8
+ - **Rows that cannot change are not encoded again.** An update pass encodes every row of a bound array to find the changed ones; in a 20,000-row list that was about 40% of a click. A deep-frozen row (`Object.freeze` on it and on everything inside it: plain objects and arrays, no getters, no own `toJSON`; image and document handles allowed: they are immutable, their wire form is fixed when made, so they need no freezing) that the bind function returns again is now encoded once more and then taken from a cache kept by the row object (weakly: a row the plugin drops goes with its entry), with its limit counts. Rows that are not frozen, or hold a mutable part, are encoded on every pass as before, so changes made in place are still sent. To benefit, keep an unchanged row as the same frozen object and make a new one when it changes ([Arrays: only the changed rows are sent](docs/explanation/ui.md#arrays-only-the-changed-rows-are-sent)). Measured in the test host with the playlist at 20,000 rows memoized that way: a click from about 24 ms to about 14 ms to the rows write.
9
+ - _Commands, menus and key bindings_: the example of a disabled command letting its key go no longer uses Escape on Leave Fullscreen.
10
+
11
+ ## 1.5.46
12
+
13
+ **Who must change: nobody.** No changes to the TypeScript API, `apiVersion` stays `vestra/1`, no new capability. The new limits and the split writes take effect with player 0.6.0 (large lists P3); with older players arrays are sent whole as before and the old limits apply there. **A plugin that binds a whole long list depends on them: set `"minPlayerVersion": "0.6.0"`.**
14
+
15
+ - **Whole lists.** `UI_VALUE_LIMITS` follow player 0.6.0: 100,000 rows per array (was 4,096), 1,048,576 values (was 16,384) and 32 MiB of text (was 1 MiB) in one property value. A plugin binds its whole list; a long list should be drawn with a Slint `ListView` (a `for` builds every row). The windowing example is replaced by a whole-list one.
16
+ - **Writes larger than one request are split.** The protocol line stays 1 MiB (`MESSAGE_LIMIT`). With a player that takes row patches:
17
+ - an array whose rows exceed 512 KiB (`CHUNK_BYTES`, internal) is sent as a whole write of its first rows and `insert` patches of the rest, each applying on the one before; every part but the last carries `more: true`, and the player holds them and writes the whole value at the last, so a list it already shows changes once (its scroll position and selection stay);
18
+ - a patch carrying more is sent as several patches, each applying on the one before; a diff may now plan up to 16,384 operations (at most 1,024 moves), sent 1,024 at most per patch, before the value goes whole;
19
+ - the initial properties of `panels.mount`, `ui.open` and `settings.page` carry at most 512 KiB of rows together: the first rows show at once and the rest follows as patches on the first update;
20
+ - only a property's top-level array is split, and not one whose rows hold Markdown documents (sent whole as before: the player merges documents in place); any other value, and each row, must fit in one request. A row of more than 512 KiB, or a value over the limits, is refused with `limit` before any part is sent;
21
+ - when the view closes or shows its initial properties again while a write goes in parts, the rest is dropped without an error and the view's next update sends what it shows; any other failure part-way leaves no base, so the next write of the property is whole (in parts again).
22
+ - **`vestra-plugin check` warns** (a reminder, never a failure) where a plugin's .slint file puts a `for` over an array it binds (an `in` or `in-out` array property) outside a `ListView`: such a `for` builds every row up front.
23
+ - **`null` for an image in a row** shows no picture (player 0.6.0), so rows without a thumbnail need no placeholder image.
24
+ - **`TestHost`** enforces the new limits and takes the split writes like the player (each line within `MESSAGE_LIMIT`, parts with `more` held until the last); `props` hold whole values as before.
25
+
3
26
  ## 1.5.45
4
27
 
5
28
  **Who must change: nobody.** No changes to the TypeScript API, `apiVersion` stays `vestra/1`, no new capability, no player version needed. Only when the SDK re-evaluates bindings changes; what a plugin's views and commands show stays the same. One case can show in tests: a plugin that changes state outside a handler (a timer, a promise continuation after the handler returned, a child process) without calling `plugin.update()`, and a test that relied on `host.setSource(...)` to refresh it. Have the plugin call `plugin.update()` after such a change (as the documentation always said), or wait `await host.settle(1000)` in the test; `settle()` does not wait for the once-a-second full pass.
@@ -357,7 +357,10 @@ async function check(target, report = null) {
357
357
  }
358
358
  say(`${target.name}: capabilities declared for every API use`);
359
359
  if (report) return;
360
- for (const line of await bottomBarWarnings(target))
360
+ for (const line of [
361
+ ...(await bottomBarWarnings(target)),
362
+ ...(await forWarnings(target)),
363
+ ])
361
364
  console.log(`${target.name}: warning: ${line}`);
362
365
  // Translation warnings never fail the check.
363
366
  if (present(join(target.directory, "locales"))) {
@@ -502,6 +505,61 @@ async function bottomBarWarnings(target) {
502
505
  return warnings;
503
506
  }
504
507
 
508
+ /**
509
+ * A `for` over an array the plugin binds (an `in` or `in-out` property of
510
+ * array type of a component or global in the same file), outside a
511
+ * `ListView`, in the plugin's .slint files: a reminder, not a failure
512
+ * (SDK 1.5.46). Such a `for` builds every row of the array up front; since
513
+ * player 0.6.0 a plugin may bind a whole long list, which belongs in a
514
+ * `ListView` (it builds only the rows on screen). A text scan: imported
515
+ * components and `@children` are not followed.
516
+ */
517
+ async function forWarnings(target) {
518
+ const { slint } = await sourceFiles(target);
519
+ const warnings = [];
520
+ for (const file of slint) {
521
+ const text = slintCode(
522
+ await readFile(join(target.directory, file), "utf8"),
523
+ );
524
+ const arrays = new Set(
525
+ [
526
+ ...text.matchAll(
527
+ /\b(?:in|in-out)\s+property\s*<\s*\[[^\]]*\]\s*>\s*([A-Za-z_][\w-]*)/g,
528
+ ),
529
+ ].map((match) => match[1]),
530
+ );
531
+ if (!arrays.size) continue;
532
+ // The elements open at each point: the name before each `{`.
533
+ const open = [];
534
+ let line = 1;
535
+ for (let i = 0; i < text.length; i++) {
536
+ const c = text[i];
537
+ if (c === "\n") line++;
538
+ else if (c === "{") {
539
+ const name = /([A-Za-z_][\w-]*)\s*$/.exec(
540
+ text.slice(Math.max(0, i - 80), i),
541
+ );
542
+ open.push(name ? name[1] : "");
543
+ } else if (c === "}") open.pop();
544
+ else if (
545
+ text.startsWith("for", i) &&
546
+ !/[\w-]/.test(text[i - 1] ?? " ") &&
547
+ /\s/.test(text[i + 3] ?? "")
548
+ ) {
549
+ const loop =
550
+ /^for\s+[\w-]+(?:\s*\[\s*[\w-]+\s*\])?\s+in\s+([\w.-]+)/.exec(
551
+ text.slice(i, i + 200),
552
+ );
553
+ const array = loop?.[1].split(".").at(-1);
554
+ if (array && arrays.has(array) && !open.includes("ListView"))
555
+ warnings.push(
556
+ `${file.replaceAll("\\", "/")}:${line}: \`for\` over the bound array \`${array}\` outside a ListView builds every row; a long list should use a ListView (docs/reference/limits.md)`,
557
+ );
558
+ }
559
+ }
560
+ }
561
+ return warnings;
562
+ }
505
563
  /** The plugin's own production .ts / .mts and .slint files. */
506
564
  async function sourceFiles(target) {
507
565
  const all = await files(target.directory);
package/dist/commands.js CHANGED
@@ -265,8 +265,8 @@ vestra.playback | copy-name | Copy to Clipboard ▸ File Name
265
265
  vestra.playback | copy-folder | Copy to Clipboard ▸ Folder Path
266
266
  vestra.playback | copy-path | Copy to Clipboard ▸ Full Path
267
267
  host | play-pause | Playback ▸ Play/Pause | Space, Mouse:LeftClick
268
- host | previous | Playback ▸ Previous | PageUp, Mouse:X1
269
- host | next | Playback ▸ Next | PageDown, Mouse:X2
268
+ host | previous | Playback ▸ Previous | PageUp, Mouse:X2
269
+ host | next | Playback ▸ Next | PageDown, Mouse:X1
270
270
  host | frame-back | Playback ▸ Seek ▸ Previous Frame | D
271
271
  host | frame-forward | Playback ▸ Seek ▸ Next Frame | F
272
272
  vestra.playback | keyframe-back | Playback ▸ Seek ▸ Previous Keyframe | Ctrl+Shift+ArrowLeft
@@ -492,7 +492,7 @@ vestra.audio | audio.normalize | Audio ▸ Processing ▸ Normalize | Shift+N
492
492
  vestra.audio | audio.clarity | Audio ▸ Processing ▸ Crystalizer | Shift+C
493
493
  vestra.audio | audio.reset | Audio ▸ Reset
494
494
  vestra.visualizer | audio-cover | Audio ▸ Show Album Cover for Audio Only
495
- host | fullscreen | Toggle Fullscreen | Enter, Alt+Enter, F11
495
+ host | fullscreen | Toggle Fullscreen | Escape
496
496
  vestra.window | pip | Screen ▸ Picture-in-Picture
497
497
  vestra.window | mini | Screen ▸ Mini Size | \`
498
498
  vestra.window | size.0.5 | Screen ▸ 0.5× | 1
@@ -536,7 +536,8 @@ vestra.preferences | language.fr | Settings ▸ Language ▸ Français
536
536
  vestra.preferences | language.es | Settings ▸ Language ▸ Español
537
537
  vestra.info | info.about | About Vestra Player… | F1
538
538
  host | quit | Quit | Cmd+Q
539
- host | exit-fullscreen | (Leave Fullscreen) | Escape
539
+ host | enter-fullscreen | (Enter Fullscreen)
540
+ host | exit-fullscreen | (Leave Fullscreen)
540
541
  host | sidebar | (Toggle Sidebar)
541
542
  vestra.info | info.brief | (Brief Info) | Shift+Tab
542
543
  vestra.playlist | playlist.play | (Play) | Enter, P | panel
@@ -66,8 +66,11 @@ export declare const BINDING_INPUTS: Readonly<{
66
66
  */
67
67
  readonly named: readonly ["Space", "Enter", "Tab", "Escape", "Backspace", "Delete", "Insert", "Home", "End", "PageUp", "PageDown", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Plus", ...string[], "NumpadPlus", "NumpadMinus", "NumpadMultiply", "NumpadDivide", "NumpadEnter", "NumpadDecimal"];
68
68
  /**
69
- * Mouse inputs on the video area. `:LeftHalf` and `:RightHalf` tell the
70
- * halves of the video apart for a double click.
69
+ * Mouse inputs on the video area; the side buttons `Mouse:X1` (back) and
70
+ * `Mouse:X2` (forward) anywhere in the main window or the floating sidebar
71
+ * window, except while a menu is open (player 0.6.0).
72
+ * `:LeftHalf` and `:RightHalf` tell the halves of the video apart for a
73
+ * double click.
71
74
  */
72
75
  readonly mouse: readonly ["Mouse:LeftClick", "Mouse:LeftDoubleClick", "Mouse:LeftDoubleClick:LeftHalf", "Mouse:LeftDoubleClick:RightHalf", "Mouse:MiddleClick", "Mouse:X1", "Mouse:X2", "Mouse:WheelUp", "Mouse:WheelDown", "Mouse:WheelLeft", "Mouse:WheelRight"];
73
76
  }>;
@@ -97,8 +97,11 @@ export const BINDING_INPUTS = Object.freeze({
97
97
  "NumpadDecimal",
98
98
  ],
99
99
  /**
100
- * Mouse inputs on the video area. `:LeftHalf` and `:RightHalf` tell the
101
- * halves of the video apart for a double click.
100
+ * Mouse inputs on the video area; the side buttons `Mouse:X1` (back) and
101
+ * `Mouse:X2` (forward) anywhere in the main window or the floating sidebar
102
+ * window, except while a menu is open (player 0.6.0).
103
+ * `:LeftHalf` and `:RightHalf` tell the halves of the video apart for a
104
+ * double click.
102
105
  */
103
106
  mouse: [
104
107
  "Mouse:LeftClick",
@@ -2,6 +2,12 @@
2
2
  export declare const PATCH_FEATURE = "ui.patch";
3
3
  /** The most operations one patch carries (the player's `MAX_PATCH_OPS`). */
4
4
  export declare const PATCH_OPS = 1024;
5
+ /**
6
+ * The most operations one diff plans (SDK 1.5.46): its patch is sent as
7
+ * several of at most {@link PATCH_OPS} each (`chunkOps`); more is sent whole
8
+ * (in parts, which the player writes at once).
9
+ */
10
+ export declare const DIFF_OPS: number;
5
11
  /**
6
12
  * Fields that identify a row, in the order they are tried. A field is used
7
13
  * when every row has it, as a string or a number, and no two rows share it.
@@ -61,7 +67,8 @@ export declare function countRow(row: unknown): RowCount;
61
67
  export declare function withinLimits(counts: readonly RowCount[]): boolean;
62
68
  /**
63
69
  * The operations that turn the old rows into the new ones (`null`: more
64
- * than {@link PATCH_OPS} would be needed), and for each new row the old row
70
+ * than {@link DIFF_OPS} would be needed, or more than {@link PATCH_OPS}
71
+ * moves), and for each new row the old row
65
72
  * whose JSON it keeps (`-1`: a new or changed row). Keyed when both arrays
66
73
  * have keys from the same field; otherwise by index.
67
74
  */
@@ -99,3 +106,47 @@ export declare function applyOps<T>(rows: T[], ops: readonly ({
99
106
  count: number;
100
107
  to: number;
101
108
  })[]): T[];
109
+ /**
110
+ * The bytes of rows one write carries at most (SDK 1.5.46, player 0.6.0):
111
+ * half the 1 MiB protocol line, which leaves room for the request around
112
+ * them. A larger whole write is sent as its first rows and `insert`
113
+ * patches of the rest; a larger patch as several patches, each applying on
114
+ * the one before (the player's doc/architecture/large-lists.md §4).
115
+ */
116
+ export declare const CHUNK_BYTES: number;
117
+ /** The bytes a row adds to a write (its JSON and a separator). */
118
+ export declare const rowBytes: (row: unknown) => number;
119
+ /**
120
+ * How many of the first rows (by their sizes, `rowBytes`) fit in `budget`
121
+ * bytes; at least one when there are rows (a row over the budget alone is
122
+ * left to the player's limits).
123
+ */
124
+ export declare function prefixWithin(sizes: readonly number[], budget: number): number;
125
+ /** A wire operation (`set` and `insert` carry rows). */
126
+ export type WireOp = {
127
+ op: "set";
128
+ at: number;
129
+ row: unknown;
130
+ } | {
131
+ op: "insert";
132
+ at: number;
133
+ rows: unknown[];
134
+ } | {
135
+ op: "remove";
136
+ at: number;
137
+ count: number;
138
+ } | {
139
+ op: "move";
140
+ from: number;
141
+ count: number;
142
+ to: number;
143
+ };
144
+ /**
145
+ * `ops` as consecutive patches of at most {@link CHUNK_BYTES} of rows and
146
+ * {@link PATCH_OPS} operations each, in order: applying them one after the
147
+ * other gives what `ops` give. An `insert` of more rows than fit is split
148
+ * into inserts of consecutive runs.
149
+ */
150
+ export declare function chunkOps<T extends WireOp>(ops: readonly T[]): T[][];
151
+ /** The bytes a row already in JSON adds to a write (and a separator). */
152
+ export declare const jsonBytes: (json: string) => number;
@@ -9,6 +9,12 @@ import { UI_VALUE_LIMITS } from "./values.js";
9
9
  export const PATCH_FEATURE = "ui.patch";
10
10
  /** The most operations one patch carries (the player's `MAX_PATCH_OPS`). */
11
11
  export const PATCH_OPS = 1024;
12
+ /**
13
+ * The most operations one diff plans (SDK 1.5.46): its patch is sent as
14
+ * several of at most {@link PATCH_OPS} each (`chunkOps`); more is sent whole
15
+ * (in parts, which the player writes at once).
16
+ */
17
+ export const DIFF_OPS = 16 * PATCH_OPS;
12
18
  /**
13
19
  * Fields that identify a row, in the order they are tried. A field is used
14
20
  * when every row has it, as a string or a number, and no two rows share it.
@@ -159,7 +165,8 @@ function increasing(values) {
159
165
  }
160
166
  /**
161
167
  * The operations that turn the old rows into the new ones (`null`: more
162
- * than {@link PATCH_OPS} would be needed), and for each new row the old row
168
+ * than {@link DIFF_OPS} would be needed, or more than {@link PATCH_OPS}
169
+ * moves), and for each new row the old row
163
170
  * whose JSON it keeps (`-1`: a new or changed row). Keyed when both arrays
164
171
  * have keys from the same field; otherwise by index.
165
172
  */
@@ -168,7 +175,7 @@ export function diff(old, next) {
168
175
  const ops = old.keys && next.keys
169
176
  ? keyed(old.enc, old.keys, next.enc, next.keys, from)
170
177
  : byIndex(old.enc, next.enc, from);
171
- return ops && ops.length <= PATCH_OPS ? { ops, from } : null;
178
+ return ops && ops.length <= DIFF_OPS ? { ops, from } : null;
172
179
  }
173
180
  function byIndex(old, next, from) {
174
181
  const min = Math.min(old.length, next.length);
@@ -236,7 +243,7 @@ function keyed(oldEnc, oldKeys, newEnc, newKeys, from) {
236
243
  }
237
244
  else
238
245
  ops.push({ op: "remove", at: i, count: 1 });
239
- if (ops.length > PATCH_OPS)
246
+ if (ops.length > DIFF_OPS)
240
247
  return null;
241
248
  }
242
249
  for (let i = p; i < oldEnd; i++)
@@ -248,7 +255,10 @@ function keyed(oldEnc, oldKeys, newEnc, newKeys, from) {
248
255
  // positions goes right after the kept row before it in the new order.
249
256
  const positions = kept.map((key) => target.get(key));
250
257
  const stay = increasing(positions);
251
- if (kept.length - stay.size + ops.length > PATCH_OPS)
258
+ // Moves cost a search each: at most PATCH_OPS of them.
259
+ if (kept.length - stay.size > PATCH_OPS)
260
+ return null;
261
+ if (kept.length - stay.size + ops.length > DIFF_OPS)
252
262
  return null;
253
263
  const stays = new Set();
254
264
  for (const index of stay)
@@ -281,13 +291,13 @@ function keyed(oldEnc, oldKeys, newEnc, newKeys, from) {
281
291
  last.rows.push(j);
282
292
  else
283
293
  ops.push({ op: "insert", at: j, rows: [j] });
284
- if (ops.length > PATCH_OPS)
294
+ if (ops.length > DIFF_OPS)
285
295
  return null;
286
296
  }
287
297
  sets.sort((a, b) => a.at - b.at);
288
298
  for (const set of sets) {
289
299
  ops.push(set);
290
- if (ops.length > PATCH_OPS)
300
+ if (ops.length > DIFF_OPS)
291
301
  return null;
292
302
  }
293
303
  return ops;
@@ -335,3 +345,91 @@ export function applyOps(rows, ops) {
335
345
  }
336
346
  return out;
337
347
  }
348
+ /**
349
+ * The bytes of rows one write carries at most (SDK 1.5.46, player 0.6.0):
350
+ * half the 1 MiB protocol line, which leaves room for the request around
351
+ * them. A larger whole write is sent as its first rows and `insert`
352
+ * patches of the rest; a larger patch as several patches, each applying on
353
+ * the one before (the player's doc/architecture/large-lists.md §4).
354
+ */
355
+ export const CHUNK_BYTES = 512 * 1024;
356
+ /** About what an operation adds to a request besides its rows. */
357
+ const OP_BYTES = 64;
358
+ /** UTF-8 bytes of `text` (JSON of a row), without encoding it. */
359
+ function utf8Bytes(text) {
360
+ let bytes = text.length;
361
+ for (let i = 0; i < text.length; i++) {
362
+ const code = text.charCodeAt(i);
363
+ if (code >= 0x80)
364
+ bytes += code >= 0x800 && (code < 0xd800 || code > 0xdfff) ? 2 : 1;
365
+ }
366
+ return bytes;
367
+ }
368
+ /** The bytes a row adds to a write (its JSON and a separator). */
369
+ export const rowBytes = (row) => utf8Bytes(JSON.stringify(row) ?? "null") + 1;
370
+ /**
371
+ * How many of the first rows (by their sizes, `rowBytes`) fit in `budget`
372
+ * bytes; at least one when there are rows (a row over the budget alone is
373
+ * left to the player's limits).
374
+ */
375
+ export function prefixWithin(sizes, budget) {
376
+ let used = 0;
377
+ for (let i = 0; i < sizes.length; i++) {
378
+ used += sizes[i];
379
+ if (used > budget)
380
+ return Math.max(i, 1);
381
+ }
382
+ return sizes.length;
383
+ }
384
+ /**
385
+ * `ops` as consecutive patches of at most {@link CHUNK_BYTES} of rows and
386
+ * {@link PATCH_OPS} operations each, in order: applying them one after the
387
+ * other gives what `ops` give. An `insert` of more rows than fit is split
388
+ * into inserts of consecutive runs.
389
+ */
390
+ export function chunkOps(ops) {
391
+ const groups = [];
392
+ let group = [];
393
+ let bytes = 0;
394
+ const push = (op, size) => {
395
+ if (group.length &&
396
+ (bytes + size > CHUNK_BYTES || group.length >= PATCH_OPS)) {
397
+ groups.push(group);
398
+ group = [];
399
+ bytes = 0;
400
+ }
401
+ group.push(op);
402
+ bytes += size;
403
+ };
404
+ for (const op of ops) {
405
+ if (op.op !== "insert") {
406
+ push(op, OP_BYTES + (op.op === "set" ? rowBytes(op.row) : 0));
407
+ continue;
408
+ }
409
+ const sizes = op.rows.map(rowBytes);
410
+ let start = 0;
411
+ while (start < sizes.length) {
412
+ const room = Math.max(CHUNK_BYTES - bytes - OP_BYTES, 0);
413
+ let count = prefixWithin(sizes.slice(start), room);
414
+ // Nothing fits beside this group's rows: the run starts a new patch.
415
+ if (room === 0 ||
416
+ sizes.slice(start, start + count).reduce((a, b) => a + b, 0) > room) {
417
+ if (group.length) {
418
+ groups.push(group);
419
+ group = [];
420
+ bytes = 0;
421
+ }
422
+ count = prefixWithin(sizes.slice(start), CHUNK_BYTES - OP_BYTES);
423
+ }
424
+ const rows = op.rows.slice(start, start + count);
425
+ const size = OP_BYTES + sizes.slice(start, start + count).reduce((a, b) => a + b, 0);
426
+ push({ op: "insert", at: op.at + start, rows }, size);
427
+ start += count;
428
+ }
429
+ }
430
+ if (group.length)
431
+ groups.push(group);
432
+ return groups;
433
+ }
434
+ /** The bytes a row already in JSON adds to a write (and a separator). */
435
+ export const jsonBytes = (json) => utf8Bytes(json) + 1;
@@ -935,10 +935,15 @@ export class TestHost {
935
935
  * view and property (none: 0, the initial properties).
936
936
  */
937
937
  #writes = new Map();
938
+ /** The parts of a whole value held until its last part, by view and property. */
939
+ #parts = new Map();
938
940
  #resetWrites(view) {
939
941
  for (const slot of [...this.#writes.keys()])
940
942
  if (slot.startsWith(`${view}\u0000`))
941
943
  this.#writes.delete(slot);
944
+ for (const slot of [...this.#parts.keys()])
945
+ if (slot.startsWith(`${view}\u0000`))
946
+ this.#parts.delete(slot);
942
947
  }
943
948
  /**
944
949
  * A property write as the player takes it, returning the value the
@@ -950,6 +955,44 @@ export class TestHost {
950
955
  #write(view, property, p, current) {
951
956
  const slot = `${view}\u0000${property}`;
952
957
  const patch = p.patch;
958
+ // A whole array sent in parts (player 0.6.0, SDK 1.5.46): every part but
959
+ // the last carries `more`; the player holds them and writes the whole
960
+ // value at the last, as here (`undefined`: held, nothing shown yet).
961
+ const more = p.more === true;
962
+ const held = this.#parts.get(slot);
963
+ this.#parts.delete(slot);
964
+ if (more && patch === undefined) {
965
+ const rows = this.#shownValues(p.value);
966
+ if (!Array.isArray(rows))
967
+ throw new HostError("invalid", "a value sent in parts is an array");
968
+ noMarkdownParts(rows);
969
+ this.#checkValues({ [property]: rows });
970
+ this.#parts.set(slot, {
971
+ seq: typeof p.seq === "number" ? p.seq : 0,
972
+ rows,
973
+ });
974
+ return undefined;
975
+ }
976
+ if (patch !== undefined && held && patch.base === held.seq) {
977
+ const ops = patchOps(patch.ops, held.rows.length);
978
+ const resolved = ops.map((op) => op.op === "set"
979
+ ? { ...op, row: this.#shownValues(op.row) }
980
+ : op.op === "insert"
981
+ ? { ...op, rows: op.rows.map((row) => this.#shownValues(row)) }
982
+ : op);
983
+ noMarkdownParts(resolved.flatMap((op) => op.op === "set" ? [op.row] : op.op === "insert" ? op.rows : []));
984
+ const rows = applyOps(held.rows, resolved);
985
+ this.#checkValues({ [property]: rows });
986
+ if (more) {
987
+ this.#parts.set(slot, { seq: patch.seq, rows });
988
+ return undefined;
989
+ }
990
+ this.#seeImages([rows]);
991
+ this.#writes.set(slot, patch.seq);
992
+ return rows;
993
+ }
994
+ if (patch !== undefined && more)
995
+ throw new HostError("stale", "the property changed since the write this patch applies on; send the whole value");
953
996
  if (patch === undefined) {
954
997
  const value = this.#shownValues(p.value);
955
998
  this.#checkValues({ [property]: value });
@@ -2211,7 +2254,7 @@ export class TestHost {
2211
2254
  ["Playback", "Previous"],
2212
2255
  202,
2213
2256
  "domain",
2214
- ["PageUp", "Mouse:X1"],
2257
+ ["PageUp", "Mouse:X2"],
2215
2258
  false,
2216
2259
  ],
2217
2260
  [
@@ -2219,7 +2262,7 @@ export class TestHost {
2219
2262
  ["Playback", "Next"],
2220
2263
  203,
2221
2264
  "domain",
2222
- ["PageDown", "Mouse:X2"],
2265
+ ["PageDown", "Mouse:X1"],
2223
2266
  false,
2224
2267
  ],
2225
2268
  [
@@ -3899,7 +3942,10 @@ export class TestHost {
3899
3942
  if (!window)
3900
3943
  throw new HostError("missing", "view not found");
3901
3944
  const property = String(p.property);
3902
- window.props[property] = this.#write(`w:${String(p.id)}`, property, p, window.props[property]);
3945
+ const next = this.#write(`w:${String(p.id)}`, property, p, window.props[property]);
3946
+ // A part of a value sent in parts is held until the last (player 0.6.0).
3947
+ if (next !== undefined)
3948
+ window.props[property] = next;
3903
3949
  },
3904
3950
  "ui.show": (p) => {
3905
3951
  const window = this.windows.get(String(p.id));
@@ -4202,7 +4248,7 @@ export class TestHost {
4202
4248
  throw new HostError("invalid", "seconds must be 0.1..3600 in tenths");
4203
4249
  seconds = p.seconds;
4204
4250
  }
4205
- // SDK 1.5.21: the main window's Toggle Fullscreen, like Enter: in when
4251
+ // SDK 1.5.21: the main window's Toggle Fullscreen, like Escape: in when
4206
4252
  // windowed, out when fullscreen, then `window.state`.
4207
4253
  if (p.action === "fullscreen") {
4208
4254
  this.coreRuns.push("fullscreen");
@@ -4260,7 +4306,10 @@ export class TestHost {
4260
4306
  "ui.setMount": (p) => {
4261
4307
  const panel = this.mounted();
4262
4308
  const property = String(p.property);
4263
- panel.props[property] = this.#write("$mount", property, p, panel.props[property]);
4309
+ const next = this.#write("$mount", property, p, panel.props[property]);
4310
+ // A part of a value sent in parts is held until the last (player 0.6.0).
4311
+ if (next !== undefined)
4312
+ panel.props[property] = next;
4264
4313
  },
4265
4314
  "timeline.marks": () => ({ marks: this.timeline.marks }),
4266
4315
  "drops.provide": (p) => {
@@ -4323,7 +4372,10 @@ export class TestHost {
4323
4372
  if (!page)
4324
4373
  throw new HostError("missing", "page not registered");
4325
4374
  const property = String(p.property);
4326
- page.props[property] = this.#write(`$settings:${String(p.id)}`, property, p, page.props[property]);
4375
+ const next = this.#write(`$settings:${String(p.id)}`, property, p, page.props[property]);
4376
+ // A part of a value sent in parts is held until the last (player 0.6.0).
4377
+ if (next !== undefined)
4378
+ page.props[property] = next;
4327
4379
  },
4328
4380
  "settings.unregister": (p) => {
4329
4381
  this.settings.pages.delete(String(p.id));
@@ -5352,6 +5404,11 @@ export async function startPlugin(activate, options = {}) {
5352
5404
  * before; at most {@link PATCH_OPS}; the result at most
5353
5405
  * `UI_VALUE_LIMITS.arrayRows` rows.
5354
5406
  */
5407
+ /** The player takes no Markdown documents in parts of a value (`invalid`). */
5408
+ function noMarkdownParts(rows) {
5409
+ if (JSON.stringify(rows).includes('"$vestraMarkdown"'))
5410
+ throw new HostError("invalid", "a value sent in parts holds no Markdown documents");
5411
+ }
5355
5412
  function patchOps(ops, length) {
5356
5413
  if (!Array.isArray(ops))
5357
5414
  throw new HostError("invalid", "patch.ops requires an array");
@@ -999,9 +999,12 @@ export interface CommandOptions {
999
999
  * `Home`, `End`, `PageUp`, `PageDown`, `ArrowLeft`/`Right`/`Up`/`Down`,
1000
1000
  * `Plus`, any other single symbol as typed (`">"`, not `"Shift+."`),
1001
1001
  * `Numpad0`–`Numpad9`, `NumpadPlus`, `NumpadMinus`, `NumpadMultiply`,
1002
- * `NumpadDivide`, `NumpadEnter`, `NumpadDecimal`, and the video-area mouse
1002
+ * `NumpadDivide`, `NumpadEnter`, `NumpadDecimal`, the video-area mouse
1003
1003
  * inputs `Mouse:LeftClick`, `Mouse:LeftDoubleClick[:LeftHalf|:RightHalf]`,
1004
- * `Mouse:MiddleClick`, `Mouse:X1`, `Mouse:X2`, `Mouse:WheelUp`/`Down`/`Left`/`Right`.
1004
+ * `Mouse:MiddleClick`, `Mouse:WheelUp`/`Down`/`Left`/`Right`, and the side
1005
+ * buttons `Mouse:X1` (back), `Mouse:X2` (forward), from 0.6.0 on anywhere
1006
+ * in the main window or the floating sidebar window, except while a menu
1007
+ * is open.
1005
1008
  *
1006
1009
  * Platforms: on macOS `Ctrl+X` fires for both ⌃X and ⌘X while `Cmd+X`
1007
1010
  * means ⌘X only; on Windows `Cmd` is the Windows logo key, so write
@@ -1384,20 +1387,23 @@ export interface MarkdownOptions {
1384
1387
  * thumbnails.
1385
1388
  *
1386
1389
  * Limits per value (`UI_VALUE_LIMITS` in `@vestra-player/sdk/testing`,
1387
- * player 0.4.3; older players: 4096 values and 1024 rows): nesting depth 8,
1388
- * 16384 values in total (every array, object, string, number, boolean and
1389
- * null counts, the value itself included; a document or an image is one), 4096 rows
1390
- * per array, 64 KiB per string or field name and 1 MiB of text in total,
1391
- * field names and the text of its Markdown documents included (256 KB per
1392
- * document). A list of rows with 8 fields is 9 values per row, so about
1393
- * 1800 rows fit. The whole request must also fit the player's 1 MiB
1394
- * message. A value over a limit is refused with code `limit` and the
1395
- * property keeps what it showed (see {@link View.set}); `TestHost` refuses
1396
- * it the same way.
1397
- *
1398
- * The limits bound the work of one write, not the size of your data: for
1399
- * a list that can grow without bound, send the rows near the view and the
1400
- * row count, and move that window as the user scrolls.
1390
+ * player 0.6.0; players 0.4.3 to 0.5: 16384 values, 4096 rows, 1 MiB of
1391
+ * text): nesting depth 8, 1,048,576 values in total (every array, object,
1392
+ * string, number, boolean and null counts, the value itself included; a
1393
+ * document or an image is one), 100,000 rows per array, 64 KiB per string
1394
+ * or field name and 32 MiB of text in total, field names and the text of
1395
+ * its Markdown documents included (256 KB per document). A list of rows
1396
+ * with 13 fields is 14 values per row, so 50,000 such rows fit. An array
1397
+ * property larger than one 1 MiB request is sent in parts (SDK 1.5.46, a
1398
+ * player that takes row patches); only the top-level array is split, so
1399
+ * any other value, and each row, must fit in one request (a row of more
1400
+ * than 512 KiB is refused with `limit`). A value over a limit is refused
1401
+ * with code
1402
+ * `limit` and the property keeps what it showed (see {@link View.set});
1403
+ * `TestHost` refuses it the same way.
1404
+ *
1405
+ * Bind a whole list and draw it with a Slint `ListView`, which builds only
1406
+ * the rows on screen; the SDK sends what changed (row patches).
1401
1407
  *
1402
1408
  * @since 1.0.0
1403
1409
  * @category UI
@@ -6058,13 +6064,13 @@ export interface Player {
6058
6064
  * | --- | --- | --- |
6059
6065
  * | `stop` | F4 | close the source |
6060
6066
  * | `play-pause` | Space, a left click on the video (player 0.5.2) | pause / resume, replay after the end |
6061
- * | `previous` / `next` | PageUp, Mouse:X1 / PageDown, Mouse:X2 | ask the sequence provider |
6067
+ * | `previous` / `next` | PageUp, Mouse:X2 / PageDown, Mouse:X1 (from 0.6.0; up to 0.5.x X1 was previous and X2 next) | ask the sequence provider |
6062
6068
  * | `frame-back` / `frame-forward` | D / F | `player.step("frame", …)` |
6063
6069
  * | `seek-backward` / `seek-forward` | ← / → | relative seek by `setStepSeek` seconds (or `host.run`'s `seconds`), exact or to keyframes as `setStepSeek` says |
6064
6070
  * | `rate-normal`, `rate-down`, `rate-up` | Z, X, C | 1×, ±0.1 within 0.25–4 |
6065
6071
  * | `volume-up` / `volume-down` | ↑ / ↓, wheel | ±5 (`volume-up` stops at 100) |
6066
6072
  * | `mute` | M | toggle mute |
6067
- * | `fullscreen` | Enter, Alt+Enter, F11 | toggle the main window's fullscreen: enter it when windowed, leave it when fullscreen, as the Toggle Fullscreen command does (SDK 1.5.21, player 0.5.2; `unavailable` when the player has no window) |
6073
+ * | `fullscreen` | Escape (from 0.6.0; up to 0.5.x Enter, Alt+Enter, F11) | toggle the main window's fullscreen: enter it when windowed, leave it when fullscreen, as the Toggle Fullscreen command does (SDK 1.5.21, player 0.5.2; `unavailable` when the player has no window) |
6068
6074
  *
6069
6075
  * In the binding overlay their keys are `host/<action>`. `fullscreen`
6070
6076
  * changes no playback state: read the result from `window.state.fullscreen`
@@ -32,9 +32,15 @@ export declare abstract class ViewBase implements View {
32
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
- * property keeps its default) and reported, like a failed write.
35
+ * property keeps its default) and reported, like a failed write. With a
36
+ * player that takes row patches, the arrays of one request carry at most
37
+ * {@link CHUNK_BYTES} of rows together (SDK 1.5.46): an array over that
38
+ * goes with its first rows, and the first flush sends the rest as patches.
39
+ * An array over the value limits is refused here, as the player would
40
+ * refuse it whole: left out and reported (a panel, a settings page), or,
41
+ * with `refuse` (a window's `show`), thrown.
36
42
  */
37
- protected wired(values: Record<string, PropValue>): Record<string, Json>;
43
+ protected wired(values: Record<string, PropValue>, refuse?: boolean): Record<string, Json>;
38
44
  on<A extends Json[]>(callback: string, handler: (...args: A) => unknown): Disposable;
39
45
  set(props: Props): void;
40
46
  bind(props: () => Props): Disposable;