@blastlauncher/client-core 3.0.0-alpha.1 → 3.0.0-alpha.2

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.
@@ -1,7 +1,8 @@
1
1
  import type { SceneMessage } from "@blastlauncher/protocol";
2
2
  import { type OutboundEvent } from "./nodes.js";
3
- import { type FormInput, type FormFieldView } from "./form-view.js";
4
- export type { FormFieldView } from "./form-view.js";
3
+ import { type IconAppearance, type IconMask, type IconSource } from "./icons.js";
4
+ import { type FormInput, type FormFieldView, type FormItemView } from "./form-view.js";
5
+ export type { FormFieldView, FormItemView } from "./form-view.js";
5
6
  import { type KeyInput, type Platform } from "./shortcut.js";
6
7
  export type ControllerInput = FormInput | {
7
8
  type: "search";
@@ -70,6 +71,8 @@ export type RowView = {
70
71
  selected: boolean;
71
72
  iconLabel: string;
72
73
  iconTint?: string;
74
+ iconSource?: IconSource;
75
+ iconMask?: IconMask;
73
76
  /** The row as one line, for clients that do not lay columns out. */
74
77
  text: string;
75
78
  };
@@ -79,6 +82,9 @@ export type GridCellView = {
79
82
  subtitle: string;
80
83
  /** The cell content as short text: image source name or color. */
81
84
  content: string;
85
+ /** Set when the content is an image: what to draw for it. */
86
+ iconSource?: IconSource;
87
+ iconMask?: IconMask;
82
88
  selected: boolean;
83
89
  };
84
90
  export type GridRowView = {
@@ -105,6 +111,10 @@ export type MenuRowView = {
105
111
  kind: "item" | "submenu" | "section";
106
112
  title: string;
107
113
  subtitle: string;
114
+ iconLabel: string;
115
+ iconTint?: string;
116
+ iconSource?: IconSource;
117
+ iconMask?: IconMask;
108
118
  shortcut: string;
109
119
  disabled: boolean;
110
120
  selected: boolean;
@@ -129,6 +139,8 @@ export type ControllerView = {
129
139
  description: string;
130
140
  iconLabel: string;
131
141
  iconTint?: string;
142
+ iconSource?: IconSource;
143
+ iconMask?: IconMask;
132
144
  } | null;
133
145
  detail: string;
134
146
  primary: {
@@ -192,6 +204,7 @@ export type ControllerView = {
192
204
  selectedId: string | null;
193
205
  } | null;
194
206
  form: {
207
+ items: FormItemView[];
195
208
  fields: FormFieldView[];
196
209
  focusId: string | null;
197
210
  } | null;
@@ -215,5 +228,11 @@ export type ControllerOptions = {
215
228
  hudDurationMs?: number;
216
229
  /** Overrides how long success and failure toasts stay visible. */
217
230
  toastDurationMs?: number;
231
+ /** Shown when the scene has no navigation title, typically the command title. */
232
+ fallbackTitle?: string;
233
+ /** Which light/dark variant of icon sources and tints the view carries. Default: "light". */
234
+ appearance?: IconAppearance;
235
+ /** Absolute directory that relative icon image names resolve under (the extension's `assets/`). */
236
+ assetsDir?: string;
218
237
  };
219
238
  export declare function createController(options: ControllerOptions): Controller;
@@ -1,14 +1,14 @@
1
- import { event, handler, selectionId, text } from "./nodes.js";
1
+ import { event, handler, handlerEvent, selectionId, text } from "./nodes.js";
2
2
  import { activeNode, actionRows, defaultActionShortcut, panelRows, sceneToView } from "./view.js";
3
- import { contentGlyph, gridMove } from "./grid.js";
4
- import { iconView } from "./icons.js";
3
+ import { contentGlyph, contentIcon, gridMove } from "./grid.js";
4
+ import { iconFields, iconView, } from "./icons.js";
5
5
  import { dropdownLabel, dropdownOptions, visibleOptions } from "./dropdown.js";
6
- import { createFormController } from "./form-view.js";
6
+ import { createFormController, } from "./form-view.js";
7
7
  import { loadMoreRequest } from "./pagination.js";
8
8
  import { searchDispatch } from "./search.js";
9
9
  import { feedbackState, TOAST_DURATION_MS } from "./feedback.js";
10
10
  import { HUD_DURATION_MS } from "./hud.js";
11
- import { menuBarStep, menuBarView } from "./menu-bar.js";
11
+ import { menuBarStep, menuBarView, menuShortcut } from "./menu-bar.js";
12
12
  import { matchesShortcut, shortcutLabel } from "./shortcut.js";
13
13
  const fresh = () => ({ events: [], effects: [] });
14
14
  const send = (out, value) => {
@@ -20,6 +20,10 @@ const clamp = (value, length) => Math.max(0, Math.min(length - 1, value));
20
20
  const runName = (node) => handler(node?.props.onAction) ? "onAction" : "onSubmit";
21
21
  export function createController(options) {
22
22
  const { platform } = options;
23
+ const icons = {
24
+ appearance: options.appearance ?? "light",
25
+ ...(options.assetsDir === undefined ? {} : { assetsDir: options.assetsDir }),
26
+ };
23
27
  let current;
24
28
  const form = createFormController();
25
29
  let searchText = "";
@@ -72,7 +76,12 @@ export function createController(options) {
72
76
  deferred.set(key, { token, event: value.event });
73
77
  out.effects.push({ type: "schedule", ms: value.delayMs, token });
74
78
  };
75
- const base = () => sceneToView(current, { searchText, selectedId });
79
+ const base = () => sceneToView(current, {
80
+ searchText,
81
+ selectedId,
82
+ ...(options.fallbackTitle ? { fallbackTitle: options.fallbackTitle } : {}),
83
+ icons,
84
+ });
76
85
  const isMenu = (view) => view.list?.type === "MenuBarExtra";
77
86
  const panelNodeOf = (view) => {
78
87
  let node = view.actions;
@@ -327,9 +336,25 @@ export function createController(options) {
327
336
  }
328
337
  function shortcutKey(out, view, press) {
329
338
  const feedback = feedbackState(current);
330
- if (mode(view, !!feedback.alert) !== "base")
339
+ const level = mode(view, !!feedback.alert);
340
+ if (level === "menu") {
341
+ // A menu item's key equivalent works while its menu is open, as in a
342
+ // native menu, from any submenu level.
343
+ const item = menuShortcut(view.list, press, platform);
344
+ if (!item)
345
+ return;
346
+ out.handled = true;
347
+ const value = handlerEvent(item, item.props.onAction, [{ type: "left-click" }]);
348
+ if (value)
349
+ send(out, value);
350
+ return;
351
+ }
352
+ // Raycast runs an action's shortcut with its action panel open too, and
353
+ // closes the panel; alerts and dropdowns keep every key to themselves.
354
+ if (level !== "base" && level !== "panel")
331
355
  return;
332
- const toast = toastVisible &&
356
+ const toast = level === "base" &&
357
+ toastVisible &&
333
358
  feedback.toast?.actions.find((action) => matchesShortcut(action.shortcut, press, platform));
334
359
  if (toast) {
335
360
  out.handled = true;
@@ -347,6 +372,9 @@ export function createController(options) {
347
372
  if (!shortcut)
348
373
  return;
349
374
  out.handled = true;
375
+ panel = null;
376
+ panelQuery = "";
377
+ panelIndex = 0;
350
378
  if (shortcut.node.type === "ActionPanel.Submenu") {
351
379
  send(out, event(shortcut.node, "onOpen"));
352
380
  openPanel([shortcut.node.id]);
@@ -482,7 +510,7 @@ export function createController(options) {
482
510
  accessories: row.accessories ?? "",
483
511
  icon: row.icon === true,
484
512
  iconLabel: row.iconView?.label ?? "",
485
- ...(row.iconView?.tint ? { iconTint: row.iconView.tint } : {}),
513
+ ...iconFields(row.iconView),
486
514
  count: row.count ?? null,
487
515
  selected: item && row.node.id === list.selected?.id,
488
516
  text: row.text,
@@ -490,7 +518,7 @@ export function createController(options) {
490
518
  };
491
519
  const actions = panelState(list);
492
520
  const dropdown = dropdownState(list);
493
- const bar = menuBarView(current, { path: menuPath, selectedId }, platform);
521
+ const bar = menuBarView(current, { path: menuPath, selectedId }, platform, icons);
494
522
  const primaryNode = list.primary;
495
523
  let breadcrumb = [];
496
524
  if (panel?.length) {
@@ -504,7 +532,7 @@ export function createController(options) {
504
532
  const alert = feedback.alert;
505
533
  const dismiss = alert?.dismiss;
506
534
  const confirm = alert?.primary;
507
- const emptyIcon = list.empty?.props.icon ? iconView(list.empty.props.icon) : undefined;
535
+ const emptyIcon = list.empty?.props.icon ? iconView(list.empty.props.icon, icons) : undefined;
508
536
  return {
509
537
  kind,
510
538
  title: list.title,
@@ -519,13 +547,18 @@ export function createController(options) {
519
547
  ? list.grid.rows.map((row) => ({
520
548
  id: row.node ? row.node.id : null,
521
549
  header: row.header,
522
- cells: row.cells.map((cell) => ({
523
- id: selectionId(cell) ?? cell.id,
524
- title: text(cell.props.title),
525
- subtitle: text(cell.props.subtitle),
526
- content: contentGlyph(cell),
527
- selected: cell.id === list.selected?.id,
528
- })),
550
+ cells: row.cells.map((cell) => {
551
+ const image = contentIcon(cell, icons);
552
+ return {
553
+ id: selectionId(cell) ?? cell.id,
554
+ title: text(cell.props.title),
555
+ subtitle: text(cell.props.subtitle),
556
+ content: contentGlyph(cell),
557
+ ...(image?.source ? { iconSource: image.source } : {}),
558
+ ...(image?.mask ? { iconMask: image.mask } : {}),
559
+ selected: cell.id === list.selected?.id,
560
+ };
561
+ }),
529
562
  }))
530
563
  : null,
531
564
  selectedId: selected,
@@ -535,7 +568,7 @@ export function createController(options) {
535
568
  title: text(list.empty.props.title),
536
569
  description: text(list.empty.props.description),
537
570
  iconLabel: emptyIcon?.label ?? "",
538
- ...(emptyIcon?.tint ? { iconTint: emptyIcon.tint } : {}),
571
+ ...iconFields(emptyIcon),
539
572
  }
540
573
  : null,
541
574
  detail: list.detail,
@@ -611,6 +644,10 @@ export function createController(options) {
611
644
  kind: !row.item ? "section" : row.isSubmenu ? "submenu" : "item",
612
645
  title: row.title ?? "",
613
646
  subtitle: row.subtitle ?? "",
647
+ iconLabel: row.iconLabel ?? "",
648
+ ...(row.iconTint ? { iconTint: row.iconTint } : {}),
649
+ ...(row.iconSource ? { iconSource: row.iconSource } : {}),
650
+ ...(row.iconMask ? { iconMask: row.iconMask } : {}),
614
651
  shortcut: row.shortcut ?? "",
615
652
  disabled: row.disabled,
616
653
  selected: row.item && row.node.id === bar.selected?.node.id,
@@ -38,6 +38,7 @@ export type FormFieldView = {
38
38
  id: string;
39
39
  type: string;
40
40
  title: string;
41
+ required: boolean;
41
42
  info: string;
42
43
  label: string;
43
44
  placeholder: string;
@@ -58,6 +59,18 @@ export type FormFieldView = {
58
59
  query: string;
59
60
  focused: boolean;
60
61
  };
62
+ export type FormItemView = {
63
+ kind: "field";
64
+ id: string;
65
+ } | {
66
+ kind: "separator";
67
+ id: string;
68
+ } | {
69
+ kind: "description";
70
+ id: string;
71
+ title: string;
72
+ text: string;
73
+ };
61
74
  /** Form-only client state. Password contents never enter this state or its view. */
62
75
  export declare function createFormController(): {
63
76
  active: (next: boolean, events: OutboundEvent[]) => void;
@@ -67,6 +80,7 @@ export declare function createFormController(): {
67
80
  }>, events: OutboundEvent[]): void;
68
81
  view(): {
69
82
  focusId: string | null;
83
+ items: FormItemView[];
70
84
  fields: FormFieldView[];
71
85
  };
72
86
  };
package/dist/form-view.js CHANGED
@@ -200,6 +200,22 @@ export function createFormController() {
200
200
  view() {
201
201
  return {
202
202
  focusId,
203
+ items: (form?.children ?? []).flatMap((node) => {
204
+ if (editable.has(node.type))
205
+ return [{ kind: "field", id: node.id }];
206
+ if (node.type === "Form.Separator")
207
+ return [{ kind: "separator", id: node.id }];
208
+ if (node.type === "Form.Description")
209
+ return [
210
+ {
211
+ kind: "description",
212
+ id: node.id,
213
+ title: text(node.props.title),
214
+ text: text(node.props.text),
215
+ },
216
+ ];
217
+ return [];
218
+ }),
203
219
  fields: fields.map((field) => {
204
220
  const password = field.type === "Form.PasswordField";
205
221
  const value = password ? null : valueOf(field);
@@ -209,7 +225,8 @@ export function createFormController() {
209
225
  id: field.id,
210
226
  type: field.type,
211
227
  title: text(field.props.title ?? field.props.id),
212
- info: text(field.props.info),
228
+ required: field.props.required === true,
229
+ info: focusId === field.id ? text(field.props.info) : "",
213
230
  label: text(field.props.label),
214
231
  placeholder: text(field.props.placeholder),
215
232
  error: draft?.error || text(field.props.error),
package/dist/grid.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { SceneNode } from "@blastlauncher/protocol";
2
+ import { type IconOptions, type IconView } from "./icons.js";
2
3
  import { type FilteringOptions } from "./rank.js";
3
4
  export type GridRow = {
4
5
  node: SceneNode | undefined;
@@ -12,6 +13,8 @@ export type GridLayout = {
12
13
  export type GridDirection = "up" | "down" | "left" | "right";
13
14
  /** The content of a cell as short text: the image source name or a color. */
14
15
  export declare function contentGlyph(node: SceneNode): string;
16
+ /** The drawable image of a cell, when its content is an image rather than a color. */
17
+ export declare function contentIcon(node: SceneNode, icons: IconOptions): IconView | undefined;
15
18
  /**
16
19
  * Lay a Grid scene node out into selectable cells, one row per `columns`.
17
20
  * Items are filtered and ranked for `query` per the grid's `filtering`.
package/dist/grid.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { selectionId, text } from "./nodes.js";
2
+ import { iconView } from "./icons.js";
2
3
  import { filteringOf, groupsOf, rankGroups, score, searchable, } from "./rank.js";
3
4
  function columnsOf(node, fallback) {
4
5
  const value = node.props.columns;
@@ -18,6 +19,18 @@ export function contentGlyph(node) {
18
19
  }
19
20
  return "◇";
20
21
  }
22
+ /** The drawable image of a cell, when its content is an image rather than a color. */
23
+ export function contentIcon(node, icons) {
24
+ const content = node.props.content;
25
+ const value = content && typeof content === "object" && !Array.isArray(content) && "value" in content
26
+ ? content.value
27
+ : content;
28
+ if (value === undefined || value === null)
29
+ return undefined;
30
+ if (typeof value === "object" && !Array.isArray(value) && "color" in value)
31
+ return undefined;
32
+ return iconView(value, icons);
33
+ }
21
34
  /**
22
35
  * Lay a Grid scene node out into selectable cells, one row per `columns`.
23
36
  * Items are filtered and ranked for `query` per the grid's `filtering`.
package/dist/icons.d.ts CHANGED
@@ -1,13 +1,45 @@
1
1
  import type { JsonValue } from "@blastlauncher/protocol";
2
+ /** What a graphical client draws for an icon; `label` stays the text fallback. */
3
+ export type IconSource = {
4
+ kind: "builtin";
5
+ name: string;
6
+ } | {
7
+ kind: "url";
8
+ url: string;
9
+ } | {
10
+ kind: "file";
11
+ path: string;
12
+ } | {
13
+ kind: "fileIcon";
14
+ path: string;
15
+ };
16
+ export type IconMask = "circle" | "roundedRectangle";
17
+ export type IconAppearance = "light" | "dark";
18
+ export type IconOptions = {
19
+ /** Picks the light or dark variant of sources and tints. Default: "light". */
20
+ appearance?: IconAppearance;
21
+ /** Absolute directory that relative image names (`icon.png`) resolve under. */
22
+ assetsDir?: string;
23
+ };
2
24
  /**
3
- * A compact terminal stand-in for a Raycast icon. `tint` is a Raycast colour
4
- * name (`red`, `raycast-blue`, `secondary-text`) or a raw CSS colour; turning
5
- * it into a palette entry is the client's job, so this module stays free of
6
- * framework and palette imports.
25
+ * An icon as a client draws it. `label` is a compact terminal stand-in; `tint`
26
+ * is a Raycast colour name (`red`, `raycast-blue`, `secondary-text`) or a raw
27
+ * CSS colour, and turning it into a palette entry is the client's job, so this
28
+ * module stays free of framework and palette imports. `source` and `mask` tell
29
+ * a graphical client what to draw: the variant for the requested appearance,
30
+ * with relative asset names already absolute.
7
31
  */
8
32
  export type IconView = {
9
33
  label: string;
10
34
  tint?: string;
35
+ source?: IconSource;
36
+ mask?: IconMask;
37
+ };
38
+ /** The optional icon fields every view row carries, flattened under `icon*` names. */
39
+ export declare function iconFields(icon: IconView | undefined): {
40
+ iconTint?: string;
41
+ iconSource?: IconSource;
42
+ iconMask?: IconMask;
11
43
  };
12
- /** Convert a Raycast image/icon value to a compact terminal representation. */
13
- export declare function iconView(value: JsonValue | undefined): IconView;
44
+ /** Convert a Raycast image/icon value to a terminal label plus a drawable source. */
45
+ export declare function iconView(value: JsonValue | undefined, options?: IconOptions): IconView;
package/dist/icons.js CHANGED
@@ -1,3 +1,11 @@
1
+ /** The optional icon fields every view row carries, flattened under `icon*` names. */
2
+ export function iconFields(icon) {
3
+ return {
4
+ ...(icon?.tint ? { iconTint: icon.tint } : {}),
5
+ ...(icon?.source ? { iconSource: icon.source } : {}),
6
+ ...(icon?.mask ? { iconMask: icon.mask } : {}),
7
+ };
8
+ }
1
9
  // Keyed by the icon name without Raycast's `-16` size suffix, lowercase and
2
10
  // with separators removed (`Icon.ArrowDown` -> "arrowdown"). Aliases share a
3
11
  // glyph where Raycast has several names for the same picture.
@@ -332,48 +340,67 @@ const tintRoles = {
332
340
  red: "red",
333
341
  yellow: "yellow",
334
342
  };
335
- function tintOf(value) {
343
+ function tintOf(value, appearance) {
336
344
  if (typeof value === "string")
337
345
  return tintRoles[value.toLowerCase()] ?? value;
338
346
  if (value && typeof value === "object" && !Array.isArray(value)) {
339
- // A DynamicColor picks the dark variant: the reference client is dark.
340
347
  const dynamic = value;
341
- return tintOf(dynamic.dark ?? dynamic.light);
348
+ return tintOf(appearance === "dark" ? (dynamic.dark ?? dynamic.light) : (dynamic.light ?? dynamic.dark), appearance);
342
349
  }
343
350
  return undefined;
344
351
  }
345
- function sourceOf(value) {
352
+ function parse(value, appearance) {
346
353
  if (typeof value === "string")
347
354
  return { source: value, ...(looksLikeImage(value) ? { image: true } : {}) };
348
355
  if (!value || typeof value !== "object" || Array.isArray(value))
349
356
  return {};
350
357
  const icon = value;
351
358
  if (typeof icon.fileIcon === "string")
352
- return { source: icon.fileIcon, image: true };
353
- const tint = tintOf(icon.tintColor);
354
- const source = icon.source;
355
- if (typeof source === "string")
356
- return {
357
- source,
358
- ...(tint ? { tint } : {}),
359
- ...(looksLikeImage(source) ? { image: true } : {}),
360
- };
359
+ return { fileIcon: icon.fileIcon, image: true };
360
+ const tint = tintOf(icon.tintColor, appearance);
361
+ const mask = icon.mask === "circle" || icon.mask === "roundedRectangle" ? icon.mask : undefined;
362
+ let source = icon.source;
361
363
  if (source && typeof source === "object" && !Array.isArray(source)) {
362
364
  const dynamic = source;
363
- const selected = dynamic.dark ?? dynamic.light;
364
- if (typeof selected === "string")
365
- return {
366
- source: selected,
367
- ...(tint ? { tint } : {}),
368
- ...(looksLikeImage(selected) ? { image: true } : {}),
369
- };
365
+ source =
366
+ appearance === "dark" ? (dynamic.dark ?? dynamic.light) : (dynamic.light ?? dynamic.dark);
370
367
  }
371
- return tint ? { tint } : {};
368
+ return {
369
+ ...(typeof source === "string"
370
+ ? { source, ...(looksLikeImage(source) ? { image: true } : {}) }
371
+ : {}),
372
+ ...(tint ? { tint } : {}),
373
+ ...(mask ? { mask } : {}),
374
+ };
372
375
  }
373
376
  function looksLikeImage(source) {
374
377
  return (/^(https?:|data:|file:|\/)/i.test(source) ||
375
378
  /\.(png|jpe?g|gif|svg|webp|bmp|heic)$/i.test(source));
376
379
  }
380
+ /** Where a string icon points; undefined when a relative asset has no directory to resolve under. */
381
+ function sourceOf(source, assetsDir) {
382
+ if (/^(https?:|data:)/i.test(source))
383
+ return { kind: "url", url: source };
384
+ if (/^file:/i.test(source)) {
385
+ try {
386
+ return { kind: "file", path: decodeURIComponent(new URL(source).pathname) };
387
+ }
388
+ catch {
389
+ return undefined;
390
+ }
391
+ }
392
+ if (source.startsWith("/") || /^[a-z]:[\\/]/i.test(source))
393
+ return { kind: "file", path: source };
394
+ if (!looksLikeImage(source))
395
+ return { kind: "builtin", name: source };
396
+ // An extension must not reach outside its own assets directory.
397
+ if (!assetsDir || source.split(/[\\/]/).includes(".."))
398
+ return undefined;
399
+ return {
400
+ kind: "file",
401
+ path: `${assetsDir.replace(/[\\/]+$/, "")}/${source.replace(/^\.?[\\/]/, "")}`,
402
+ };
403
+ }
377
404
  /** `globe-01-16` -> "globe": drop the file extension, `16` size and `01` index suffixes. */
378
405
  function iconName(source) {
379
406
  const base = (source.split(/[\\/]/).at(-1) ?? source).replace(/\.[a-z0-9]{1,5}$/i, "");
@@ -397,13 +424,23 @@ function fallbackLabel(source) {
397
424
  const label = titleCase(words);
398
425
  return label.length <= 12 ? label : `${titleCase(words.slice(0, 9))}…`;
399
426
  }
400
- /** Convert a Raycast image/icon value to a compact terminal representation. */
401
- export function iconView(value) {
402
- const { source, tint, image } = sourceOf(value);
427
+ /** Convert a Raycast image/icon value to a terminal label plus a drawable source. */
428
+ export function iconView(value, options = {}) {
429
+ const { source, fileIcon, tint, mask, image } = parse(value, options.appearance ?? "light");
430
+ const drawable = fileIcon !== undefined
431
+ ? { kind: "fileIcon", path: fileIcon }
432
+ : source === undefined
433
+ ? undefined
434
+ : sourceOf(source, options.assetsDir);
435
+ const extras = {
436
+ ...(tint ? { tint } : {}),
437
+ ...(drawable ? { source: drawable } : {}),
438
+ ...(mask ? { mask } : {}),
439
+ };
403
440
  if (!source)
404
- return { label: "◇", ...(tint ? { tint } : {}) };
441
+ return { label: fileIcon === undefined ? "◇" : "▣", ...extras };
405
442
  const name = iconName(source);
406
443
  const glyph = glyphs[name] ?? glyphs[name.replace(/(?:filled|disabled|small|large)$/, "")] ?? undefined;
407
444
  const label = glyph ?? (image ? "▣" : fallbackLabel(source));
408
- return { label, ...(tint ? { tint } : {}) };
445
+ return { label, ...extras };
409
446
  }
package/dist/index.d.ts CHANGED
@@ -12,3 +12,5 @@ export * from "./feedback.js";
12
12
  export * from "./menu-bar.js";
13
13
  export * from "./icons.js";
14
14
  export * from "./controller.js";
15
+ export * from "./root.js";
16
+ export * from "./prompt.js";
package/dist/index.js CHANGED
@@ -15,3 +15,5 @@ export * from "./feedback.js";
15
15
  export * from "./menu-bar.js";
16
16
  export * from "./icons.js";
17
17
  export * from "./controller.js";
18
+ export * from "./root.js";
19
+ export * from "./prompt.js";
@@ -1,6 +1,7 @@
1
1
  import type { SceneMessage, SceneNode } from "@blastlauncher/protocol";
2
2
  import { type OutboundEvent } from "./nodes.js";
3
- import { type Platform } from "./shortcut.js";
3
+ import { type KeyInput, type Platform } from "./shortcut.js";
4
+ import { type IconMask, type IconOptions, type IconSource } from "./icons.js";
4
5
  export type MenuRow = {
5
6
  node: SceneNode;
6
7
  text: string;
@@ -8,6 +9,10 @@ export type MenuRow = {
8
9
  disabled: boolean;
9
10
  title?: string;
10
11
  subtitle?: string;
12
+ iconLabel?: string;
13
+ iconTint?: string;
14
+ iconSource?: IconSource;
15
+ iconMask?: IconMask;
11
16
  shortcut?: string;
12
17
  isSubmenu?: boolean;
13
18
  };
@@ -32,9 +37,9 @@ export type MenuBarView = {
32
37
  tooltip: string;
33
38
  };
34
39
  /** The rows of the menu level addressed by `path` (ids of open Submenus). */
35
- export declare function menuRows(menu: SceneNode | undefined, path: string[], platform?: Platform): MenuRow[];
40
+ export declare function menuRows(menu: SceneNode | undefined, path: string[], platform?: Platform, icons?: IconOptions): MenuRow[];
36
41
  /** The menu of the active view as rows, selection and loading state. */
37
- export declare function menuBarView(scene: SceneMessage | undefined, state?: MenuBarState, platform?: Platform): MenuBarView;
42
+ export declare function menuBarView(scene: SceneMessage | undefined, state?: MenuBarState, platform?: Platform, icons?: IconOptions): MenuBarView;
38
43
  export type MenuBarAction = {
39
44
  type: "move";
40
45
  delta: 1 | -1;
@@ -57,3 +62,5 @@ export type MenuBarStep = {
57
62
  };
58
63
  /** The menu-bar navigation reducer. Pure: the client sends `events` itself. */
59
64
  export declare function menuBarStep(scene: SceneMessage | undefined, state: MenuBarState, action: MenuBarAction, platform?: Platform): MenuBarStep;
65
+ /** The enabled item anywhere in `menu` whose shortcut is `press`, if any. */
66
+ export declare function menuShortcut(menu: SceneNode | undefined, press: KeyInput, platform?: Platform): SceneNode | undefined;
package/dist/menu-bar.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { handler, handlerEvent, event, selectionId, text } from "./nodes.js";
2
- import { shortcutLabel } from "./shortcut.js";
2
+ import { matchesShortcut, shortcutLabel } from "./shortcut.js";
3
3
  import { activeNode } from "./view.js";
4
+ import { iconFields, iconView } from "./icons.js";
4
5
  export const MENU_BAR_START = { path: [], selectedId: null };
5
6
  /** The rows of the menu level addressed by `path` (ids of open Submenus). */
6
- export function menuRows(menu, path, platform = "Linux") {
7
+ export function menuRows(menu, path, platform = "Linux", icons = {}) {
7
8
  if (menu?.type !== "MenuBarExtra")
8
9
  return [];
9
10
  let level = menu;
@@ -30,12 +31,14 @@ export function menuRows(menu, path, platform = "Linux") {
30
31
  const title = text(node.props.title);
31
32
  const subtitle = text(node.props.subtitle);
32
33
  const shortcut = shortcutLabel(node.props.shortcut, platform);
34
+ const icon = node.props.icon === undefined ? undefined : iconView(node.props.icon, icons);
33
35
  rows.push({
34
36
  node,
35
37
  item: true,
36
38
  disabled: !handler(node.props.onAction),
37
39
  title,
38
40
  subtitle,
41
+ ...(icon ? { iconLabel: icon.label, ...iconFields(icon) } : {}),
39
42
  shortcut,
40
43
  text: [title, subtitle, shortcut].filter(Boolean).join(" "),
41
44
  });
@@ -66,10 +69,10 @@ export function menuRows(menu, path, platform = "Linux") {
66
69
  return rows;
67
70
  }
68
71
  /** The menu of the active view as rows, selection and loading state. */
69
- export function menuBarView(scene, state = MENU_BAR_START, platform = "Linux") {
72
+ export function menuBarView(scene, state = MENU_BAR_START, platform = "Linux", icons = {}) {
70
73
  const active = activeNode(scene?.root);
71
74
  const menu = active?.type === "MenuBarExtra" ? active : undefined;
72
- const rows = menuRows(menu, state.path, platform);
75
+ const rows = menuRows(menu, state.path, platform, icons);
73
76
  const items = rows.filter((row) => row.item);
74
77
  return {
75
78
  menu,
@@ -115,3 +118,17 @@ export function menuBarStep(scene, state, action, platform = "Linux") {
115
118
  };
116
119
  return same;
117
120
  }
121
+ /** The enabled item anywhere in `menu` whose shortcut is `press`, if any. */
122
+ export function menuShortcut(menu, press, platform = "Linux") {
123
+ if (menu?.type !== "MenuBarExtra")
124
+ return;
125
+ const queue = [...menu.children];
126
+ for (let node = queue.shift(); node; node = queue.shift()) {
127
+ if (node.type === "MenuBarExtra.Item" &&
128
+ handler(node.props.onAction) &&
129
+ matchesShortcut(node.props.shortcut, press, platform))
130
+ return node;
131
+ queue.push(...node.children);
132
+ }
133
+ return;
134
+ }
@@ -0,0 +1,64 @@
1
+ import { type ControllerInput, type ControllerOptions } from "./controller.js";
2
+ import type { ControllerView } from "./controller.js";
3
+ export type PromptValue = string | number | boolean;
4
+ export type PromptValues = Record<string, PromptValue>;
5
+ /** One input of the form. `key` identifies it in `values`, view field ids and effects. */
6
+ export type PromptField = {
7
+ key: string;
8
+ /** A preference or argument type: `textfield`, `password`, `checkbox`, `dropdown`, `file`, `directory`, `appPicker`. */
9
+ type: string;
10
+ title: string;
11
+ description?: string;
12
+ placeholder?: string;
13
+ /** A checkbox's label next to the box. */
14
+ label?: string;
15
+ required: boolean;
16
+ data?: {
17
+ title: string;
18
+ value: string;
19
+ }[];
20
+ };
21
+ export type PromptForm = {
22
+ title: string;
23
+ /** Primary action title, e.g. "Save & Launch". */
24
+ submit: string;
25
+ description?: string;
26
+ sections: {
27
+ title: string;
28
+ fields: PromptField[];
29
+ }[];
30
+ /** Starting values by field key; missing ones start empty (checkbox off, dropdown on its first option). */
31
+ values: PromptValues;
32
+ };
33
+ export type PromptStatus = {
34
+ title: string;
35
+ toast: {
36
+ style: "animated" | "success" | "failure";
37
+ title: string;
38
+ message: string;
39
+ /** Adds a Retry action to the toast and the primary action. */
40
+ retry?: boolean;
41
+ };
42
+ };
43
+ export type PromptEffect = {
44
+ type: "submit";
45
+ values: PromptValues;
46
+ } | {
47
+ type: "retry";
48
+ } | {
49
+ type: "cancel";
50
+ };
51
+ export type PromptResult = {
52
+ effects: PromptEffect[];
53
+ };
54
+ export type PromptController = {
55
+ /** Replace the view with a form; the next `submit` effect carries its values. */
56
+ form(form: PromptForm): void;
57
+ /** Replace the view with a status screen: a toast over an empty detail. */
58
+ status(status: PromptStatus): void;
59
+ /** Any controller input; anything outside the prompt's own inputs is ignored. */
60
+ input(input: ControllerInput): PromptResult;
61
+ view(): ControllerView;
62
+ subscribe(listener: (view: ControllerView) => void): () => void;
63
+ };
64
+ export declare function createPromptController(options: ControllerOptions): PromptController;
package/dist/prompt.js ADDED
@@ -0,0 +1,184 @@
1
+ import { createController } from "./controller.js";
2
+ const node = (type, id, props = {}, children = []) => ({ type, id, props, children });
3
+ const handler = (name) => ({ $handler: name });
4
+ const actions = (...children) => node("Slot", "slot-actions", { name: "actions" }, [node("ActionPanel", "panel", {}, children)]);
5
+ const SET = "set:";
6
+ const empty = (value) => value === undefined || value === "";
7
+ /** The value a field starts with, so a form never shows a dropdown with nothing chosen. */
8
+ function initial(field, values) {
9
+ const value = values[field.key];
10
+ if (value !== undefined)
11
+ return value;
12
+ if (field.type === "checkbox")
13
+ return false;
14
+ if (field.type === "dropdown")
15
+ return field.data?.[0]?.value ?? "";
16
+ return "";
17
+ }
18
+ function fieldNode(field, value, error) {
19
+ const common = {
20
+ id: field.key,
21
+ title: field.title,
22
+ required: field.required,
23
+ ...(field.description ? { info: field.description } : {}),
24
+ ...(error ? { error } : {}),
25
+ onChange: handler(`${SET}${field.key}`),
26
+ };
27
+ switch (field.type) {
28
+ case "checkbox":
29
+ return node("Form.Checkbox", field.key, {
30
+ ...common,
31
+ label: field.label ?? field.title,
32
+ value: value === true,
33
+ });
34
+ case "dropdown":
35
+ return node("Form.Dropdown", field.key, { ...common, value: String(value) }, (field.data ?? []).map((option) => node("Form.Dropdown.Item", `${field.key}:${option.value}`, option)));
36
+ case "password":
37
+ return node("Form.PasswordField", field.key, {
38
+ ...common,
39
+ value: String(value),
40
+ hasValue: value !== "",
41
+ ...(field.placeholder ? { placeholder: field.placeholder } : {}),
42
+ });
43
+ case "file":
44
+ case "directory":
45
+ return node("Form.FilePicker", field.key, {
46
+ ...common,
47
+ value: value === "" ? [] : [String(value)],
48
+ allowMultipleSelection: false,
49
+ canChooseFiles: field.type === "file",
50
+ canChooseDirectories: field.type === "directory",
51
+ });
52
+ default:
53
+ return node("Form.TextField", field.key, {
54
+ ...common,
55
+ value: String(value),
56
+ ...(field.placeholder ? { placeholder: field.placeholder } : {}),
57
+ });
58
+ }
59
+ }
60
+ export function createPromptController(options) {
61
+ const controller = createController(options);
62
+ let revision = 0;
63
+ let current;
64
+ let values = {};
65
+ let errors = new Map();
66
+ let status;
67
+ const show = (list, ...feedback) => {
68
+ const message = {
69
+ type: "scene",
70
+ revision: ++revision,
71
+ root: node("Navigation", "prompt", { pop: handler("cancel") }, [
72
+ node("Navigation.Entry", "entry", { active: true }, [list]),
73
+ ...feedback,
74
+ ]),
75
+ };
76
+ controller.scene(message);
77
+ };
78
+ const render = () => {
79
+ if (current) {
80
+ const form = current;
81
+ const children = [];
82
+ if (form.description)
83
+ children.push(node("Form.Description", "description", { text: form.description }));
84
+ form.sections.forEach((section, index) => {
85
+ if (index > 0 || form.description)
86
+ children.push(node("Form.Separator", `rule:${index}`));
87
+ if (section.title)
88
+ children.push(node("Form.Description", `section:${index}`, { text: section.title }));
89
+ for (const field of section.fields)
90
+ children.push(fieldNode(field, initial(field, values), errors.get(field.key) ?? ""));
91
+ });
92
+ children.push(actions(node("Action.SubmitForm", "submit", { title: form.submit, onSubmit: handler("submit") })));
93
+ show(node("Form", "prompt-form", { navigationTitle: form.title }, children));
94
+ }
95
+ else if (status) {
96
+ const toast = status.toast;
97
+ show(node("Detail", "prompt-status", {
98
+ navigationTitle: status.title,
99
+ markdown: "",
100
+ isLoading: toast.style === "animated",
101
+ }, toast.retry
102
+ ? [actions(node("Action", "retry", { title: "Retry", onAction: handler("retry") }))]
103
+ : []), node("Toast", "toast", { style: toast.style.toUpperCase(), title: toast.title, message: toast.message }, toast.retry
104
+ ? [
105
+ node("Toast.Action", "toast-retry", {
106
+ name: "primaryAction",
107
+ title: "Retry",
108
+ shortcut: { modifiers: [], key: "r" },
109
+ onAction: handler("retry"),
110
+ }),
111
+ ]
112
+ : []));
113
+ }
114
+ };
115
+ const submit = () => {
116
+ if (!current)
117
+ return [];
118
+ errors = new Map();
119
+ const all = current.sections.flatMap((section) => section.fields);
120
+ for (const field of all)
121
+ if (field.required && empty(initial(field, values)))
122
+ errors.set(field.key, "Required");
123
+ if (errors.size)
124
+ return [];
125
+ return [
126
+ {
127
+ type: "submit",
128
+ values: Object.fromEntries(all.map((field) => [field.key, initial(field, values)])),
129
+ },
130
+ ];
131
+ };
132
+ return {
133
+ form(form) {
134
+ current = form;
135
+ status = undefined;
136
+ values = { ...form.values };
137
+ errors = new Map();
138
+ render();
139
+ },
140
+ status(next) {
141
+ current = undefined;
142
+ status = next;
143
+ render();
144
+ },
145
+ input(input) {
146
+ const effects = [];
147
+ const result = controller.input(input);
148
+ // The controller asks to close on `back`; the prompt cancels instead.
149
+ if (result.effects.some((effect) => effect.type === "quit"))
150
+ effects.push({ type: "cancel" });
151
+ let changed = false;
152
+ for (const { handler: name, args } of result.events) {
153
+ if (name === "cancel")
154
+ effects.push({ type: "cancel" });
155
+ else if (name === "retry")
156
+ effects.push({ type: "retry" });
157
+ else if (name === "submit") {
158
+ effects.push(...submit());
159
+ changed = true;
160
+ }
161
+ else if (name.startsWith(SET)) {
162
+ const key = name.slice(SET.length);
163
+ const field = current?.sections.flatMap((s) => s.fields).find((f) => f.key === key);
164
+ const value = args[0];
165
+ if (!field)
166
+ continue;
167
+ if (Array.isArray(value))
168
+ values[key] = typeof value[0] === "string" ? value[0] : "";
169
+ else if (typeof value === "string" ||
170
+ typeof value === "boolean" ||
171
+ typeof value === "number")
172
+ values[key] = value;
173
+ errors.delete(key);
174
+ changed = true;
175
+ }
176
+ }
177
+ if (changed)
178
+ render();
179
+ return { effects };
180
+ },
181
+ view: () => controller.view(),
182
+ subscribe: (listener) => controller.subscribe(listener),
183
+ };
184
+ }
package/dist/root.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { type IconView } from "./icons.js";
2
+ import type { ControllerInput, ControllerView } from "./controller.js";
3
+ import { type Platform } from "./shortcut.js";
4
+ /** A command exposed by an opened extension. `extension` is the grouping and launch key. */
5
+ export type RootCommand = {
6
+ extension: string;
7
+ extensionTitle?: string;
8
+ command: string;
9
+ title: string;
10
+ subtitle?: string;
11
+ description?: string;
12
+ mode?: "view" | "no-view" | "menu-bar";
13
+ icon?: IconView;
14
+ };
15
+ /** `launch` runs the command; `configure` opens its preferences form without running it. */
16
+ export type RootEffect = {
17
+ type: "launch" | "configure";
18
+ extension: string;
19
+ command: string;
20
+ scope?: "extension" | "command";
21
+ };
22
+ export type RootResult = {
23
+ effects: RootEffect[];
24
+ };
25
+ /** Any controller input is accepted; those with no meaning in the root list are ignored. */
26
+ export type RootInput = ControllerInput;
27
+ export type RootController = {
28
+ input(input: RootInput): RootResult;
29
+ /** Replace the catalog (an extension opened, closed or edited). Selection follows the command. */
30
+ setCatalog(commands: RootCommand[]): void;
31
+ /** Show a failure toast until the next input. */
32
+ fail(title: string, message: string): void;
33
+ /** Come back from a command: clears the search, selection and any toast. */
34
+ reset(): void;
35
+ view(): ControllerView;
36
+ subscribe(listener: (view: ControllerView) => void): () => void;
37
+ };
38
+ export declare function createRootController(initial?: RootCommand[], options?: {
39
+ platform?: Platform;
40
+ }): RootController;
package/dist/root.js ADDED
@@ -0,0 +1,219 @@
1
+ import { rankGroups, score } from "./rank.js";
2
+ import { iconFields } from "./icons.js";
3
+ import { matchesShortcut, shortcutLabel } from "./shortcut.js";
4
+ const PLACEHOLDER = "Search commands…";
5
+ const OPEN = "Open Command";
6
+ const CONFIGURE = "Configure Command";
7
+ const CONFIGURE_KEYS = { modifiers: ["cmd", "shift"], key: "," };
8
+ const ACTIONS = [{ id: "open" }, { id: "configure" }];
9
+ const MODE_LABELS = { view: "Command", "no-view": "No View", "menu-bar": "Menu Bar" };
10
+ const commandId = (command) => `${command.extension}:${command.command}`;
11
+ export function createRootController(initial = [], options = {}) {
12
+ const platform = options.platform ?? "Linux";
13
+ let catalog = initial;
14
+ let query = "";
15
+ let selectedId = null;
16
+ let actionsOpen = false;
17
+ let actionIndex = 0;
18
+ let failure = null;
19
+ const listeners = new Set();
20
+ // Sections are extensions in catalog order; a query orders items by score inside each.
21
+ const visible = () => {
22
+ const byExtension = new Map();
23
+ for (const command of catalog) {
24
+ const items = byExtension.get(command.extension);
25
+ if (items)
26
+ items.push(command);
27
+ else
28
+ byExtension.set(command.extension, [command]);
29
+ }
30
+ const groups = [...byExtension].map(([section, items]) => ({
31
+ section,
32
+ items,
33
+ }));
34
+ return rankGroups(groups, query, (c) => score({
35
+ title: c.title,
36
+ subtitle: c.subtitle || c.description || "",
37
+ keywords: [c.command, c.extension, c.extensionTitle ?? ""],
38
+ }, query), { enabled: true, keepSectionOrder: true }).flatMap((group) => group.items);
39
+ };
40
+ // The chosen command, falling back to the first when it disappeared (live catalog).
41
+ const chosen = (items) => items.find((c) => commandId(c) === selectedId) ?? items[0];
42
+ const view = () => {
43
+ const items = visible();
44
+ const current = chosen(items);
45
+ const rows = [];
46
+ let section;
47
+ for (const command of items) {
48
+ if (command.extension !== section) {
49
+ section = command.extension;
50
+ const title = command.extensionTitle ?? section;
51
+ rows.push({
52
+ id: `section:${section}`,
53
+ kind: "section",
54
+ title,
55
+ subtitle: "",
56
+ accessories: "",
57
+ icon: false,
58
+ count: items.filter((c) => c.extension === section).length,
59
+ selected: false,
60
+ iconLabel: "",
61
+ text: title,
62
+ });
63
+ }
64
+ const subtitle = command.subtitle || command.description || "";
65
+ rows.push({
66
+ id: commandId(command),
67
+ kind: "item",
68
+ title: command.title,
69
+ subtitle,
70
+ accessories: command.mode ? MODE_LABELS[command.mode] : "",
71
+ icon: !!command.icon,
72
+ count: null,
73
+ selected: command === current,
74
+ iconLabel: command.icon?.label ?? "",
75
+ ...iconFields(command.icon),
76
+ text: subtitle ? `${command.title} ${subtitle}` : command.title,
77
+ });
78
+ }
79
+ return {
80
+ kind: "list",
81
+ title: "Commands",
82
+ navigation: { canGoBack: false },
83
+ loading: false,
84
+ search: { text: query, placeholder: PLACEHOLDER },
85
+ rows,
86
+ grid: null,
87
+ selectedId: current ? commandId(current) : null,
88
+ emptyText: query ? "No matching commands" : "No commands",
89
+ empty: null,
90
+ detail: "",
91
+ primary: { title: OPEN, shortcut: "", enabled: !!current },
92
+ actions: {
93
+ open: actionsOpen && !!current,
94
+ title: current?.title ?? "Actions",
95
+ query: "",
96
+ breadcrumb: [],
97
+ rows: actionsOpen && current
98
+ ? [
99
+ {
100
+ id: "open",
101
+ kind: "action",
102
+ title: OPEN,
103
+ shortcut: "",
104
+ selected: actionIndex === 0,
105
+ },
106
+ {
107
+ id: "configure",
108
+ kind: "action",
109
+ title: CONFIGURE,
110
+ shortcut: shortcutLabel(CONFIGURE_KEYS, platform),
111
+ selected: actionIndex === 1,
112
+ },
113
+ ]
114
+ : [],
115
+ selectedIndex: actionsOpen && current ? actionIndex : -1,
116
+ },
117
+ dropdown: null,
118
+ feedback: {
119
+ toast: failure && { style: "failure", ...failure, actions: [] },
120
+ hud: null,
121
+ alert: null,
122
+ },
123
+ menuBar: null,
124
+ form: null,
125
+ requests: { quit: false, clearSearch: false },
126
+ };
127
+ };
128
+ const notify = () => {
129
+ const next = view();
130
+ for (const listener of listeners)
131
+ listener(next);
132
+ };
133
+ const run = (type) => {
134
+ const current = chosen(visible());
135
+ actionsOpen = false;
136
+ return {
137
+ effects: current
138
+ ? [
139
+ {
140
+ type,
141
+ extension: current.extension,
142
+ command: current.command,
143
+ },
144
+ ]
145
+ : [],
146
+ };
147
+ };
148
+ return {
149
+ input(input) {
150
+ failure = null;
151
+ let result = { effects: [] };
152
+ switch (input.type) {
153
+ case "search":
154
+ query = input.text;
155
+ selectedId = null;
156
+ actionsOpen = false;
157
+ break;
158
+ case "move": {
159
+ const items = visible();
160
+ const step = input.direction === "down" ? 1 : input.direction === "up" ? -1 : 0;
161
+ const current = chosen(items);
162
+ if (actionsOpen) {
163
+ actionIndex = Math.max(0, Math.min(ACTIONS.length - 1, actionIndex + step));
164
+ break;
165
+ }
166
+ const next = items[(current ? items.indexOf(current) : 0) + step];
167
+ if (next)
168
+ selectedId = commandId(next);
169
+ break;
170
+ }
171
+ case "select": {
172
+ const picked = visible().find((c) => commandId(c) === input.id);
173
+ if (picked)
174
+ selectedId = commandId(picked);
175
+ break;
176
+ }
177
+ case "primary":
178
+ result = run(actionsOpen && ACTIONS[actionIndex]?.id === "configure" ? "configure" : "launch");
179
+ break;
180
+ case "key":
181
+ if (matchesShortcut(CONFIGURE_KEYS, input.key, platform))
182
+ result = run("configure");
183
+ break;
184
+ case "openActions":
185
+ actionsOpen = true;
186
+ actionIndex = 0;
187
+ break;
188
+ case "closeActions":
189
+ case "back":
190
+ actionsOpen = false;
191
+ break;
192
+ default:
193
+ break;
194
+ }
195
+ notify();
196
+ return result;
197
+ },
198
+ setCatalog(commands) {
199
+ catalog = commands;
200
+ notify();
201
+ },
202
+ fail(title, message) {
203
+ failure = { title, message };
204
+ notify();
205
+ },
206
+ reset() {
207
+ query = "";
208
+ actionsOpen = false;
209
+ failure = null;
210
+ notify();
211
+ },
212
+ view,
213
+ subscribe(listener) {
214
+ listeners.add(listener);
215
+ listener(view());
216
+ return () => listeners.delete(listener);
217
+ },
218
+ };
219
+ }
package/dist/view.d.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  import type { SceneMessage, SceneNode } from "@blastlauncher/protocol";
2
2
  import { type FilteringOptions } from "./rank.js";
3
3
  import { type Platform } from "./shortcut.js";
4
- import { type IconView } from "./icons.js";
4
+ import { type IconOptions, type IconView } from "./icons.js";
5
5
  /** Raycast's built-in shortcuts for actions without an explicit shortcut. */
6
6
  export declare function defaultActionShortcut(node: SceneNode, index: number): unknown;
7
7
  export { event, handler, handlerEvent, isFiltering, paginationOf, selectionId, slot, text, } from "./nodes.js";
8
8
  export type UIState = {
9
9
  searchText: string;
10
10
  selectedId: string | null;
11
+ /** Shown when the scene has no navigation title, typically the command title. */
12
+ fallbackTitle?: string;
13
+ icons?: IconOptions;
11
14
  };
12
15
  export type Row = {
13
16
  node: SceneNode;
package/dist/view.js CHANGED
@@ -36,32 +36,40 @@ export function activeNode(node) {
36
36
  return activeNode(node.children[0]);
37
37
  return node ?? undefined;
38
38
  }
39
- function itemAccessories(node) {
39
+ function itemAccessories(node, icons) {
40
40
  const accessories = node.props.accessories;
41
41
  const labels = Array.isArray(accessories)
42
42
  ? accessories.map((value) => {
43
43
  if (!value || typeof value !== "object" || Array.isArray(value))
44
44
  return "";
45
- return [text(value.text), text(value.tag), value.icon ? iconView(value.icon).label : ""]
45
+ return [
46
+ text(value.text),
47
+ text(value.tag),
48
+ value.icon ? iconView(value.icon, icons).label : "",
49
+ ]
46
50
  .filter(Boolean)
47
51
  .join(" ");
48
52
  })
49
53
  : [];
50
54
  return labels.filter(Boolean).join(" ");
51
55
  }
52
- function itemText(node) {
56
+ function itemText(node, icons) {
53
57
  const accessories = node.props.accessories;
54
58
  const labels = Array.isArray(accessories)
55
59
  ? accessories.map((value) => {
56
60
  if (!value || typeof value !== "object" || Array.isArray(value))
57
61
  return "";
58
- return [text(value.text), text(value.tag), value.icon ? iconView(value.icon).label : ""]
62
+ return [
63
+ text(value.text),
64
+ text(value.tag),
65
+ value.icon ? iconView(value.icon, icons).label : "",
66
+ ]
59
67
  .filter(Boolean)
60
68
  .join(" ");
61
69
  })
62
70
  : [];
63
71
  return [
64
- node.props.icon ? iconView(node.props.icon).label : "",
72
+ node.props.icon ? iconView(node.props.icon, icons).label : "",
65
73
  text(node.props.title),
66
74
  text(node.props.subtitle),
67
75
  ...labels,
@@ -69,16 +77,16 @@ function itemText(node) {
69
77
  .filter(Boolean)
70
78
  .join(" ");
71
79
  }
72
- function itemRow(node) {
80
+ function itemRow(node, icons) {
73
81
  return {
74
82
  node,
75
- text: itemText(node),
83
+ text: itemText(node, icons),
76
84
  item: true,
77
85
  title: text(node.props.title),
78
86
  subtitle: text(node.props.subtitle),
79
- accessories: itemAccessories(node),
87
+ accessories: itemAccessories(node, icons),
80
88
  icon: Boolean(node.props.icon),
81
- ...(node.props.icon ? { iconView: iconView(node.props.icon) } : {}),
89
+ ...(node.props.icon ? { iconView: iconView(node.props.icon, icons) } : {}),
82
90
  };
83
91
  }
84
92
  function sectionRow(node) {
@@ -103,6 +111,7 @@ export function sceneToView(scene, state) {
103
111
  const list = activeNode(scene?.root);
104
112
  const rows = [];
105
113
  const query = state.searchText;
114
+ const icons = state.icons ?? {};
106
115
  const filtering = filteringOf(list);
107
116
  const grid = list?.type === "Grid" ? gridLayout(list, query, filtering) : undefined;
108
117
  if (list?.type === "List") {
@@ -112,7 +121,7 @@ export function sceneToView(scene, state) {
112
121
  if (header)
113
122
  rows.push(header);
114
123
  for (const node of group.items)
115
- rows.push(itemRow(node));
124
+ rows.push(itemRow(node, icons));
116
125
  if (header)
117
126
  header.count = group.items.length;
118
127
  }
@@ -132,7 +141,9 @@ export function sceneToView(scene, state) {
132
141
  grid,
133
142
  accessory: searchBarDropdown(list),
134
143
  navigation: scene?.root?.type === "Navigation" ? scene.root : undefined,
144
+ // An empty list shows its EmptyView, whose actions replace the list's, as in Raycast.
135
145
  actions: (selected ? slot(selected, "actions") : undefined) ??
146
+ (!selected && empty ? slot(empty, "actions") : undefined) ??
136
147
  (list ? slot(list, "actions") : undefined),
137
148
  detail: list?.type === "Detail"
138
149
  ? detailText(list)
@@ -147,11 +158,12 @@ export function sceneToView(scene, state) {
147
158
  (list?.type === "MenuBarExtra"
148
159
  ? [text(list.props.title), text(list.props.tooltip)].filter(Boolean).join(" — ")
149
160
  : "") ||
161
+ state.fallbackTitle ||
150
162
  "Blast",
151
163
  loading: list?.props.isLoading === true || !scene,
152
164
  emptyText: empty
153
165
  ? [
154
- empty.props.icon ? iconView(empty.props.icon).label : "",
166
+ empty.props.icon ? iconView(empty.props.icon, icons).label : "",
155
167
  text(empty.props.title),
156
168
  text(empty.props.description),
157
169
  ]
@@ -159,6 +171,7 @@ export function sceneToView(scene, state) {
159
171
  .join(" ")
160
172
  : "No results",
161
173
  primary: firstAction(selected ? slot(selected, "actions") : undefined) ??
174
+ firstAction(!selected && empty ? slot(empty, "actions") : undefined) ??
162
175
  firstAction(list ? slot(list, "actions") : undefined),
163
176
  };
164
177
  }
package/package.json CHANGED
@@ -9,9 +9,9 @@
9
9
  }
10
10
  },
11
11
  "dependencies": {
12
- "@blastlauncher/protocol": "3.0.0-alpha.1"
12
+ "@blastlauncher/protocol": "3.0.0-alpha.2"
13
13
  },
14
- "version": "3.0.0-alpha.1",
14
+ "version": "3.0.0-alpha.2",
15
15
  "license": "MIT",
16
16
  "repository": {
17
17
  "type": "git",