@blastlauncher/client-core 3.0.0-alpha.1 → 3.0.0-alpha.3
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/dist/controller.d.ts +21 -2
- package/dist/controller.js +56 -19
- package/dist/form-view.d.ts +14 -0
- package/dist/form-view.js +18 -1
- package/dist/grid.d.ts +3 -0
- package/dist/grid.js +13 -0
- package/dist/icons.d.ts +38 -6
- package/dist/icons.js +63 -26
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/menu-bar.d.ts +10 -3
- package/dist/menu-bar.js +21 -4
- package/dist/prompt.d.ts +64 -0
- package/dist/prompt.js +184 -0
- package/dist/root.d.ts +40 -0
- package/dist/root.js +219 -0
- package/dist/view.d.ts +4 -1
- package/dist/view.js +24 -11
- package/package.json +2 -2
package/dist/controller.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { SceneMessage } from "@blastlauncher/protocol";
|
|
2
2
|
import { type OutboundEvent } from "./nodes.js";
|
|
3
|
-
import { type
|
|
4
|
-
|
|
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;
|
package/dist/controller.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
|
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,
|
package/dist/form-view.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
4
|
-
* name (`red`, `raycast-blue`, `secondary-text`) or a raw
|
|
5
|
-
* it into a palette entry is the client's job, so this
|
|
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
|
|
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
|
|
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 {
|
|
353
|
-
const tint = tintOf(icon.tintColor);
|
|
354
|
-
const
|
|
355
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
|
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
|
|
401
|
-
export function iconView(value) {
|
|
402
|
-
const { source, tint, image } =
|
|
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:
|
|
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, ...
|
|
445
|
+
return { label, ...extras };
|
|
409
446
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/menu-bar.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/prompt.d.ts
ADDED
|
@@ -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 [
|
|
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 [
|
|
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