@vestra-player/sdk 1.5.27 → 1.5.29

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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.29
4
+
5
+ **Who must change: nobody.** Additions only, `apiVersion` stays `vestra/1`. A plugin that does not use `button` is unaffected. A plugin that uses it on a player before 0.5.10 keeps working without a button: the player builds the `plugin` object with its own SDK copy, which does not send the field, so the command and its menu entry register as before (declare `"minPlayerVersion": "0.5.10"` when the button matters). A plugin that mounts its own bottom bar should place `BarButtons` (below); until it does, `vestra-plugin check` warns and the player logs the buttons it hides.
6
+
7
+ - **Commands can have a bottom-bar button** (player 0.5.10, user-approved design of 2026-10-27): `button: { icon, order }` on `commands.add` (`CommandOptions.button`, `CommandButton`, `BarButtonIcon`). The icon is a name of the player's icon set or an image from `plugin.ui.image` (drawn in the icon colour); `order` 0–999 places it left to right. The button needs a menu `path`; its tooltip is the command's title (without a trailing "…") and first key; disabled or busy commands show a disabled button with the reason; a click runs the command like its menu item. One button per plugin, eight in the player (`BAR_BUTTON_LIMITS`; past eight the highest orders stay and the lowest go first, ties: the smaller plugin id, then the earlier declaration); a button past them is left out (the command and its menu entry stay) and the player logs it with `limit`. `add` throws `invalid` for an unknown icon name (`BAR_BUTTON_ICONS`), an order outside 0–999, a released image or a button without a path.
8
+ - **`@vestra/bar-buttons.slint`**: `BarButtons`, the strip of command buttons a bottom bar places at its right end with `room`, the width it can spare. It shows the cells that fit (the player's sidebar toggle, **Toggle Sidebar** `host/sidebar`, rightmost and last to go; cells go from the strip's outer (left) end inward, so of the plugins' buttons the lowest order goes first; `order` is position only) and is as wide as the cells it shows (`first-shown`, out, is the index of the leftmost plugin button shown). The player fills it in every window it composes and runs the clicks. Slint library copy synced; the icon set gains `store` (a shopping bag) and `ai` (a sparkle).
9
+ - **`TestHost.barButtons`**: the plugin's button as the player shows it (`command`, `icon`, `image`, `order`, `enabled`, `checked`, `tooltip`) with `click()`, which runs the command and rejects while it is disabled or once it is gone; empty after the command is disposed. The test host checks buttons as the player does.
10
+ - **`vestra-plugin check` warns** when a plugin mounts a bottom bar whose Slint file does not import `BarButtons` from `@vestra/bar-buttons.slint`, itself or through the plugin's own Slint files it imports (comments do not count; a warning, the check still passes).
11
+ - `BUILTIN_COMMANDS` gains the player's **Toggle Sidebar** (`host/sidebar`, outside the menu, no default key).
12
+ - Docs: *Commands* (concepts) gains *Bottom-bar buttons*; *Commands, menus and bindings* (reference) gains *Bottom-bar buttons*; *Interface guidelines* gains *Writing a bottom bar*; *Limits* gains *Bottom-bar buttons*; *Theme* gains *Bar buttons* and the two icons; *Windows, panels…* points to buttons for a one-click entry point. `llms.txt` and `llms-full.txt` regenerated.
13
+
14
+ ## 1.5.28
15
+
16
+ **Who must change: nobody.** No API changes, `apiVersion` stays `vestra/1`. Only the SDK's copy of what the player ships changes; a plugin that read `BUILTIN_COMMANDS` or `BUILTIN_SUBMENUS` to avoid a key or a menu path simply finds those entries free now.
17
+
18
+ - **`BUILTIN_COMMANDS` and `BUILTIN_SUBMENUS` follow the player** (its agreement test failed on them):
19
+ - Subtitles 0.4.6: `vestra.subtitles` **Move Up** and **Move Down** have no default keys any more (they were `Alt+ArrowUp` / `Alt+ArrowDown`, which pan the video).
20
+ - The NES emulator is no longer a built-in plugin (it moved to the plugin repository as VestraNES, under **Games ▸ VestraNES**): its three commands (`nes.open`, `nes.stop`, `nes.window`) and the built-in **Emulator** submenu are gone. The player has no built-in Games item.
21
+ - Docs: *Built-in commands* regenerated; *Menu guidelines* names a domain submenu **Games** for an emulator and no longer lists Emulator at the top level. `TestHost.otherResolvers` examples use a neutral id (`example.emulator`). `llms.txt` and `llms-full.txt` regenerated.
22
+
3
23
  ## 1.5.27
4
24
 
5
25
  **Who must change: nobody.** No API changes, `apiVersion` stays `vestra/1`, no player version needed. Plugins pick the fix up with the player's next SDK upgrade (the player builds the `plugin` object with its own copy); nothing in a plugin changes.
@@ -75,6 +75,7 @@ import {
75
75
  basename,
76
76
  dirname,
77
77
  extname,
78
+ isAbsolute,
78
79
  join,
79
80
  relative,
80
81
  resolve,
@@ -356,6 +357,8 @@ async function check(target, report = null) {
356
357
  }
357
358
  say(`${target.name}: capabilities declared for every API use`);
358
359
  if (report) return;
360
+ for (const line of await bottomBarWarnings(target))
361
+ console.log(`${target.name}: warning: ${line}`);
359
362
  // Translation warnings never fail the check.
360
363
  if (present(join(target.directory, "locales"))) {
361
364
  try {
@@ -399,6 +402,106 @@ async function capabilityFindings(target, config) {
399
402
  .map((finding) => ({ ...finding, file: resolve(finding.file) }));
400
403
  }
401
404
 
405
+ /** Slint source without its comments (string literals kept as they are). */
406
+ function slintCode(text) {
407
+ let out = "";
408
+ for (let i = 0; i < text.length;) {
409
+ if (text[i] === '"') {
410
+ let j = i + 1;
411
+ while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
412
+ out += text.slice(i, j + 1);
413
+ i = j + 1;
414
+ } else if (text.startsWith("//", i)) {
415
+ const next = text.indexOf("\n", i);
416
+ i = next < 0 ? text.length : next;
417
+ } else if (text.startsWith("/*", i)) {
418
+ const close = text.indexOf("*/", i + 2);
419
+ i = close < 0 ? text.length : close + 2;
420
+ out += " ";
421
+ } else out += text[i++];
422
+ }
423
+ return out;
424
+ }
425
+
426
+ /** Whether `path` lies inside `directory` (not a sibling sharing its prefix). */
427
+ function inside(directory, path) {
428
+ const rel = relative(resolve(directory), path);
429
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
430
+ }
431
+
432
+ /**
433
+ * Whether the Slint file at `file` (relative to the plugin directory)
434
+ * imports `BarButtons` from `@vestra/bar-buttons.slint`, itself or
435
+ * through the plugin's own Slint files (relative paths, any depth, inside
436
+ * the plugin directory). Local re-exports count as such files too:
437
+ * `export { Row } from "row.slint"` and `export * from "row.slint"` are
438
+ * followed, and `export { BarButtons } from "@vestra/bar-buttons.slint"` (or
439
+ * `export * from` it) in a file the bar imports counts as placing it.
440
+ * Comments do not count.
441
+ */
442
+ async function importsBarButtons(target, file, seen = new Set()) {
443
+ const path = resolve(target.directory, file);
444
+ if (seen.has(path) || !inside(target.directory, path)) return false;
445
+ seen.add(path);
446
+ const text = await readFile(path, "utf8").catch(() => null);
447
+ if (text === null) return false;
448
+ const code = slintCode(text);
449
+ // { names (or "*"), from }: imports, and re-exports with a source file.
450
+ const imports = [
451
+ ...code.matchAll(
452
+ /\b(?:import|export)\s*(?:(\*)\s*from\s*|\{([^}]*)\}\s*from\s*)?"([^"]+)"/g,
453
+ ),
454
+ ].map(([, star, names, from]) => ({ names: star ?? names ?? "", from }));
455
+ for (const { names, from } of imports)
456
+ if (
457
+ from === "@vestra/bar-buttons.slint" &&
458
+ (names === "*" ||
459
+ names
460
+ .split(",")
461
+ .some((name) => /^BarButtons(\s+as\s+[\w-]+)?$/.test(name.trim())))
462
+ )
463
+ return true;
464
+ for (const { from } of imports) {
465
+ if (
466
+ from.startsWith("@") ||
467
+ !from.endsWith(".slint") ||
468
+ from === "std-widgets.slint"
469
+ )
470
+ continue;
471
+ if (await importsBarButtons(target, join(dirname(file), from), seen))
472
+ return true;
473
+ }
474
+ return false;
475
+ }
476
+
477
+ /**
478
+ * A plugin that mounts a bottom bar (`panels.mount("bottom", "<file>")`)
479
+ * whose Slint file does not import `BarButtons` (`@vestra/bar-buttons.slint`),
480
+ * itself or through its local imports: while it is the bottom bar, no
481
+ * plugin's command button shows (SDK 1.5.29). The mount is read from the
482
+ * source text: a literal file name in the call.
483
+ */
484
+ async function bottomBarWarnings(target) {
485
+ const { typescript: sources } = await sourceFiles(target);
486
+ const files = new Set();
487
+ for (const file of sources) {
488
+ const text = await readFile(join(target.directory, file), "utf8");
489
+ for (const match of text.matchAll(
490
+ /\.mount\(\s*["'`]bottom["'`]\s*,\s*["'`]([^"'`$]+)["'`]/g,
491
+ ))
492
+ files.add(match[1]);
493
+ }
494
+ const warnings = [];
495
+ for (const file of files) {
496
+ if (!present(join(target.directory, file))) continue;
497
+ if (await importsBarButtons(target, file)) continue;
498
+ warnings.push(
499
+ `${file} is a bottom bar without BarButtons (import { BarButtons } from "@vestra/bar-buttons.slint"): while it is mounted, the command buttons of every plugin (and the player's sidebar toggle) show nowhere; see docs/guidelines/interface.md#writing-a-bottom-bar`,
500
+ );
501
+ }
502
+ return warnings;
503
+ }
504
+
402
505
  /** The plugin's own production .ts / .mts and .slint files. */
403
506
  async function sourceFiles(target) {
404
507
  const all = await files(target.directory);
@@ -1,5 +1,5 @@
1
1
  /** Finite candidate command registry. Host authenticates owner; no plugin supplies an owner id. */
2
- import type { Transport } from "./wire.ts";
2
+ import type { Json, Transport } from "./wire.ts";
3
3
  import type { ListJson, TextJson } from "./i18n.ts";
4
4
  /**
5
5
  * The player's command registry caps, the same values as the player's own
@@ -44,6 +44,43 @@ export declare const COMMAND_LIMITS: {
44
44
  * ```
45
45
  */
46
46
  export declare const MENU_IDS: readonly ["open", "playback", "subtitles", "video", "audio", "screen", "settings"];
47
+ /**
48
+ * The icons a bottom-bar button may name (`CommandOptions.button.icon`):
49
+ * every icon of the player's V-Line set, `@vestra/icons.slint` (draw one
50
+ * in your own Slint with `Icon { name: "store"; }`). The player's tests check
51
+ * that this list is its icon set.
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * import assert from "node:assert/strict";
56
+ * import { BAR_BUTTON_ICONS } from "@vestra-player/sdk/testing";
57
+ *
58
+ * assert.ok(BAR_BUTTON_ICONS.includes("store"));
59
+ * ```
60
+ *
61
+ * @since 1.5.29
62
+ */
63
+ export declare const BAR_BUTTON_ICONS: readonly ["play", "pause", "stop", "back", "forward", "open", "volume", "mute", "playlist", "detach", "attach", "close", "failed", "move-top", "move-up", "move-down", "move-bottom", "search", "chapter", "bookmark", "check-task", "check-box", "menu-check", "submenu", "scroll-up", "scroll-down", "caret-down", "sort-asc", "sort-desc", "tag-remove", "preview-play", "add", "remove", "sort", "fullscreen", "exit-fullscreen", "store", "ai"];
64
+ /**
65
+ * The player's limits on bottom-bar buttons (`CommandOptions.button`), the
66
+ * same values as the player's (its tests check that they agree). A button
67
+ * past `perPlugin` or `total` is left out: its command still registers and
68
+ * its menu entry stays, and the player logs the button it left out
69
+ * (`limit`). Which ones stay: each plugin's first declared button, then the
70
+ * `total` highest by order, plugin id and declaration order: as in a narrow
71
+ * strip, the lowest order goes first (equal orders: the smaller plugin id,
72
+ * then the earlier declaration, goes first).
73
+ *
74
+ * @since 1.5.29
75
+ */
76
+ export declare const BAR_BUTTON_LIMITS: {
77
+ /** Buttons one plugin shows (1). */
78
+ readonly perPlugin: 1;
79
+ /** Buttons the player shows, every plugin together, besides its own sidebar toggle (8). */
80
+ readonly total: 8;
81
+ /** The highest `order` (999; the lowest is 0). */
82
+ readonly maxOrder: 999;
83
+ };
47
84
  /**
48
85
  * A submenu that one of the player's built-in plugins declares: an entry of
49
86
  * {@link BUILTIN_SUBMENUS}.
@@ -157,6 +194,15 @@ export type CommandDeclaration = {
157
194
  focus: "panel" | null;
158
195
  /** Fire again on keyboard auto-repeat while held. */
159
196
  repeat: boolean;
197
+ /**
198
+ * A bottom-bar button (player 0.5.10): an icon name, or an image in the
199
+ * `plugin.ui.image` wire form. Sent only when set: older players refuse
200
+ * unknown declaration fields.
201
+ */
202
+ button?: {
203
+ icon: string | Json;
204
+ order: number;
205
+ };
160
206
  };
161
207
  export declare function commandClient(transport: Transport): {
162
208
  register: (id: string, revision: number, declaration: CommandDeclaration, state: CommandState) => Promise<{
package/dist/commands.js CHANGED
@@ -49,6 +49,82 @@ export const MENU_IDS = [
49
49
  "screen",
50
50
  "settings",
51
51
  ];
52
+ /**
53
+ * The icons a bottom-bar button may name (`CommandOptions.button.icon`):
54
+ * every icon of the player's V-Line set, `@vestra/icons.slint` (draw one
55
+ * in your own Slint with `Icon { name: "store"; }`). The player's tests check
56
+ * that this list is its icon set.
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * import assert from "node:assert/strict";
61
+ * import { BAR_BUTTON_ICONS } from "@vestra-player/sdk/testing";
62
+ *
63
+ * assert.ok(BAR_BUTTON_ICONS.includes("store"));
64
+ * ```
65
+ *
66
+ * @since 1.5.29
67
+ */
68
+ export const BAR_BUTTON_ICONS = [
69
+ "play",
70
+ "pause",
71
+ "stop",
72
+ "back",
73
+ "forward",
74
+ "open",
75
+ "volume",
76
+ "mute",
77
+ "playlist",
78
+ "detach",
79
+ "attach",
80
+ "close",
81
+ "failed",
82
+ "move-top",
83
+ "move-up",
84
+ "move-down",
85
+ "move-bottom",
86
+ "search",
87
+ "chapter",
88
+ "bookmark",
89
+ "check-task",
90
+ "check-box",
91
+ "menu-check",
92
+ "submenu",
93
+ "scroll-up",
94
+ "scroll-down",
95
+ "caret-down",
96
+ "sort-asc",
97
+ "sort-desc",
98
+ "tag-remove",
99
+ "preview-play",
100
+ "add",
101
+ "remove",
102
+ "sort",
103
+ "fullscreen",
104
+ "exit-fullscreen",
105
+ "store",
106
+ "ai",
107
+ ];
108
+ /**
109
+ * The player's limits on bottom-bar buttons (`CommandOptions.button`), the
110
+ * same values as the player's (its tests check that they agree). A button
111
+ * past `perPlugin` or `total` is left out: its command still registers and
112
+ * its menu entry stays, and the player logs the button it left out
113
+ * (`limit`). Which ones stay: each plugin's first declared button, then the
114
+ * `total` highest by order, plugin id and declaration order: as in a narrow
115
+ * strip, the lowest order goes first (equal orders: the smaller plugin id,
116
+ * then the earlier declaration, goes first).
117
+ *
118
+ * @since 1.5.29
119
+ */
120
+ export const BAR_BUTTON_LIMITS = {
121
+ /** Buttons one plugin shows (1). */
122
+ perPlugin: 1,
123
+ /** Buttons the player shows, every plugin together, besides its own sidebar toggle (8). */
124
+ total: 8,
125
+ /** The highest `order` (999; the lowest is 0). */
126
+ maxOrder: 999,
127
+ };
52
128
  /**
53
129
  * The submenus the player's built-in plugins declare, by key path, in menu
54
130
  * order (built-in menus as in {@link MENU_IDS}, then top-level submenus).
@@ -123,7 +199,6 @@ export const BUILTIN_SUBMENUS = Object.freeze([
123
199
  },
124
200
  { path: ["Settings", "Language"], plugins: ["vestra.preferences"] },
125
201
  { path: ["Copy to Clipboard"], plugins: ["vestra.playback"] },
126
- { path: ["Emulator"], plugins: ["vestra.nes"] },
127
202
  ]);
128
203
  /** owner | id | key path, or (title) outside the menu | bindings | panel */
129
204
  const BUILTIN_COMMAND_TABLE = `
@@ -228,8 +303,8 @@ vestra.subtitles | second-position-aboveMain | Subtitles ▸ Secondary Subtitle
228
303
  vestra.subtitles | bold | Subtitles ▸ Subtitle Style ▸ Bold | Alt+B
229
304
  vestra.subtitles | size-up | Subtitles ▸ Subtitle Style ▸ Size + | Alt+PageUp
230
305
  vestra.subtitles | size-down | Subtitles ▸ Subtitle Style ▸ Size − | Alt+PageDown
231
- vestra.subtitles | move-up | Subtitles ▸ Subtitle Style ▸ Move Up | Alt+ArrowUp
232
- vestra.subtitles | move-down | Subtitles ▸ Subtitle Style ▸ Move Down | Alt+ArrowDown
306
+ vestra.subtitles | move-up | Subtitles ▸ Subtitle Style ▸ Move Up
307
+ vestra.subtitles | move-down | Subtitles ▸ Subtitle Style ▸ Move Down
233
308
  vestra.subtitles | move-reset | Subtitles ▸ Subtitle Style ▸ Reset Position | Alt+Home
234
309
  vestra.subtitles | shadow | Subtitles ▸ Subtitle Style ▸ Shadow
235
310
  vestra.subtitles | box | Subtitles ▸ Subtitle Style ▸ Opaque Box
@@ -369,9 +444,6 @@ vestra.audio | audio.normalize | Audio ▸ Processing ▸ Normalize | Shift+N
369
444
  vestra.audio | audio.clarity | Audio ▸ Processing ▸ Crystalizer | Shift+C
370
445
  vestra.audio | audio.reset | Audio ▸ Reset
371
446
  vestra.visualizer | audio-cover | Audio ▸ Show Album Cover for Audio Only
372
- vestra.nes | nes.open | Emulator ▸ Open NES ROM…
373
- vestra.nes | nes.stop | Emulator ▸ Stop NES
374
- vestra.nes | nes.window | Emulator ▸ NES Tools…
375
447
  host | fullscreen | Toggle Fullscreen | Enter, Alt+Enter, F11
376
448
  vestra.window | pip | Screen ▸ Picture-in-Picture
377
449
  vestra.window | mini | Screen ▸ Mini Size | \`
@@ -417,6 +489,7 @@ vestra.preferences | language.es | Settings ▸ Language ▸ Español
417
489
  vestra.info | info.about | About Vestra Player… | F1
418
490
  host | quit | Quit | Cmd+Q
419
491
  host | exit-fullscreen | (Leave Fullscreen) | Escape
492
+ host | sidebar | (Toggle Sidebar)
420
493
  vestra.info | info.brief | (Brief Info) | Shift+Tab
421
494
  vestra.playlist | playlist.play | (Play) | Enter, P | panel
422
495
  vestra.playlist | playlist.list-add | (Add Files…) | Ctrl+I | panel
@@ -13,7 +13,7 @@ export { HostError } from "./wire.ts";
13
13
  export type { Json, Transport } from "./wire.ts";
14
14
  export { API_VERSION, CAPABILITIES, CAPABILITY_INFO, MESSAGE_LIMIT, } from "./capabilities.ts";
15
15
  export { HOST_CAPABILITIES } from "./host-capabilities.ts";
16
- export { BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "./commands.ts";
16
+ export { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "./commands.ts";
17
17
  export { BINDING_INPUTS } from "./plugin/bindings.ts";
18
18
  export { OSD_LIMITS } from "./plugin/osd.ts";
19
19
  export { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./plugin/services.ts";
@@ -11,7 +11,7 @@ export { createPlugin } from "./plugin/index.js";
11
11
  export { HostError } from "./wire.js";
12
12
  export { API_VERSION, CAPABILITIES, CAPABILITY_INFO, MESSAGE_LIMIT, } from "./capabilities.js";
13
13
  export { HOST_CAPABILITIES } from "./host-capabilities.js";
14
- export { BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "./commands.js";
14
+ export { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "./commands.js";
15
15
  export { BINDING_INPUTS } from "./plugin/bindings.js";
16
16
  export { OSD_LIMITS } from "./plugin/osd.js";
17
17
  export { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./plugin/services.js";
@@ -1,7 +1,8 @@
1
1
  /** Menu commands: declaration, state sync (revisions hidden) and dispatch. */
2
2
  import { failure, Latest, quiet } from "./runtime.js";
3
3
  import { checkBindings } from "./bindings.js";
4
- import { MENU_IDS } from "../commands.js";
4
+ import { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, MENU_IDS } from "../commands.js";
5
+ import { ImageHandle } from "./ui.js";
5
6
  import { isTextValue, textProblem } from "../i18n.js";
6
7
  const value = (item) => typeof item === "function" ? item() : item;
7
8
  /** Validate options and build the wire declaration. */
@@ -32,7 +33,7 @@ function declare(options) {
32
33
  if (!Number.isInteger(order) || Math.abs(order) > 1_000_000)
33
34
  throw failure("invalid", `command ${options.id}: order must be an integer within ±1000000`);
34
35
  const bindings = [...(options.bindings ?? [])];
35
- return {
36
+ const declaration = {
36
37
  title: title,
37
38
  path: path,
38
39
  order,
@@ -42,6 +43,32 @@ function declare(options) {
42
43
  focus: options.focus ?? null,
43
44
  repeat: options.repeat ?? false,
44
45
  };
46
+ // Only when set: players before 0.5.10 refuse the field.
47
+ if (options.button !== undefined)
48
+ declaration.button = button(options.id, options.button, path.length > 0);
49
+ return declaration;
50
+ }
51
+ /** `CommandOptions.button`, checked as the player checks it, in wire form. */
52
+ function button(id, value, inMenu) {
53
+ const fail = (problem) => failure("invalid", `command ${id}: ${problem}`);
54
+ if (value === null || typeof value !== "object")
55
+ throw fail("button is { icon, order }");
56
+ if (!inMenu)
57
+ throw fail("button needs a menu path: a bar button is also a menu item");
58
+ const { icon, order } = value;
59
+ if (!Number.isInteger(order) ||
60
+ order < 0 ||
61
+ order > BAR_BUTTON_LIMITS.maxOrder)
62
+ throw fail(`button order must be an integer 0–${BAR_BUTTON_LIMITS.maxOrder}`);
63
+ if (icon instanceof ImageHandle) {
64
+ if (icon.released)
65
+ throw fail(`button image ${icon.path} was released with the scope that created it`);
66
+ return { icon: icon.toJSON(), order };
67
+ }
68
+ if (typeof icon !== "string" ||
69
+ !BAR_BUTTON_ICONS.includes(icon))
70
+ throw fail(`unknown button icon ${JSON.stringify(icon)}: a name of @vestra/icons.slint (BAR_BUTTON_ICONS) or an image from plugin.ui.image`);
71
+ return { icon, order };
45
72
  }
46
73
  /** Unregistrations in flight per plugin: re-adding an id waits for its removal. */
47
74
  const removals = new WeakMap();
@@ -2,7 +2,7 @@ import type { Server } from "node:net";
2
2
  import { HostError } from "../wire.ts";
3
3
  import type { PackageManifest, PackageSource } from "./types.ts";
4
4
  import type { Json, LayoutSlot, LocalServices, PlaybackStats, Transport, WireSystemInfo } from "../wire.ts";
5
- import { BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS } from "../commands.ts";
5
+ import { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS } from "../commands.ts";
6
6
  import type { BuiltinCommand, BuiltinSubmenu } from "../commands.ts";
7
7
  import { CAPABILITY_INFO, MESSAGE_LIMIT } from "../capabilities.ts";
8
8
  import type { CapabilityInfo } from "../capabilities.ts";
@@ -22,6 +22,8 @@ import { LOCALES, LIST_SEPARATORS, TEXT_LIMITS } from "../i18n.ts";
22
22
  export { HostError };
23
23
  /** The player's command caps, which the test host enforces as well. */
24
24
  export { COMMAND_LIMITS, MENU_IDS };
25
+ /** The icons and limits of bottom-bar buttons, which the test host checks as well. */
26
+ export { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS };
25
27
  /** The submenus of the player's built-in plugins, by key path. */
26
28
  export { BUILTIN_COMMANDS, BUILTIN_SUBMENUS };
27
29
  export type { BuiltinCommand, BuiltinSubmenu };
@@ -199,6 +201,37 @@ export interface FakeCommand {
199
201
  */
200
202
  run(): Promise<void>;
201
203
  }
204
+ /**
205
+ * A command's bottom-bar button (`CommandOptions.button`) as the player shows
206
+ * it: an entry of {@link TestHost.barButtons}.
207
+ *
208
+ * @since 1.5.29
209
+ */
210
+ export interface FakeBarButton {
211
+ /** The command's local id. */
212
+ readonly command: string;
213
+ /** The icon name, or the image's path for a `plugin.ui.image` icon. */
214
+ readonly icon: string;
215
+ /** `true` when the icon is the plugin's image. */
216
+ readonly image: boolean;
217
+ /** The declared `order`. */
218
+ readonly order: number;
219
+ /** Clickable: the command is enabled and not busy. */
220
+ readonly enabled: boolean;
221
+ /** Drawn in the accent colour: the command is checked. */
222
+ readonly checked: boolean;
223
+ /**
224
+ * The tooltip without the key: the command's shown title (trailing "…"
225
+ * removed), then, while it cannot run, its reason on a second line.
226
+ */
227
+ readonly tooltip: string;
228
+ /**
229
+ * Click the button, then wait until the host is idle: runs the command
230
+ * as its menu item would. Rejects (with a {@link HostError}) while the
231
+ * button is disabled, and once the command is gone.
232
+ */
233
+ click(): Promise<void>;
234
+ }
202
235
  /**
203
236
  * A generated stream (`plugin.player.openStream`) as the test host sees it:
204
237
  * the fake player end of the frame connection. Assertion surface; see
@@ -962,7 +995,7 @@ export declare class TestHost implements Transport {
962
995
  * @example
963
996
  * ```ts
964
997
  * const host = new TestHost();
965
- * host.otherResolvers.push({ owner: "vestra.nes", extensions: ["nes"] });
998
+ * host.otherResolvers.push({ owner: "example.emulator", extensions: ["nes"] });
966
999
  * ```
967
1000
  */
968
1001
  readonly otherResolvers: {
@@ -989,7 +1022,7 @@ export declare class TestHost implements Transport {
989
1022
  *
990
1023
  * @example
991
1024
  * ```ts
992
- * host.otherResolvers.push({ owner: "vestra.nes", extensions: ["nes"] });
1025
+ * host.otherResolvers.push({ owner: "example.emulator", extensions: ["nes"] });
993
1026
  * host.updateFileTypes();
994
1027
  * await host.settle();
995
1028
  * assert.deepEqual(plugin.resolvers.fileTypes, ["nes"]);
@@ -1477,6 +1510,24 @@ export declare class TestHost implements Transport {
1477
1510
  * registered (never added, or disposed).
1478
1511
  */
1479
1512
  command(id: string): FakeCommand;
1513
+ /**
1514
+ * The plugin's bottom-bar buttons (`CommandOptions.button`) as the player
1515
+ * shows them: at most one, the first declared (`BAR_BUTTON_LIMITS`; a
1516
+ * later one stays a menu item only). Empty once the command is disposed.
1517
+ * Whether the bar places them is the bar's business: the list is what
1518
+ * the player hands to `BarButtons`.
1519
+ *
1520
+ * @example
1521
+ * ```ts
1522
+ * const [button] = host.barButtons;
1523
+ * assert.equal(button?.icon, "store");
1524
+ * await button?.click();
1525
+ * assert.equal(host.window("store").visible, true);
1526
+ * ```
1527
+ *
1528
+ * @since 1.5.29
1529
+ */
1530
+ get barButtons(): readonly FakeBarButton[];
1480
1531
  /**
1481
1532
  * The plugin's tool window with this id (by default the file name without
1482
1533
  * extension: `ui/picture.slint` → `picture`). Throws when it was never
@@ -66,7 +66,7 @@ import { extname, isAbsolute, join } from "node:path";
66
66
  import { fileURLToPath } from "node:url";
67
67
  import { HostError } from "../wire.js";
68
68
  import { localSocketPath } from "./local.js";
69
- import { BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "../commands.js";
69
+ import { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "../commands.js";
70
70
  import { CAPABILITY_INFO, MESSAGE_LIMIT } from "../capabilities.js";
71
71
  import { OSD_LIMITS } from "./osd.js";
72
72
  import { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./services.js";
@@ -83,6 +83,8 @@ import { Catalog, format as formatTemplate, HOST_GROUPS, HOST_ZH_HANS, LOCALES,
83
83
  export { HostError };
84
84
  /** The player's command caps, which the test host enforces as well. */
85
85
  export { COMMAND_LIMITS, MENU_IDS };
86
+ /** The icons and limits of bottom-bar buttons, which the test host checks as well. */
87
+ export { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS };
86
88
  /** The submenus of the player's built-in plugins, by key path. */
87
89
  export { BUILTIN_COMMANDS, BUILTIN_SUBMENUS };
88
90
  /** The manifest capabilities: what each opens and how the store shows it. */
@@ -1079,7 +1081,7 @@ export class TestHost {
1079
1081
  * @example
1080
1082
  * ```ts
1081
1083
  * const host = new TestHost();
1082
- * host.otherResolvers.push({ owner: "vestra.nes", extensions: ["nes"] });
1084
+ * host.otherResolvers.push({ owner: "example.emulator", extensions: ["nes"] });
1083
1085
  * ```
1084
1086
  */
1085
1087
  otherResolvers = [];
@@ -1107,7 +1109,7 @@ export class TestHost {
1107
1109
  *
1108
1110
  * @example
1109
1111
  * ```ts
1110
- * host.otherResolvers.push({ owner: "vestra.nes", extensions: ["nes"] });
1112
+ * host.otherResolvers.push({ owner: "example.emulator", extensions: ["nes"] });
1111
1113
  * host.updateFileTypes();
1112
1114
  * await host.settle();
1113
1115
  * assert.deepEqual(plugin.resolvers.fileTypes, ["nes"]);
@@ -2095,6 +2097,53 @@ export class TestHost {
2095
2097
  throw new Error(`command ${id} is not registered`);
2096
2098
  return command;
2097
2099
  }
2100
+ /**
2101
+ * The plugin's bottom-bar buttons (`CommandOptions.button`) as the player
2102
+ * shows them: at most one, the first declared (`BAR_BUTTON_LIMITS`; a
2103
+ * later one stays a menu item only). Empty once the command is disposed.
2104
+ * Whether the bar places them is the bar's business: the list is what
2105
+ * the player hands to `BarButtons`.
2106
+ *
2107
+ * @example
2108
+ * ```ts
2109
+ * const [button] = host.barButtons;
2110
+ * assert.equal(button?.icon, "store");
2111
+ * await button?.click();
2112
+ * assert.equal(host.window("store").visible, true);
2113
+ * ```
2114
+ *
2115
+ * @since 1.5.29
2116
+ */
2117
+ get barButtons() {
2118
+ const host = this;
2119
+ return [...this.commands]
2120
+ .filter(([, command]) => command.declaration.button !== undefined)
2121
+ .slice(0, BAR_BUTTON_LIMITS.perPlugin)
2122
+ .map(([id, command]) => {
2123
+ const button = command.declaration.button;
2124
+ const image = typeof button.icon !== "string";
2125
+ const reason = command.display.reason;
2126
+ const title = command.display.title.replace(/(…|\.\.\.)\s*$/, "");
2127
+ const enabled = command.state.enabled && !command.state.busy;
2128
+ return Object.freeze({
2129
+ command: id,
2130
+ icon: image
2131
+ ? String(button.icon.$vestraImage)
2132
+ : button.icon,
2133
+ image,
2134
+ order: button.order,
2135
+ enabled,
2136
+ checked: command.state.checked === true,
2137
+ tooltip: !enabled && reason ? `${title}\n${reason}` : title,
2138
+ async click() {
2139
+ const current = host.commands.get(id);
2140
+ if (current !== command)
2141
+ throw new HostError("missing", `command ${id} is gone`);
2142
+ await current.run();
2143
+ },
2144
+ });
2145
+ });
2146
+ }
2098
2147
  /**
2099
2148
  * The plugin's tool window with this id (by default the file name without
2100
2149
  * extension: `ui/picture.slint` → `picture`). Throws when it was never
@@ -2501,6 +2550,23 @@ export class TestHost {
2501
2550
  throw new HostError("limit", "command registry limit reached");
2502
2551
  const host = this;
2503
2552
  const declaration = p.declaration;
2553
+ // Player 0.5.10: a bar button needs a path, an order 0–999 and a
2554
+ // known icon or an image of the plugin.
2555
+ if (declaration.button !== undefined) {
2556
+ const { icon, order } = declaration.button;
2557
+ if (path.length === 0)
2558
+ throw new HostError("invalid", "button needs a menu path");
2559
+ if (!Number.isInteger(order) ||
2560
+ order < 0 ||
2561
+ order > BAR_BUTTON_LIMITS.maxOrder)
2562
+ throw new HostError("invalid", `button order must be an integer 0–${BAR_BUTTON_LIMITS.maxOrder}`);
2563
+ if (typeof icon === "string") {
2564
+ if (!BAR_BUTTON_ICONS.includes(icon))
2565
+ throw new HostError("invalid", `unknown button icon "${icon}"`);
2566
+ }
2567
+ else if (this.imagePath(icon) === null)
2568
+ throw new HostError("invalid", "button icon is an icon name or an image from plugin.ui.image");
2569
+ }
2504
2570
  // Like the player: the menu texts and the reason are checked (and
2505
2571
  // shown) when they arrive.
2506
2572
  for (const segment of [declaration.title, ...(declaration.path ?? [])])