@vestra-player/sdk 1.6.5 → 1.6.8

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,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.8
4
+
5
+ **Who must change: nobody; authors of a bottom bar should move to `content-min-width`.** Documentation, a read-only Slint property and a removed CLI warning: no change to the TypeScript API, the wire or `TestHost`, `apiVersion` stays `vestra/1`, no new capability. A plugin's `button` declarations stay as they are. A bottom bar that still places `BarButtons` keeps working on player 0.7.6 (it shows that strip, and the player draws no second one); it is deprecated and goes in a later patch.
6
+
7
+ - **The player places the bottom bar's button strip** (player 0.7.6, menu redesign step 3a). It draws the strip at the right end of the bottom slot, also when no plugin mounts a bottom bar (in a band of its own): the plugins' buttons, a » menu, the player's **Command Palette** (`host/palette`, a magnifier) and **Toggle Sidebar**. [Bottom-bar buttons](docs/explanation/commands.md#bottom-bar-buttons) now reads: buttons are ordered by `order`, then by plugin id; lower `order` comes first and stays visible longest; when the strip runs out of room, or more than 8 plugin buttons are declared, the remaining buttons move to the » menu, in the same order; a button is never dropped. Before player 0.7.6 a narrow strip lost its lowest orders first and the player left out all but the eight highest; `BAR_BUTTON_LIMITS.total` is now what the strip holds. [Reference: commands](docs/reference/commands.md#bottom-bar-buttons), [Reference: limits](docs/reference/limits.md#bottom-bar-buttons), `CommandButton`, `CommandOptions.button` and `BAR_BUTTON_LIMITS` say so.
8
+ - **Writing a bottom bar** ([Interface guidelines](docs/guidelines/interface.md#writing-a-bottom-bar)): a bar places nothing. It reports the least width of its own controls in a `content-min-width` length property on the mounted component (the player gives the strip the rest, so the time label is never covered) and ends its row at the strip, reading `BarButtonsHost.width` (new, read only; `fixed` for an optional control kept ahead of plugin buttons) from `@vestra/bar-buttons.slint`. The file stays in the Slint library copy for that global; its `BarButtons` component is deprecated.
9
+ - **`@vestra/bar-buttons.slint`** (the read-only copy of the player's widget library, synced): `BarButtonsHost` gains `width`, `fixed`, `shown` and `more` (out: the strip's width, the cells a bar must leave free (the player's own and the » cell), the plugin buttons in the strip, whether » shows) and the player's `palette`, `overflow` and `room`; the comments describe the player's strip and mark `BarButtons` deprecated.
10
+ - **`vestra-plugin check` no longer warns** that a bottom bar does not place `BarButtons`: since player 0.7.6 that is the right thing to do.
11
+
12
+ ## 1.6.7
13
+
14
+ **Who must change: nobody.** The 1.6.6 release below, published again under a new number: its npm publish failed (the provenance transparency log returned 500, then 409 for the same tarball), so 1.6.6 never reached npm. Nothing else changed.
15
+
16
+ ## 1.6.6
17
+
18
+ **Who must change: nobody.** Additions only: two members of `plugin.commands`, a wire method and event, two Slint widget additions and a `TestHost` method. `apiVersion` stays `vestra/1`, no new capability (`commands` covers them). A plugin that does not use them sends exactly what it sent before. One that uses them needs player 0.7.6 (`"minPlayerVersion": "0.7.6"`): the player builds `plugin` from its own SDK, so on older players `plugin.commands.keys` does not exist (a `TypeError`), and their `@vestra/widgets.slint` has no `KeyHint` or `ToolTipKey`, so a Slint file that uses them does not compile.
19
+
20
+ - **`plugin.commands.keys(id)`** (player 0.7.6): the keys of one of the plugin's **own** commands, as the player's menus and command palette write them (`["W"]`, `["Ctrl+B"]`, `["⌘B"]` on macOS, arrows as `←`), always the current binding: the user's keys after a change, `[]` when the user cleared them or turned on Ignore Default Shortcuts, `[]` for a key another command holds; mouse bindings and (outside macOS) `Cmd` bindings left out. Read-only and synchronous; read in a `bind` function, the view follows every change. Another plugin's commands, or an unknown id, read `[]`; it needs `commands` only, not `bindings.manage`. The first read asks the player (`commands.keys`) and subscribes to the `commands.keys` event, which the player sends only to the plugin whose own labels changed, at most every 200 ms; until the first answer every command reads `[]`. See [Key hints beside controls](docs/explanation/commands.md#key-hints-beside-controls).
21
+ - **`plugin.commands.onKeysChange(listener)`** (player 0.7.6): runs after any of the plugin's keys changed. `commands.keys` is a wake event, like `bindings.changed`: a listener (or a binding that reads `keys`) does not keep a plugin running while idle, and a plugin in standby is started for a change of its keys.
22
+ - **`KeyHint`** in `@vestra/widgets.slint`: a key cap (monospace `font-xs`, `text-dim`, a 1px border, both palettes); an empty `text` draws nothing and takes no space.
23
+ - **`ToolTipArea.keys`** (a list of the new struct `ToolTipKey`, `{ key, text }`) and **`ToolTipArea.detail`**: the tip reads "Brightness [W] − [E] + · double-click: Default" with key caps. Without them the tip is the plain text tip as before.
24
+ - **`TestHost.rebind(command, inputs | null)`** changes or clears a key of the plugin's command as the user does; `TestHost` answers `commands.keys` and pushes the event like the player, writing labels for `host.systemInfo.os` (macOS by default: `⇧W`, `⌘B`). `bindings.set` and `bindings.reset` push it too.
25
+ - The [theme-widgets example](examples/theme-widgets) shows a slider's two commands (F7, F8) as key caps and in its tooltip.
26
+
3
27
  ## 1.6.5
4
28
 
5
29
  **Who must change: nobody, but run `vestra-plugin i18n extract` again if a command sets a `category`.** A fix in the plugin CLI only: no change to the TypeScript API, the wire or `TestHost`, `apiVersion` stays `vestra/1`, no new capability, no player version needed. A plugin whose commands set `category` as a string literal that is not already a message now has those texts in `locales/messages.pot`, so its next `vestra-plugin i18n check` fails with "messages.pot is out of date" until `i18n extract` runs. A category written with `plugin.i18n.text(…)` was already extracted, and a plugin that sets no category sees no change.
@@ -81,7 +81,6 @@ import {
81
81
  basename,
82
82
  dirname,
83
83
  extname,
84
- isAbsolute,
85
84
  join,
86
85
  relative,
87
86
  resolve,
@@ -363,10 +362,7 @@ async function check(target, report = null) {
363
362
  }
364
363
  say(`${target.name}: capabilities declared for every API use`);
365
364
  if (report) return;
366
- for (const line of [
367
- ...(await bottomBarWarnings(target)),
368
- ...(await forWarnings(target)),
369
- ])
365
+ for (const line of await forWarnings(target))
370
366
  console.log(`${target.name}: warning: ${line}`);
371
367
  // Translation warnings never fail the check.
372
368
  if (present(join(target.directory, "locales"))) {
@@ -432,85 +428,6 @@ function slintCode(text) {
432
428
  return out;
433
429
  }
434
430
 
435
- /** Whether `path` lies inside `directory` (not a sibling sharing its prefix). */
436
- function inside(directory, path) {
437
- const rel = relative(resolve(directory), path);
438
- return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
439
- }
440
-
441
- /**
442
- * Whether the Slint file at `file` (relative to the plugin directory)
443
- * imports `BarButtons` from `@vestra/bar-buttons.slint`, itself or
444
- * through the plugin's own Slint files (relative paths, any depth, inside
445
- * the plugin directory). Local re-exports count as such files too:
446
- * `export { Row } from "row.slint"` and `export * from "row.slint"` are
447
- * followed, and `export { BarButtons } from "@vestra/bar-buttons.slint"` (or
448
- * `export * from` it) in a file the bar imports counts as placing it.
449
- * Comments do not count.
450
- */
451
- async function importsBarButtons(target, file, seen = new Set()) {
452
- const path = resolve(target.directory, file);
453
- if (seen.has(path) || !inside(target.directory, path)) return false;
454
- seen.add(path);
455
- const text = await readFile(path, "utf8").catch(() => null);
456
- if (text === null) return false;
457
- const code = slintCode(text);
458
- // { names (or "*"), from }: imports, and re-exports with a source file.
459
- const imports = [
460
- ...code.matchAll(
461
- /\b(?:import|export)\s*(?:(\*)\s*from\s*|\{([^}]*)\}\s*from\s*)?"([^"]+)"/g,
462
- ),
463
- ].map(([, star, names, from]) => ({ names: star ?? names ?? "", from }));
464
- for (const { names, from } of imports)
465
- if (
466
- from === "@vestra/bar-buttons.slint" &&
467
- (names === "*" ||
468
- names
469
- .split(",")
470
- .some((name) => /^BarButtons(\s+as\s+[\w-]+)?$/.test(name.trim())))
471
- )
472
- return true;
473
- for (const { from } of imports) {
474
- if (
475
- from.startsWith("@") ||
476
- !from.endsWith(".slint") ||
477
- from === "std-widgets.slint"
478
- )
479
- continue;
480
- if (await importsBarButtons(target, join(dirname(file), from), seen))
481
- return true;
482
- }
483
- return false;
484
- }
485
-
486
- /**
487
- * A plugin that mounts a bottom bar (`panels.mount("bottom", "<file>")`)
488
- * whose Slint file does not import `BarButtons` (`@vestra/bar-buttons.slint`),
489
- * itself or through its local imports: while it is the bottom bar, no
490
- * plugin's command button shows (SDK 1.5.29). The mount is read from the
491
- * source text: a literal file name in the call.
492
- */
493
- async function bottomBarWarnings(target) {
494
- const { typescript: sources } = await sourceFiles(target);
495
- const files = new Set();
496
- for (const file of sources) {
497
- const text = await readFile(join(target.directory, file), "utf8");
498
- for (const match of text.matchAll(
499
- /\.mount\(\s*["'`]bottom["'`]\s*,\s*["'`]([^"'`$]+)["'`]/g,
500
- ))
501
- files.add(match[1]);
502
- }
503
- const warnings = [];
504
- for (const file of files) {
505
- if (!present(join(target.directory, file))) continue;
506
- if (await importsBarButtons(target, file)) continue;
507
- warnings.push(
508
- `${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`,
509
- );
510
- }
511
- return warnings;
512
- }
513
-
514
431
  /**
515
432
  * A `for` over an array the plugin binds (an `in` or `in-out` property of
516
433
  * array type of a component or global in the same file), outside a
@@ -98,6 +98,8 @@ export const API_REQUESTS = Object.freeze({
98
98
  "Resolvers.add": [{ method: "sourceResolvers.register" }],
99
99
  "Resolvers.onFileTypesChange": [listen("resolvers.fileTypes")],
100
100
  "Commands.add": [{ method: "commands.register" }],
101
+ "Commands.keys": [{ method: "commands.keys" }, listen("commands.keys")],
102
+ "Commands.onKeysChange": [listen("commands.keys")],
101
103
  "Host.run": [{ method: "host.run" }],
102
104
  "Windows.create": [{ method: "ui.open" }],
103
105
  "Dialogs.confirm": [{ method: "dialogs.confirm" }],
@@ -158,12 +158,11 @@ export declare const BAR_BUTTON_ICONS: readonly ["play", "pause", "stop", "back"
158
158
  /**
159
159
  * The player's limits on bottom-bar buttons (`CommandOptions.button`), the
160
160
  * same values as the player's (its tests check that they agree). A button
161
- * past `perPlugin` or `total` is left out: its command still registers and
162
- * its menu entry stays, and the player logs the button it left out
163
- * (`limit`). Which ones stay: each plugin's first declared button, then the
164
- * `total` highest by order, plugin id and declaration order: as in a narrow
165
- * strip, the lowest order goes first (equal orders: the smaller plugin id,
166
- * then the earlier declaration, goes first).
161
+ * past `perPlugin` has none (its command still registers and its menu entry
162
+ * stays). `total` is what the strip holds: the `total` lowest by order,
163
+ * plugin id and declaration order; the rest are in the strip's » menu, in
164
+ * the same order (older players keep the `total` highest and leave the
165
+ * others out).
167
166
  *
168
167
  * @since 1.5.29
169
168
  *
@@ -172,7 +171,7 @@ export declare const BAR_BUTTON_ICONS: readonly ["play", "pause", "stop", "back"
172
171
  export declare const BAR_BUTTON_LIMITS: {
173
172
  /** Buttons one plugin shows (1). */
174
173
  readonly perPlugin: 1;
175
- /** Buttons the player shows, every plugin together, besides its own sidebar toggle (8). */
174
+ /** Buttons in the strip, every plugin together, besides the player's own (8); the rest are in its » menu. */
176
175
  readonly total: 8;
177
176
  /** The highest `order` (999; the lowest is 0). */
178
177
  readonly maxOrder: 999;
@@ -345,12 +344,28 @@ export type CommandDeclaration = {
345
344
  */
346
345
  home?: string;
347
346
  };
347
+ /**
348
+ * `commands.keys` (player 0.7.6): each of the plugin's own commands, by
349
+ * local id, with the labels of its current keys; the answer to the request
350
+ * and the data of the event of that name.
351
+ */
352
+ export type CommandKeys = {
353
+ keys: Record<string, string[]>;
354
+ /** The player's binding table revision: a larger one is newer. */
355
+ revision: number;
356
+ };
348
357
  export declare function commandClient(transport: Transport): {
349
358
  register: (id: string, revision: number, declaration: CommandDeclaration, state: CommandState, when?: CommandWhen) => Promise<{
350
359
  revision: number;
351
360
  }>;
352
361
  /** Remove the command from the menu and the binding table; the id is free again. */
353
362
  unregister: (id: string) => Promise<void>;
363
+ /**
364
+ * The key labels of the plugin's own commands, as menus show them
365
+ * (player 0.7.6); later changes come as the `commands.keys` event, in
366
+ * the same shape. `revision` orders answers and events.
367
+ */
368
+ keys: () => Promise<CommandKeys>;
354
369
  update: (id: string, revision: number, state: CommandState, when?: CommandWhen) => Promise<{
355
370
  revision: number;
356
371
  }>;
package/dist/commands.js CHANGED
@@ -202,12 +202,11 @@ export const BAR_BUTTON_ICONS = [
202
202
  /**
203
203
  * The player's limits on bottom-bar buttons (`CommandOptions.button`), the
204
204
  * same values as the player's (its tests check that they agree). A button
205
- * past `perPlugin` or `total` is left out: its command still registers and
206
- * its menu entry stays, and the player logs the button it left out
207
- * (`limit`). Which ones stay: each plugin's first declared button, then the
208
- * `total` highest by order, plugin id and declaration order: as in a narrow
209
- * strip, the lowest order goes first (equal orders: the smaller plugin id,
210
- * then the earlier declaration, goes first).
205
+ * past `perPlugin` has none (its command still registers and its menu entry
206
+ * stays). `total` is what the strip holds: the `total` lowest by order,
207
+ * plugin id and declaration order; the rest are in the strip's » menu, in
208
+ * the same order (older players keep the `total` highest and leave the
209
+ * others out).
211
210
  *
212
211
  * @since 1.5.29
213
212
  *
@@ -216,7 +215,7 @@ export const BAR_BUTTON_ICONS = [
216
215
  export const BAR_BUTTON_LIMITS = {
217
216
  /** Buttons one plugin shows (1). */
218
217
  perPlugin: 1,
219
- /** Buttons the player shows, every plugin together, besides its own sidebar toggle (8). */
218
+ /** Buttons in the strip, every plugin together, besides the player's own (8); the rest are in its » menu. */
220
219
  total: 8,
221
220
  /** The highest `order` (999; the lowest is 0). */
222
221
  maxOrder: 999,
@@ -692,6 +691,12 @@ export function commandClient(transport) {
692
691
  }),
693
692
  /** Remove the command from the menu and the binding table; the id is free again. */
694
693
  unregister: (id) => transport.request("commands.unregister", { id }),
694
+ /**
695
+ * The key labels of the plugin's own commands, as menus show them
696
+ * (player 0.7.6); later changes come as the `commands.keys` event, in
697
+ * the same shape. `revision` orders answers and events.
698
+ */
699
+ keys: () => transport.request("commands.keys", {}),
695
700
  update: (id, revision, state, when) => transport.request("commands.update", {
696
701
  id,
697
702
  revision,
Binary file
@@ -84,6 +84,7 @@ export const HOST_CAPABILITIES = {
84
84
  needs: {
85
85
  cases: {
86
86
  "bindings.changed": "bindings.manage",
87
+ "commands.keys": "commands",
87
88
  "packages.changed": null,
88
89
  "video.upscaling.changed": "video.upscaling",
89
90
  },
@@ -96,6 +97,7 @@ export const HOST_CAPABILITIES = {
96
97
  needs: {
97
98
  cases: {
98
99
  "bindings.changed": "bindings.manage",
100
+ "commands.keys": "commands",
99
101
  "packages.changed": null,
100
102
  "video.upscaling.changed": "video.upscaling",
101
103
  },
@@ -116,6 +118,7 @@ export const HOST_CAPABILITIES = {
116
118
  { name: "commands.update", needs: "commands" },
117
119
  { name: "commands.unregister", needs: "commands" },
118
120
  { name: "host.run", needs: "commands" },
121
+ { name: "commands.keys", needs: "commands" },
119
122
  ],
120
123
  name: "commands",
121
124
  requires: [],
@@ -79,3 +79,12 @@ export declare function canonical(text: string): string;
79
79
  export declare function checkBindings(bindings: readonly string[]): string[];
80
80
  /** `plugin.bindings` (`bindings.manage`): the table and the user overlay. */
81
81
  export declare const bindingsApi: (scope: ScopeImpl) => Bindings;
82
+ /**
83
+ * How the player's menus write a canonical binding (`bindings.rs`
84
+ * `label_in`), for `TestHost`'s `commands.keys`: `Ctrl+O` and arrows as
85
+ * ← → ↑ ↓ (`Esc`, `PgUp`, `Num5`); on macOS (`mac`) symbols in Apple's
86
+ * order ⌃⌥⇧⌘, a `Ctrl` binding as ⌘ (it answers to ⌘ there) and ⌃ only
87
+ * next to ⌘. `null` for a binding menus do not show: a mouse binding, and
88
+ * a `Cmd` one outside macOS.
89
+ */
90
+ export declare function keyLabel(binding: string, mac: boolean): string | null;
@@ -219,3 +219,44 @@ export const bindingsApi = (scope) => ({
219
219
  });
220
220
  },
221
221
  });
222
+ /** Named keys whose label is not their name (the player's `label_in`). */
223
+ const NAMED_LABELS = {
224
+ ArrowLeft: "←",
225
+ ArrowRight: "→",
226
+ ArrowUp: "↑",
227
+ ArrowDown: "↓",
228
+ Escape: "Esc",
229
+ PageUp: "PgUp",
230
+ PageDown: "PgDn",
231
+ Delete: "Del",
232
+ Plus: "+",
233
+ NumpadPlus: "Num+",
234
+ NumpadMinus: "Num-",
235
+ NumpadMultiply: "Num*",
236
+ NumpadDivide: "Num/",
237
+ NumpadEnter: "NumEnter",
238
+ NumpadDecimal: "Num.",
239
+ };
240
+ /**
241
+ * How the player's menus write a canonical binding (`bindings.rs`
242
+ * `label_in`), for `TestHost`'s `commands.keys`: `Ctrl+O` and arrows as
243
+ * ← → ↑ ↓ (`Esc`, `PgUp`, `Num5`); on macOS (`mac`) symbols in Apple's
244
+ * order ⌃⌥⇧⌘, a `Ctrl` binding as ⌘ (it answers to ⌘ there) and ⌃ only
245
+ * next to ⌘. `null` for a binding menus do not show: a mouse binding, and
246
+ * a `Cmd` one outside macOS.
247
+ */
248
+ export function keyLabel(binding, mac) {
249
+ if (binding.startsWith("Mouse:"))
250
+ return null;
251
+ const parts = binding.split("+");
252
+ const input = parts.pop();
253
+ const has = (mod) => parts.includes(mod);
254
+ const [ctrl, alt, shift, cmd] = ["Ctrl", "Alt", "Shift", "Cmd"].map(has);
255
+ if (cmd && !mac)
256
+ return null;
257
+ const mods = mac
258
+ ? `${ctrl && cmd ? "⌃" : ""}${alt ? "⌥" : ""}${shift ? "⇧" : ""}${ctrl || cmd ? "⌘" : ""}`
259
+ : parts.map((mod) => `${mod}+`).join("");
260
+ const digit = /^Numpad(\d)$/.exec(input)?.[1];
261
+ return mods + (NAMED_LABELS[input] ?? (digit ? `Num${digit}` : input));
262
+ }
@@ -242,6 +242,105 @@ class CommandHandle {
242
242
  pending.delete(this.id);
243
243
  }
244
244
  }
245
+ const NONE = Object.freeze([]);
246
+ const KEYS = "commands.keys";
247
+ /**
248
+ * One per plugin: the SDK's copy of the key labels of the plugin's own
249
+ * commands (player 0.7.6). Started by the first `keys` read or
250
+ * `onKeysChange`: it subscribes to `commands.keys` (a mirror, unless a
251
+ * listener or a binding holds it), then asks once; the player pushes every
252
+ * later change. Answers and events carry the binding table's revision, and
253
+ * an older one than what the copy has is dropped.
254
+ */
255
+ class KeysService {
256
+ #runtime;
257
+ #keys = new Map();
258
+ #revision = -1;
259
+ #started = false;
260
+ #listeners = new Set();
261
+ constructor(runtime) {
262
+ this.#runtime = runtime;
263
+ }
264
+ start() {
265
+ if (this.#started)
266
+ return;
267
+ this.#started = true;
268
+ const runtime = this.#runtime;
269
+ // Without `commands` the plugin has no commands: every read is [].
270
+ if (!runtime.can("commands"))
271
+ return;
272
+ runtime.on(KEYS, (data) => this.#accept(data), "commands", true);
273
+ // Subscribed first: a change after this answer comes as an event.
274
+ runtime.host.commands.keys().then((answer) => this.#accept(answer),
275
+ // An older player (`unsupported`): no keys, as without a binding.
276
+ () => { });
277
+ }
278
+ #accept(data) {
279
+ const answer = data;
280
+ const revision = answer?.revision;
281
+ const keys = answer?.keys;
282
+ if (typeof revision !== "number" || !keys || typeof keys !== "object")
283
+ return;
284
+ if (revision < this.#revision)
285
+ return;
286
+ this.#revision = revision;
287
+ const next = new Map();
288
+ for (const [id, labels] of Object.entries(keys))
289
+ if (Array.isArray(labels) && labels.length)
290
+ next.set(id, Object.freeze(labels.map(String)));
291
+ const changed = [];
292
+ for (const id of new Set([...this.#keys.keys(), ...next.keys()])) {
293
+ const before = this.#keys.get(id) ?? NONE;
294
+ const after = next.get(id) ?? NONE;
295
+ if (before.length !== after.length ||
296
+ before.some((label, at) => label !== after[at]))
297
+ changed.push(id);
298
+ // The same labels keep the same array.
299
+ else if (after !== NONE)
300
+ next.set(id, before);
301
+ }
302
+ this.#keys = next;
303
+ if (!changed.length)
304
+ return;
305
+ this.#runtime.changed(KEYS, changed);
306
+ for (const listener of [...this.#listeners])
307
+ listener();
308
+ }
309
+ get(id) {
310
+ return this.#keys.get(id) ?? NONE;
311
+ }
312
+ watch(listener) {
313
+ this.#listeners.add(listener);
314
+ const held = this.#runtime.hold(KEYS);
315
+ return {
316
+ dispose: async () => {
317
+ this.#listeners.delete(listener);
318
+ await held.dispose();
319
+ },
320
+ };
321
+ }
322
+ }
323
+ const keysOf = (runtime) => runtime.service("commands.keys", () => new KeysService(runtime));
245
324
  export const commands = (scope) => ({
246
325
  add: (options) => new CommandHandle(scope, options),
326
+ keys(id) {
327
+ if (typeof id !== "string")
328
+ throw failure("invalid", "keys takes a command id of this plugin");
329
+ const service = keysOf(scope.runtime);
330
+ service.start();
331
+ scope.runtime.reading(KEYS);
332
+ scope.runtime.depends(KEYS, id);
333
+ return service.get(id);
334
+ },
335
+ onKeysChange(listener) {
336
+ const service = keysOf(scope.runtime);
337
+ service.start();
338
+ const watch = service.watch(() => scope.run(listener));
339
+ return scope.add({
340
+ dispose: async () => {
341
+ scope.forget(watch);
342
+ await watch.dispose();
343
+ },
344
+ });
345
+ },
247
346
  });
@@ -13,6 +13,8 @@ const SDK_DIRECTORY = fileURLToPath(new URL("..", import.meta.url));
13
13
  export const WAKE_EVENTS = [
14
14
  "packages.changed",
15
15
  "bindings.changed",
16
+ // Player 0.7.6: sent only to the plugin whose own keys changed.
17
+ "commands.keys",
16
18
  "appearance.changed",
17
19
  "resolvers.fileTypes",
18
20
  ];
@@ -25,6 +27,7 @@ const STATE = {
25
27
  "window.state": { name: "plugin.window.state", when: true },
26
28
  "appearance.changed": { name: "plugin.appearance", when: true },
27
29
  "timeline.changed": { name: "plugin.timeline.marks", when: false },
30
+ "commands.keys": { name: "plugin.commands.keys", when: false },
28
31
  "playback.backend": { name: "plugin.subtitles.state", when: false },
29
32
  "resolvers.fileTypes": {
30
33
  name: "plugin.resolvers.fileTypes",
@@ -2403,6 +2403,34 @@ export declare class TestHost implements Transport {
2403
2403
  * ```
2404
2404
  */
2405
2405
  get conflicts(): string[][];
2406
+ /**
2407
+ * Change the keys of one of the plugin's commands as the user does in
2408
+ * Keyboard Shortcuts (SDK 1.6.6): `inputs` replaces its keys (`[]`
2409
+ * clears them, as Ignore Default Shortcuts does for every default),
2410
+ * `null` gives it its own defaults back. It writes
2411
+ * {@link TestHost.bindings | bindings.overlay}, and the plugin hears it
2412
+ * as from the player: `bindings.changed` and, when what
2413
+ * `plugin.commands.keys` reads changed, `commands.keys`.
2414
+ *
2415
+ * @example Check that a key hint follows a rebinding
2416
+ * The view showed W for Brightness −; after the user moves it to
2417
+ * Shift+W, it shows the new key. `rebind` changes the overlay and
2418
+ * pushes the new labels, and `settle` lets the binding run again. The
2419
+ * test host is a Mac unless `systemInfo.os` says otherwise, hence ⇧W.
2420
+ * ```ts
2421
+ * import assert from "node:assert/strict";
2422
+ * declare const host: TestHost;
2423
+ * // ---cut---
2424
+ * host.rebind("brightness.down", ["Shift+W"]);
2425
+ * await host.settle();
2426
+ * assert.equal(host.window("picture").props["brightness-down"], "⇧W");
2427
+ * ```
2428
+ *
2429
+ * @capability None.
2430
+ * @throws `Error` `command` is not a command of the plugin.
2431
+ * @since 1.6.6
2432
+ */
2433
+ rebind(command: string, inputs: readonly string[] | null): void;
2406
2434
  /**
2407
2435
  * The binding overlay `plugin.bindings.set` writes (`"<plugin>/<command>"`
2408
2436
  * to inputs; preset it to simulate saved overrides). `press` honours it.
@@ -2617,8 +2645,8 @@ export declare class TestHost implements Transport {
2617
2645
  * The plugin's bottom-bar buttons (`CommandOptions.button`) as the player
2618
2646
  * shows them: at most one, the first declared (`BAR_BUTTON_LIMITS`; a
2619
2647
  * later one stays a menu item only). Empty once the command is disposed.
2620
- * Whether the bar places them is the bar's business: the list is what
2621
- * the player hands to `BarButtons`.
2648
+ * Whether one is in the strip or its » menu is the player's business
2649
+ * (the room and the other plugins' buttons).
2622
2650
  *
2623
2651
  * @example Click the plugin's bottom-bar button in a test
2624
2652
  * The test finds the plugin's Store button, clicks it and checks that the
@@ -78,7 +78,7 @@ import { CAPABILITY_INFO, MESSAGE_LIMIT } from "../capabilities.js";
78
78
  import { OSD_LIMITS } from "./osd.js";
79
79
  import { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./services.js";
80
80
  import { SUBTITLE_LIMITS } from "./subtitles.js";
81
- import { BINDING_INPUTS, canonical } from "./bindings.js";
81
+ import { BINDING_INPUTS, canonical, keyLabel } from "./bindings.js";
82
82
  import { THEME_PALETTES } from "../palettes.js";
83
83
  import { IMAGE_LIMITS, UI_VALUE_LIMITS, valueLimitProblem } from "./values.js";
84
84
  import { validStep } from "./player.js";
@@ -3080,7 +3080,7 @@ export class TestHost {
3080
3080
  const scope = declaration.focus ? "panel" : "global";
3081
3081
  // The user overlay (`bindings.set`) replaces a command's own bindings.
3082
3082
  const overridden = this.bindings.overlay.get(this.#bindingKey(id));
3083
- for (const binding of overridden ?? declaration.bindings) {
3083
+ for (const binding of overridden ?? declaration.bindings ?? []) {
3084
3084
  const key = `${scope} ${binding}`;
3085
3085
  claims.set(key, [...(claims.get(key) ?? []), id]);
3086
3086
  }
@@ -3091,6 +3091,87 @@ export class TestHost {
3091
3091
  #bindingKey(id) {
3092
3092
  return this.coreCommands.has(id) ? id : `${this.manifestId}/${id}`;
3093
3093
  }
3094
+ /**
3095
+ * Change the keys of one of the plugin's commands as the user does in
3096
+ * Keyboard Shortcuts (SDK 1.6.6): `inputs` replaces its keys (`[]`
3097
+ * clears them, as Ignore Default Shortcuts does for every default),
3098
+ * `null` gives it its own defaults back. It writes
3099
+ * {@link TestHost.bindings | bindings.overlay}, and the plugin hears it
3100
+ * as from the player: `bindings.changed` and, when what
3101
+ * `plugin.commands.keys` reads changed, `commands.keys`.
3102
+ *
3103
+ * @example Check that a key hint follows a rebinding
3104
+ * The view showed W for Brightness −; after the user moves it to
3105
+ * Shift+W, it shows the new key. `rebind` changes the overlay and
3106
+ * pushes the new labels, and `settle` lets the binding run again. The
3107
+ * test host is a Mac unless `systemInfo.os` says otherwise, hence ⇧W.
3108
+ * ```ts
3109
+ * import assert from "node:assert/strict";
3110
+ * declare const host: TestHost;
3111
+ * // ---cut---
3112
+ * host.rebind("brightness.down", ["Shift+W"]);
3113
+ * await host.settle();
3114
+ * assert.equal(host.window("picture").props["brightness-down"], "⇧W");
3115
+ * ```
3116
+ *
3117
+ * @capability None.
3118
+ * @throws `Error` `command` is not a command of the plugin.
3119
+ * @since 1.6.6
3120
+ */
3121
+ rebind(command, inputs) {
3122
+ if (!this.commands.has(command))
3123
+ throw new Error(`test host: no command ${command}`);
3124
+ const key = this.#bindingKey(command);
3125
+ if (inputs === null)
3126
+ this.bindings.overlay.delete(key);
3127
+ else
3128
+ this.bindings.overlay.set(key, inputs.map(canonical));
3129
+ this.emit("bindings.changed", null);
3130
+ this.#keysChanged();
3131
+ }
3132
+ /** The labels `commands.keys` last answered or pushed, as JSON. */
3133
+ #keysSent = "";
3134
+ /** The binding table revision of `commands.keys` (a counter here). */
3135
+ #keysRevision = 0;
3136
+ /**
3137
+ * `commands.keys` as the player answers it: each of the plugin's own
3138
+ * commands with the labels of the keys it holds now (the overlay, else
3139
+ * its defaults; not a key an earlier command holds), as menus write
3140
+ * them on `systemInfo.os`.
3141
+ */
3142
+ #keys() {
3143
+ const claims = this.#claims();
3144
+ const mac = this.systemInfo.os === "macos";
3145
+ const keys = {};
3146
+ for (const [id, { declaration }] of this.commands) {
3147
+ const scope = declaration.focus ? "panel" : "global";
3148
+ const current = this.bindings.overlay.get(this.#bindingKey(id)) ??
3149
+ declaration.bindings ??
3150
+ [];
3151
+ const labels = [];
3152
+ for (const input of current) {
3153
+ if (claims.get(`${scope} ${input}`)?.[0] !== id)
3154
+ continue;
3155
+ const label = keyLabel(input, mac);
3156
+ if (label !== null && !labels.includes(label))
3157
+ labels.push(label);
3158
+ }
3159
+ keys[id] = labels;
3160
+ }
3161
+ return { keys, revision: this.#keysRevision };
3162
+ }
3163
+ /** Push `commands.keys` when the labels changed and the plugin listens. */
3164
+ #keysChanged() {
3165
+ this.#keysRevision++;
3166
+ if (!this.subscriptions.has("commands.keys"))
3167
+ return;
3168
+ const now = this.#keys();
3169
+ const text = JSON.stringify(now.keys);
3170
+ if (text === this.#keysSent)
3171
+ return;
3172
+ this.#keysSent = text;
3173
+ this.emit("commands.keys", now);
3174
+ }
3094
3175
  /**
3095
3176
  * The binding overlay `plugin.bindings.set` writes (`"<plugin>/<command>"`
3096
3177
  * to inputs; preset it to simulate saved overrides). `press` honours it.
@@ -3324,8 +3405,8 @@ export class TestHost {
3324
3405
  * The plugin's bottom-bar buttons (`CommandOptions.button`) as the player
3325
3406
  * shows them: at most one, the first declared (`BAR_BUTTON_LIMITS`; a
3326
3407
  * later one stays a menu item only). Empty once the command is disposed.
3327
- * Whether the bar places them is the bar's business: the list is what
3328
- * the player hands to `BarButtons`.
3408
+ * Whether one is in the strip or its » menu is the player's business
3409
+ * (the room and the other plugins' buttons).
3329
3410
  *
3330
3411
  * @example Click the plugin's bottom-bar button in a test
3331
3412
  * The test finds the plugin's Store button, clicks it and checks that the
@@ -4182,11 +4263,20 @@ export class TestHost {
4182
4263
  await host.settle();
4183
4264
  },
4184
4265
  });
4266
+ // The player pushes the new command's keys after the answer.
4267
+ queueMicrotask(() => this.#keysChanged());
4185
4268
  return { revision: 1 };
4186
4269
  },
4187
4270
  "commands.unregister": (p) => {
4188
4271
  if (!this.commands.delete(String(p.id)))
4189
4272
  throw new HostError("missing", `command ${p.id} is not registered`);
4273
+ queueMicrotask(() => this.#keysChanged());
4274
+ },
4275
+ // Player 0.7.6: the labels of the plugin's own commands.
4276
+ "commands.keys": () => {
4277
+ const now = this.#keys();
4278
+ this.#keysSent = JSON.stringify(now.keys);
4279
+ return now;
4190
4280
  },
4191
4281
  "commands.update": (p) => {
4192
4282
  // The request itself, as the player reads it (capabilities/commands.rs
@@ -5102,6 +5192,7 @@ export class TestHost {
5102
5192
  throw new HostError("missing", `no command ${key}`);
5103
5193
  this.bindings.overlay.set(key, [...p.inputs]);
5104
5194
  this.emit("bindings.changed", null);
5195
+ this.#keysChanged();
5105
5196
  },
5106
5197
  "bindings.reset": (p) => {
5107
5198
  if (p.plugin === undefined)
@@ -5109,6 +5200,7 @@ export class TestHost {
5109
5200
  else
5110
5201
  this.bindings.overlay.delete(`${p.plugin}/${p.command}`);
5111
5202
  this.emit("bindings.changed", null);
5203
+ this.#keysChanged();
5112
5204
  },
5113
5205
  "packages.list": () => structuredClone(this.packages),
5114
5206
  "packages.setEnabled": (p) => {
@@ -954,17 +954,17 @@ export type BarButtonIcon = "play" | "pause" | "stop" | "back" | "forward" | "op
954
954
  * A command's button on the bottom bar: {@link CommandOptions.button}.
955
955
  *
956
956
  * @remarks
957
- * **Where it shows.** The bottom bar places the player's strip of command
958
- * buttons, `BarButtons` from `@vestra/bar-buttons.slint`, at its right end;
959
- * the player fills it with the buttons of every plugin, left to right by
960
- * `order`, and its own sidebar toggle rightmost. When the window is narrow
961
- * the strip loses cells from its outer (left) end inward: the lowest order
962
- * goes first, the sidebar toggle last. A
963
- * bottom bar that does not place `BarButtons` shows no buttons: the
964
- * command's menu entry stays, and the player logs the buttons it cannot
965
- * show and whose bar hides them. The built-in bar (`vestra.playback`) places
966
- * it; see
967
- * [Writing a bottom bar](../../docs/guidelines/interface.md#writing-a-bottom-bar).
957
+ * **Where it shows.** The player draws the bottom bar's button strip and
958
+ * decides where it goes: today at the right end of the bottom bar; this may
959
+ * change (older players let the bar place `BarButtons` itself). Buttons
960
+ * are ordered by `order`, then by plugin id; lower `order` comes first and
961
+ * stays visible longest. When the strip runs out of room, or more than 8
962
+ * plugin buttons are declared, the remaining buttons move to the strip's »
963
+ * menu, in the same order. A button is never dropped: it is either in the
964
+ * strip or in the » menu, and its command keeps its menu entry, its key and
965
+ * its place in the command palette. Users may pin and unpin buttons later;
966
+ * do not rely on a position. See
967
+ * [Bottom-bar buttons](../../docs/explanation/commands.md#bottom-bar-buttons).
968
968
  *
969
969
  * **What it shows.** The icon, the command's title (translated, a trailing
970
970
  * "…" removed) and its first key as the tooltip ("Plugin Store (F9)"),
@@ -974,9 +974,9 @@ export type BarButtonIcon = "play" | "pause" | "stop" | "back" | "forward" | "op
974
974
  * a command with `checked` shows a small dot under its icon while checked.
975
975
  * A click runs the command exactly like a click on its menu item.
976
976
  *
977
- * **Limits.** One button per plugin and eight in the player
978
- * (`BAR_BUTTON_LIMITS`): a button past them is left out (its command and
979
- * menu entry stay) and the player logs it with `limit`. The button goes
977
+ * **Limits.** One button per plugin, the first it declares (a later one
978
+ * stays a menu item only), and eight in the strip (`BAR_BUTTON_LIMITS`); the
979
+ * rest are in the » menu. The button goes
980
980
  * with its command: disposing the command, or disabling, stopping or
981
981
  * uninstalling the plugin, removes it.
982
982
  *
@@ -984,8 +984,7 @@ export type BarButtonIcon = "play" | "pause" | "stop" | "back" | "forward" | "op
984
984
  * A Store icon appears in the bottom bar's button strip; a click opens
985
985
  * the store window, exactly like the menu item. The player draws the
986
986
  * button from the command's title, keys and state, and places it by
987
- * `order` (200 is where the player's own Plugin Store button sits); a
988
- * bar that does not place `BarButtons` shows only the menu item.
987
+ * `order` (200 is where the player's own Plugin Store button sits).
989
988
  * ```ts
990
989
  * const store = plugin.windows.create("ui/store.slint");
991
990
  * plugin.commands.add({
@@ -1238,8 +1237,8 @@ export interface CommandOptions {
1238
1237
  /**
1239
1238
  * A button for this command on the bottom bar (SDK 1.5.29, player 0.5.10):
1240
1239
  * an icon and a place, `{ icon: "store", order: 200 }`. Needs a `path`: the
1241
- * menu entry is where the command stays reachable when the bar shows no
1242
- * button (a bar without `BarButtons`, a narrow window, a limit). See
1240
+ * menu entry is where the command stays reachable wherever the button
1241
+ * is (the strip or its » menu). See
1243
1242
  * {@link CommandButton} for where it shows, what it shows and the limits.
1244
1243
  * Fails with `invalid` for an unknown icon name, an order outside 0–999 or
1245
1244
  * a button without a path.
@@ -1451,6 +1450,81 @@ export interface Commands {
1451
1450
  * @since 1.0.0
1452
1451
  */
1453
1452
  add(options: CommandOptions): Command;
1453
+ /**
1454
+ * The keys of one of this plugin's own commands (its local id, as in
1455
+ * {@link CommandOptions.id}), written as the player's menus and command
1456
+ * palette write them: `["W"]`, `["Ctrl+B"]`, `["⌘B"]` on macOS, arrows as
1457
+ * `←`. Read-only, and always what is bound now: the user's own keys
1458
+ * after a change in Keyboard Shortcuts, none when the user cleared them
1459
+ * or turned on Ignore Default Shortcuts, and none for a key another
1460
+ * command holds. Mouse bindings are left out, and so are `Cmd`
1461
+ * bindings outside macOS (as in the menus). The first label is the one
1462
+ * the menus show.
1463
+ *
1464
+ * @remarks
1465
+ * For key hints beside a panel's controls (`KeyHint` and the `keys` of
1466
+ * `ToolTipArea` in `@vestra/widgets.slint`). Read it in a
1467
+ * {@link View.bind} function and the view follows every change; or
1468
+ * listen with {@link Commands.onKeysChange}. The first read asks the
1469
+ * player, and until its answer arrives (a few milliseconds) every
1470
+ * command reads `[]`; a binding that read it runs again then.
1471
+ *
1472
+ * Only the plugin's own commands: an id the plugin has not added (or
1473
+ * another plugin's) reads `[]`. Needs nothing beyond `commands`, not
1474
+ * `bindings.manage` (the whole binding table, every plugin's, is
1475
+ * {@link Bindings.list}). Without the `commands` capability every
1476
+ * command reads `[]`. Players before 0.7.6 have no `keys` (the player
1477
+ * builds `plugin` from its own SDK) and no `KeyHint` in their widgets,
1478
+ * so declare `"minPlayerVersion": "0.7.6"`.
1479
+ *
1480
+ * @example Show a slider's keys beside it
1481
+ * The Brightness row shows its two keys, W and E by default, as key caps
1482
+ * (`KeyHint` in `ui/picture.slint`). `keys` is read inside the `bind` function,
1483
+ * so the view gets the new labels whenever the user changes a key or
1484
+ * clears it (an empty list hides the cap).
1485
+ * ```ts
1486
+ * const pictureWindow = plugin.windows.create("ui/picture.slint");
1487
+ * plugin.commands.add({ id: "brightness.down", path: ["@video", "Brightness −"], bindings: ["W"], run() {} });
1488
+ * plugin.commands.add({ id: "brightness.up", path: ["@video", "Brightness +"], bindings: ["E"], run() {} });
1489
+ * pictureWindow.bind(() => ({
1490
+ * // Read in bind: the view follows every key change; "" hides a cap.
1491
+ * brightnessDown: plugin.commands.keys("brightness.down")[0] ?? "",
1492
+ * brightnessUp: plugin.commands.keys("brightness.up")[0] ?? "",
1493
+ * }));
1494
+ * ```
1495
+ *
1496
+ * @capability commands
1497
+ * @player 0.7.6 (declare `"minPlayerVersion": "0.7.6"`; older players
1498
+ * have no `keys`: calling it throws a `TypeError`).
1499
+ * @since 1.6.6
1500
+ */
1501
+ keys(id: string): readonly string[];
1502
+ /**
1503
+ * Run `listener` after the keys of any of this plugin's commands changed
1504
+ * (read them again with {@link Commands.keys}): the user changed or
1505
+ * cleared a key, turned Ignore Default Shortcuts on or off, a command with
1506
+ * keys was added or removed, or the player's first answer arrived. At most every
1507
+ * 200 ms. A plugin in standby that listens is started for it, as for a
1508
+ * key change in {@link Bindings.onChange}.
1509
+ *
1510
+ * @capability commands
1511
+ *
1512
+ * @example Name the current key in a status line
1513
+ * The OSD tells the user the new key after they change it. The listener
1514
+ * carries no data; it reads the labels with `keys`.
1515
+ * ```ts
1516
+ * plugin.commands.onKeysChange(() => {
1517
+ * const [key] = plugin.commands.keys("toggle-blur");
1518
+ * if (key) void plugin.osd.show("Blur: {}", [key]);
1519
+ * else void plugin.osd.show("Blur has no key");
1520
+ * });
1521
+ * ```
1522
+ *
1523
+ * @player 0.7.6 (declare `"minPlayerVersion": "0.7.6"`; older players
1524
+ * have no `onKeysChange`).
1525
+ * @since 1.6.6
1526
+ */
1527
+ onKeysChange(listener: () => void): Disposable;
1454
1528
  }
1455
1529
  /**
1456
1530
  * An image file for a Slint `image` property, made by {@link Ui.image}.
package/dist/wire.d.ts CHANGED
@@ -441,7 +441,7 @@ export interface Transport {
441
441
  locale?: string;
442
442
  }
443
443
  /** The events a plugin subscribes to (`events.subscribe`). */
444
- type HostEvent = "source.state" | "playback.backend" | "window.state" | "packages.changed" | "bindings.changed" | "timeline.changed" | "appearance.changed" | "resolvers.fileTypes" | "video.upscaling.changed";
444
+ type HostEvent = "source.state" | "playback.backend" | "window.state" | "packages.changed" | "bindings.changed" | "commands.keys" | "timeline.changed" | "appearance.changed" | "resolvers.fileTypes" | "video.upscaling.changed";
445
445
  export declare function createWire(transport: Transport): {
446
446
  processing: {
447
447
  state: () => Promise<import("./processing.ts").ProcessingState>;
@@ -453,6 +453,7 @@ export declare function createWire(transport: Transport): {
453
453
  revision: number;
454
454
  }>;
455
455
  unregister: (id: string) => Promise<void>;
456
+ keys: () => Promise<import("./commands.ts").CommandKeys>;
456
457
  update: (id: string, revision: number, state: import("./commands.ts").CommandState, when?: import("./commands.ts").CommandWhen) => Promise<{
457
458
  revision: number;
458
459
  }>;
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vestra Player SDK
2
2
 
3
- > @vestra-player/sdk 1.6.5: write plugins for Vestra Player in TypeScript (plugin API `vestra/1`). A plugin is a folder with `manifest.json` and an entry module whose default export `activate(plugin)` the player calls. The documentation is a tree of short pages read by path, like `go doc`: the same pages and API as https://vestrasys.github.io/vestra-player-sdk/.
3
+ > @vestra-player/sdk 1.6.8: write plugins for Vestra Player in TypeScript (plugin API `vestra/1`). A plugin is a folder with `manifest.json` and an entry module whose default export `activate(plugin)` the player calls. The documentation is a tree of short pages read by path, like `go doc`: the same pages and API as https://vestrasys.github.io/vestra-player-sdk/.
4
4
 
5
5
  ## Read the documentation
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vestra-player/sdk",
3
- "version": "1.6.5",
3
+ "version": "1.6.8",
4
4
  "description": "Plugin API types, test host and plugin CLI for Vestra Player (apiVersion vestra/1)",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -1,15 +1,26 @@
1
- // Command buttons of a bottom bar (player 0.5.10, SDK 1.5.29): the buttons
1
+ // Command buttons of the bottom bar (player 0.5.10, SDK 1.5.29): the buttons
2
2
  // that plugins' commands contribute (`commands.add({ button })`) and the
3
- // player's sidebar toggle, rightmost. The player fills `BarButtonsHost` in
4
- // every window it composes and runs the clicks; a bottom bar places one
5
- // `BarButtons` in its own layout and gives it the width it can spare:
6
- // import { BarButtons } from "@vestra/bar-buttons.slint";
7
- // strip := BarButtons { x: parent.width - self.width - 4px; room: ...; }
8
- // It shows as many 41×40 cells as fit in `room`, the strip as wide as the
9
- // cells shown (`width`). Cells go from the strip's outer (left) end inward:
10
- // of the plugins' buttons (ascending `order`, left to right) the lowest
11
- // order goes first, and the sidebar toggle goes last. A bar without a `BarButtons` shows none of them (their menu
12
- // entries stay; the player logs which buttons are hidden).
3
+ // player's own. Since SDK 1.6.8 (menu redesign step 3a) the player draws
4
+ // the strip itself, at the right end of the bottom slot, its 40px cells on
5
+ // the slot's bottom edge (a bar's 40px button row), and also when no plugin
6
+ // mounts a bottom bar: `[plugin buttons…][»][Command Palette][Toggle
7
+ // Sidebar]`. A bottom bar places nothing: it lays its controls out left of
8
+ // the strip and says how wide they must stay:
9
+ // import { BarButtonsHost } from "@vestra/bar-buttons.slint";
10
+ // export component MyBar inherits Rectangle {
11
+ // out property <length> content-min-width: ...; // the least your row needs
12
+ // // your row ends at: root.width - BarButtonsHost.width - 4px
13
+ // }
14
+ // The player gives its strip `bar width - content-min-width - 4px` (all of
15
+ // the width without `content-min-width`), so plugin buttons never cover
16
+ // what the bar needs (its time label, UI-BAR-10). Plugin buttons go left to
17
+ // right by ascending `order`, then plugin id, then registration; at most 8
18
+ // are in the strip, and the ones past 8 or past the room are in the `»`
19
+ // menu, in the same order: the lowest order stays longest. `»` shows only
20
+ // while something is in it; the player's own cells always show.
21
+ //
22
+ // `BarButtons` (below) is deprecated: the strip a bar placed itself before
23
+ // SDK 1.6.8. While a bar still places it, the player draws no second strip.
13
24
  //
14
25
  // The cells look like the bar's own icon buttons: a 32×32 plate on hover
15
26
  // and press, the bar's tooltip (ToolTipArea). Every enabled cell draws its
@@ -43,14 +54,56 @@ export struct BarButtonItem {
43
54
  dimmed: bool,
44
55
  }
45
56
 
46
- // Filled by the player; a bar reads it only through `BarButtons`.
57
+ // Filled by the player. A bar reads `width` (to end its own row left of
58
+ // the strip) and `fixed`; everything else is the player's.
47
59
  export global BarButtonsHost {
48
- // The plugins' buttons, left to right, at most 8.
60
+ // The plugins' buttons for the strip, left to right, at most 8.
49
61
  in property <[BarButtonItem]> buttons;
62
+ // How many plugin buttons are past those 8 (always in the `»` menu).
63
+ in property <int> overflow;
50
64
  // The sidebar toggle (`token` "" while there is none).
51
65
  in property <BarButtonItem> toggle;
52
- // Set when a `BarButtons` exists in this window.
66
+ // The command palette (`host/palette`; `token` "" while there is none).
67
+ in property <BarButtonItem> palette;
68
+ // Set when a (deprecated) `BarButtons` exists in this window.
53
69
  in-out property <bool> placed;
70
+ // The width the player gives its strip (the player sets it).
71
+ in-out property <length> room;
72
+ // The player's own cells, always shown (step 3b adds Adjust).
73
+ private property <int> own: (root.palette.token != "" ? 1 : 0) + (root.toggle.token != "" ? 1 : 0);
74
+ // The cells a bar must always leave free, 41px each: the player's own,
75
+ // and the `»` cell whenever any plugin button exists (drawn only on
76
+ // overflow, but always reserved, so `»` never covers the bar). Never
77
+ // depends on `room`. A bar that keeps an optional control ahead of the
78
+ // plugins' buttons counts it in `content-min-width` while it fits
79
+ // beside these.
80
+ out property <int> fixed: root.own + (root.buttons.length > 0 || root.overflow > 0 ? 1 : 0);
81
+ // The strip's layout for a room, as pure functions, so the player's
82
+ // strip binds them to its own `room` (no frame behind the shell's
83
+ // layout) and the properties below give the same numbers for `room`.
84
+ // The plugin buttons in the strip (the first of `buttons`) at `room`.
85
+ public pure function shown-in(room: length) -> int {
86
+ // Cells left for plugin buttons and `»`.
87
+ let free = Math.max(0, Math.floor(room / 41px) - root.own);
88
+ if root.overflow == 0 && root.buttons.length <= free {
89
+ return root.buttons.length;
90
+ }
91
+ return Math.max(0, Math.min(root.buttons.length, free - 1));
92
+ }
93
+ // Whether `»` shows at `room` (a button past 8 or past the room).
94
+ public pure function more-in(room: length) -> bool {
95
+ return root.shown-in(room) < root.buttons.length || root.overflow > 0;
96
+ }
97
+ // The strip's width at `room` (0 while a bar places `BarButtons`).
98
+ public pure function width-in(room: length) -> length {
99
+ if root.placed {
100
+ return 0px;
101
+ }
102
+ return (root.shown-in(room) + (root.more-in(room) ? 1 : 0) + root.own) * 41px;
103
+ }
104
+ out property <int> shown: root.shown-in(root.room);
105
+ out property <bool> more: root.more-in(root.room);
106
+ out property <length> width: root.width-in(root.room);
54
107
  // A click on the cell with this token.
55
108
  callback clicked(string);
56
109
  }
@@ -127,8 +180,11 @@ component Cell inherits Rectangle {
127
180
  }
128
181
  }
129
182
 
130
- // The strip: place it at the bar's right end and give it the width you can
131
- // spare; it is as wide as the cells it shows (read `width` to lay out the rest).
183
+ // Deprecated (SDK 1.6.8; the player places its strip): the strip a bar
184
+ // placed at its right end with the width it could spare. A bar that still
185
+ // places it shows this one instead of the player's (no palette cell, no
186
+ // `»`): the cells that fit in `room`, lost from its outer (left) end
187
+ // inward, the sidebar toggle last; it is as wide as the cells it shows.
132
188
  export component BarButtons {
133
189
  // The width the bar can give the strip; the cells that fit show.
134
190
  in property <length> room;
@@ -1,5 +1,5 @@
1
1
  // Compact controls built on the Vestra theme (core-ui-design.md §2.1):
2
- // import { Button, Slider, ToolTipArea, UrlInputRow } from "@vestra/widgets.slint";
2
+ // import { Button, KeyHint, Slider, ToolTipArea, ToolTipKey, UrlInputRow } from "@vestra/widgets.slint";
3
3
  // import { Choice, Check, Radio, RadioGroup, RadioMark, Tab, Tabs } from "@vestra/widgets.slint";
4
4
  // Icons: @vestra/icons.slint. Other controls come from std-widgets, which the
5
5
  // host compiles as fluent-dark and switches to light for a light palette.
@@ -9,10 +9,48 @@
9
9
  // so a click on a panel control leaves the player's shortcuts (Space, the
10
10
  // arrows) with the video, as on macOS. A focused control shows a 1px accent
11
11
  // ring.
12
- import { TextEdit, ListView } from "std-widgets.slint";
12
+ import { TextEdit, ListView, Palette } from "std-widgets.slint";
13
13
  import { Theme } from "theme.slint";
14
14
  import { Icon } from "icons.slint";
15
15
 
16
+ // A key cap: the label of a shortcut as the player's menus write it
17
+ // (`plugin.commands.keys`: `W`, `Ctrl+B`, `⌘B` on macOS), in small
18
+ // monospace `text-dim` type inside a 1px border. Put it at the right end of
19
+ // a control's row, the minus key before the plus key (`W` `E`). An empty
20
+ // `text` draws nothing and takes no space, so a command without a key (or
21
+ // one the user cleared) shows no cap; the element stays the same either way.
22
+ export component KeyHint {
23
+ in property <string> text;
24
+ private property <bool> shown: root.text != "";
25
+ visible: root.shown;
26
+ width: root.shown ? max(16px, label.preferred-width + 8px) : 0px;
27
+ height: root.shown ? 15px : 0px;
28
+ accessible-role: AccessibleRole.text;
29
+ accessible-label: root.text;
30
+ Rectangle {
31
+ border-width: 1px;
32
+ border-radius: 3px;
33
+ // The cap's edge: a step above the panel in both palettes.
34
+ border-color: Theme.icon-disabled;
35
+ background: Theme.bg-list;
36
+ label := Text {
37
+ text: root.text;
38
+ font-family: Theme.font-mono;
39
+ font-size: Theme.font-xs;
40
+ color: Theme.text-dim;
41
+ horizontal-alignment: center;
42
+ vertical-alignment: center;
43
+ }
44
+ }
45
+ }
46
+
47
+ // One key in a tooltip (`ToolTipArea.keys`): its label (`KeyHint`) and what
48
+ // it does, a word or a sign (`−`, `+`). An empty `key` is left out.
49
+ export struct ToolTipKey {
50
+ key: string,
51
+ text: string,
52
+ }
53
+
16
54
  // Hover for about half a second to show `text` beside the pointer. The tip
17
55
  // closes when the pointer leaves, or presses on content that does not take
18
56
  // the press itself; it shows again on the next hover. It is Slint's
@@ -20,8 +58,16 @@ import { Icon } from "icons.slint";
20
58
  // pointer, and the tip does not take the keyboard focus. (Up to SDK 1.5.20 the tip was a
21
59
  // PopupWindow, which swallowed the first press after it showed and took the
22
60
  // focus while it was open.)
61
+ // With `keys` (SDK 1.6.6) the tip names the control's keys after its title,
62
+ // each as a key cap and what it does: "Brightness [W] − [E] +"; `detail`
63
+ // follows dimmed ("· double-click: Default"). Changing `keys` or `detail`
64
+ // changes the tip's text, never which elements exist while hovering.
23
65
  export component ToolTipArea {
24
66
  in property <string> text;
67
+ in property <[ToolTipKey]> keys;
68
+ in property <string> detail;
69
+ // The tip names keys or a detail: drawn with key caps.
70
+ private property <bool> rich: root.keys.length > 0 || root.detail != "";
25
71
  // The tip is showing (read-only); it follows the tip's delay closely,
26
72
  // not exactly.
27
73
  out property <bool> showing;
@@ -44,10 +90,56 @@ export component ToolTipArea {
44
90
  }
45
91
  }
46
92
  // Removing it closes an open tip.
47
- if root.text != "" && !root.done: Tooltip {
93
+ if root.text != "" && !root.done && !root.rich: Tooltip {
48
94
  // Interpolated text is plain: `*` or `_` in it are not markup.
49
95
  text: @markdown("\{root.text}");
50
96
  }
97
+ // The tip with key caps, drawn as the text tip is (std-widgets'
98
+ // ToolTipImpl). Its own element: one Tooltip per parent.
99
+ if root.text != "" && !root.done && root.rich: Rectangle {
100
+ width: 100%;
101
+ height: 100%;
102
+ Tooltip {
103
+ Rectangle {
104
+ background: Palette.alternate-background;
105
+ border-radius: 4px;
106
+ border-width: 1px;
107
+ border-color: Palette.border;
108
+ HorizontalLayout {
109
+ padding-left: 9px;
110
+ padding-right: 9px;
111
+ padding-top: 6px;
112
+ padding-bottom: 6px;
113
+ Text {
114
+ text: root.text;
115
+ color: Palette.foreground;
116
+ vertical-alignment: center;
117
+ }
118
+ for entry in root.keys: HorizontalLayout {
119
+ padding-left: entry.key == "" ? 0px : 10px;
120
+ spacing: entry.key == "" ? 0px : 4px;
121
+ VerticalLayout {
122
+ alignment: center;
123
+ KeyHint { text: entry.key; }
124
+ }
125
+ Text {
126
+ text: entry.key == "" ? "" : entry.text;
127
+ color: Palette.foreground;
128
+ vertical-alignment: center;
129
+ }
130
+ }
131
+ HorizontalLayout {
132
+ padding-left: root.detail == "" ? 0px : 10px;
133
+ Text {
134
+ text: root.detail;
135
+ color: Theme.text-dim;
136
+ vertical-alignment: center;
137
+ }
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
51
143
  }
52
144
  Timer {
53
145
  interval: 500ms;