@embedpdf/core-ui 3.0.0-next.14 → 3.0.0-next.16

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/index.cjs CHANGED
@@ -1,33 +1,43 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  //#region src/schema.ts
3
3
  const PINNED = 5;
4
- function item(command, opts) {
4
+ /** A command button: `item('zoom:in', { variants: ['icon+label', 'icon'] })`. */
5
+ function item(command, options) {
5
6
  return {
6
7
  command,
7
- ...opts
8
+ ...options
8
9
  };
9
10
  }
10
- function custom(slot, opts) {
11
- return {
11
+ /**
12
+ * An item you draw yourself, by its slot name, and the command it becomes in the "More" menu:
13
+ * `custom('page-number', 'page:go-to', { variants: ['full', 'compact'] })`. Without a command,
14
+ * the item never leaves the bar.
15
+ */
16
+ function custom(slot, terminal, options) {
17
+ return terminal === void 0 ? {
18
+ slot,
19
+ ...options
20
+ } : {
12
21
  slot,
13
- ...opts
22
+ terminal,
23
+ ...options
14
24
  };
15
25
  }
16
- function group(id, optsOrItems, items) {
17
- if (Array.isArray(optsOrItems)) return {
18
- id,
19
- items: optsOrItems
20
- };
21
- const { items: optItems, ...opts } = optsOrItems;
26
+ /** A group of items, with a separator between groups: `group('zoom', ['zoom:out', 'zoom:in'])`. */
27
+ function group(id, items, options) {
22
28
  return {
23
29
  id,
24
- ...opts,
25
- items: items ?? optItems ?? []
30
+ ...options,
31
+ items
26
32
  };
27
33
  }
28
34
  const DEFAULT_IMPORTANCE = 3;
29
35
  const DEFAULT_COMMAND_VARIANTS = ["icon"];
30
36
  const DEFAULT_CUSTOM_VARIANTS = ["default"];
37
+ /** Whether a unit is a custom item with no command to become in menus: it never leaves the bar. */
38
+ const isStandaloneUnit = (unit) => unit.kind === "custom" && unit.terminal === null;
39
+ /** The command a unit runs: its own, or a custom item's `terminal`; null for a standalone item. */
40
+ const commandOfUnit = (unit) => unit.kind === "command" ? unit.command : unit.terminal;
31
41
  function normalizeChild(groupId, child, seen) {
32
42
  const asItem = typeof child === "string" ? { command: child } : child;
33
43
  const unit = "slot" in asItem ? {
@@ -35,8 +45,8 @@ function normalizeChild(groupId, child, seen) {
35
45
  key: `${groupId}:${asItem.slot}`,
36
46
  slot: asItem.slot,
37
47
  variants: asItem.variants ?? DEFAULT_CUSTOM_VARIANTS,
38
- terminal: asItem.terminal,
39
- importance: asItem.importance ?? 3
48
+ terminal: asItem.terminal ?? null,
49
+ importance: asItem.terminal === void 0 ? 5 : asItem.importance ?? 3
40
50
  } : {
41
51
  kind: "command",
42
52
  key: `${groupId}:${asItem.command}`,
@@ -49,7 +59,11 @@ function normalizeChild(groupId, child, seen) {
49
59
  seen.add(unit.key);
50
60
  return unit;
51
61
  }
52
- /** Resolve shorthands + defaults; validate uniqueness. Solver/projection input. */
62
+ /**
63
+ * Resolve shorthands + defaults; validate uniqueness. Solver/projection input.
64
+ * A group holding a custom item without a command is pinned with it: it never
65
+ * collapses or overflows whole, so the item stays in the bar.
66
+ */
53
67
  function normalizeBar(bar) {
54
68
  const seen = /* @__PURE__ */ new Set();
55
69
  const groupIds = /* @__PURE__ */ new Set();
@@ -63,17 +77,19 @@ function normalizeBar(bar) {
63
77
  if (!groups || groups.length === 0) continue;
64
78
  sections.push({
65
79
  name,
66
- groups: groups.map((g) => {
67
- if (groupIds.has(g.id)) throw new Error(`[ui-core] duplicate group in bar: ${g.id}`);
68
- groupIds.add(g.id);
80
+ groups: groups.map((group) => {
81
+ if (groupIds.has(group.id)) throw new Error(`[ui-core] duplicate group in bar: ${group.id}`);
82
+ groupIds.add(group.id);
83
+ const units = group.items.map((child) => normalizeChild(group.id, child, seen));
69
84
  return {
70
- id: g.id,
71
- labelKey: g.labelKey,
72
- role: g.role ?? "buttons",
73
- shed: g.shed ?? false,
74
- collapse: g.collapse,
75
- importance: g.importance ?? 3,
76
- units: g.items.map((c) => normalizeChild(g.id, c, seen))
85
+ id: group.id,
86
+ labelKey: group.labelKey,
87
+ role: group.role ?? "buttons",
88
+ shed: group.shed ?? false,
89
+ collapse: group.collapse,
90
+ importance: units.some(isStandaloneUnit) ? 5 : group.importance ?? 3,
91
+ dependent: group.dependent === true,
92
+ units
77
93
  };
78
94
  })
79
95
  });
@@ -85,204 +101,915 @@ function normalizeBar(bar) {
85
101
  }
86
102
  /**
87
103
  * Drop units whose command is currently invisible (permissions, disabled
88
- * categories, contextual visibility) BEFORE solving — an invisible unit must
104
+ * categories, contextual visibility) before solving — an invisible unit must
89
105
  * not consume budget or appear in the overflow. Groups that empty out vanish
90
- * naturally in the solver's separator math and in the projection.
106
+ * naturally in the solver's separator math and in the projection. A custom
107
+ * item without a command has nothing to ask `keep` about, and stays. A bar
108
+ * that shows nothing ({@link hasVisibleUnits}) keeps no unit at all, so its
109
+ * dependent groups go too.
91
110
  */
92
111
  function filterBar(bar, keep) {
112
+ const shows = hasVisibleUnits(bar, keep);
93
113
  return {
94
114
  id: bar.id,
95
- sections: bar.sections.map((s) => ({
96
- name: s.name,
97
- groups: s.groups.map((g) => ({
98
- ...g,
99
- units: g.units.filter(keep)
115
+ sections: bar.sections.map((section) => ({
116
+ name: section.name,
117
+ groups: section.groups.map((group) => ({
118
+ ...group,
119
+ units: shows ? group.units.filter((unit) => isStandaloneUnit(unit) || keep(unit)) : []
100
120
  }))
101
121
  }))
102
122
  };
103
123
  }
104
124
  /**
105
- * Identity + validation. Checks what is checkable without a command registry:
106
- * bars normalize cleanly, and every `menu` a strip/bar could reference exists
107
- * is left to `validateChrome` (which also takes the known command ids).
125
+ * Whether the bar shows anything once `keep` (as for {@link filterBar}) has had its say: a unit
126
+ * left in a group that isn't dependent. Dependent groups show only beside one, so a bar with
127
+ * only dependent groups left shows nothing.
108
128
  */
109
- function defineChrome(chrome) {
110
- for (const bar of Object.values(chrome.bars)) normalizeBar(bar);
111
- for (const bar of Object.values(chrome.modeBars ?? {})) normalizeBar(bar);
112
- for (const strip of Object.values(chrome.strips ?? {})) normalizeBar(strip);
113
- return chrome;
114
- }
115
- /** Dev-time cross-check: every command referenced by the chrome exists. */
116
- function validateChrome(chrome, knownCommands) {
117
- const problems = [];
118
- const checkBar = (bar, where) => {
119
- for (const section of normalizeBar(bar).sections) for (const g of section.groups) for (const u of g.units) {
120
- const refs = u.kind === "command" ? [u.command] : [u.terminal];
121
- for (const c of refs) if (!knownCommands.has(c)) problems.push(`${where}: unknown command "${c}"`);
122
- }
123
- };
124
- for (const [id, bar] of Object.entries(chrome.bars)) checkBar(bar, `bars.${id}`);
125
- for (const [id, bar] of Object.entries(chrome.modeBars ?? {})) checkBar(bar, `modeBars.${id}`);
126
- for (const [id, strip] of Object.entries(chrome.strips ?? {})) checkBar(strip, `strips.${id}`);
127
- for (const [id, menu] of Object.entries(chrome.menus ?? {})) for (const section of menu.sections) for (const c of section.items) if (!knownCommands.has(c)) problems.push(`menus.${id}: unknown command "${c}"`);
128
- return problems;
129
+ function hasVisibleUnits(bar, keep) {
130
+ return bar.sections.some((section) => section.groups.some((group) => !group.dependent && group.units.some((unit) => isStandaloneUnit(unit) || keep(unit))));
129
131
  }
130
132
  //#endregion
131
- //#region src/transforms.ts
133
+ //#region src/suggestions.ts
134
+ /**
135
+ * "Did you mean …?" for ids: the closest known id to one that doesn't exist,
136
+ * by edit distance, when it is close enough to be a typo.
137
+ */
138
+ /**
139
+ * The number of single-character inserts, deletes, substitutions and swaps of two neighbours
140
+ * that turn `left` into `right`.
141
+ */
142
+ function editDistance(left, right) {
143
+ if (left === right) return 0;
144
+ let beforePrevious = [];
145
+ let previous = Array.from({ length: right.length + 1 }, (_, j) => j);
146
+ for (let i = 1; i <= left.length; i++) {
147
+ const current = [i];
148
+ for (let j = 1; j <= right.length; j++) {
149
+ const cost = left[i - 1] === right[j - 1] ? 0 : 1;
150
+ let distance = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
151
+ if (i > 1 && j > 1 && left[i - 1] === right[j - 2] && left[i - 2] === right[j - 1]) distance = Math.min(distance, beforePrevious[j - 2] + 1);
152
+ current.push(distance);
153
+ }
154
+ beforePrevious = previous;
155
+ previous = current;
156
+ }
157
+ return previous[right.length];
158
+ }
132
159
  /**
133
- * Pure transforms over ChromeSchema — the "surgical edit" tier of the
134
- * customization ladder, between "pass nothing" and "own the whole value".
135
- * They are ordinary functions over data: no registry, no merge grammar, no
136
- * order sensitivity beyond function composition. Anything they can't express
137
- * is a sign the caller should write the schema value instead.
138
- *
139
- * Addressing is by BAR ID across all three bar collections (bars, modeBars,
140
- * strips). Mode bars may also be addressed by their surface key
141
- * ('mode:annotate') since that is what appears in `chrome.modeBars`.
160
+ * The candidate closest to `id`, or `null` when none is within a typo's reach: at most a third
161
+ * of the id's length, and never more than 3 edits apart.
142
162
  */
163
+ function closestMatch(id, candidates) {
164
+ const reach = Math.min(3, Math.max(1, Math.floor(id.length / 3)));
165
+ let best = null;
166
+ let bestDistance = reach + 1;
167
+ for (const candidate of candidates) {
168
+ if (candidate === id) continue;
169
+ const distance = editDistance(id, candidate);
170
+ if (distance < bestDistance) {
171
+ best = candidate;
172
+ bestDistance = distance;
173
+ }
174
+ }
175
+ return best;
176
+ }
177
+ /** `' (did you mean "x"?)'` for the closest candidate, or `''` when there is none. */
178
+ function didYouMean(id, candidates) {
179
+ const match = closestMatch(id, candidates);
180
+ return match === null ? "" : ` (did you mean "${match}"?)`;
181
+ }
182
+ //#endregion
183
+ //#region src/layout.ts
184
+ const states = /* @__PURE__ */ new WeakMap();
185
+ /** A layout of `data`, which it never changes. */
186
+ function createLayout(data = {}) {
187
+ return layoutOf(data, []);
188
+ }
189
+ /** Whether `value` is a layout made by {@link createLayout} or one of its edits. */
190
+ function isLayout(value) {
191
+ return typeof value === "object" && value !== null && states.has(value);
192
+ }
193
+ /** What the edits that made `layout` couldn't do. */
194
+ function layoutWarningsOf(layout) {
195
+ return states.get(layout)?.warnings ?? [];
196
+ }
197
+ /** `layout` with one more warning. */
198
+ function withLayoutWarning(layout, warning) {
199
+ const state = states.get(layout);
200
+ return state ? layoutOf(state.data, [...state.warnings, warning]) : layout;
201
+ }
202
+ function layoutOf(data, warnings) {
203
+ const next = ([nextData, added]) => layoutOf(nextData, added.length > 0 ? [...warnings, ...added] : warnings);
204
+ const layout = Object.freeze({
205
+ place: (bar, place) => next(placeBar(data, bar, place)),
206
+ addBar: (id, bar) => next([withRecord(data, "bars", id, bar), []]),
207
+ removeBar: (id) => next(removeBar(data, id)),
208
+ add: (item, options) => next(addItem(data, item, options)),
209
+ remove: (ids, options) => next(removeItems(data, typeof ids === "string" ? [ids] : ids, options)),
210
+ replace: (id, item) => next(replaceItem(data, id, item)),
211
+ addMenu: (id, sections) => next([withRecord(data, "menus", id, sections), []]),
212
+ addPanel: (id, panel) => next([withRecord(data, "panels", id, panel), []]),
213
+ panel: (id, changes) => next(updatePanel(data, id, changes)),
214
+ panels: (changes) => next([updatePanels(data, changes), []]),
215
+ removePanel: (id) => next(removePanel(data, id)),
216
+ tabs: (visibility) => next([{
217
+ ...data,
218
+ tabs: visibility
219
+ }, []]),
220
+ toJSON: () => data
221
+ });
222
+ states.set(layout, {
223
+ data,
224
+ warnings
225
+ });
226
+ return layout;
227
+ }
143
228
  const SECTION_NAMES = [
144
229
  "start",
145
230
  "center",
146
231
  "end"
147
232
  ];
148
- const isCustom = (child) => typeof child !== "string" && "slot" in child;
149
- const commandOf = (child) => typeof child === "string" ? child : isCustom(child) ? null : child.command;
150
- /** Every (collection, key) location whose bar matches `id`. */
151
- function locate(schema, id) {
152
- const hits = [];
153
- for (const coll of [
154
- "bars",
155
- "modeBars",
156
- "strips"
157
- ]) for (const [key, bar] of Object.entries(schema[coll] ?? {})) if (bar.id === id || key === id) hits.push([coll, key]);
158
- return hits;
159
- }
160
- function withBar(schema, coll, key, bar) {
233
+ /** Whether a section entry is a group written as an object. */
234
+ const isGroupObject = (entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry) && "items" in entry;
235
+ /**
236
+ * A bar section's entries; none for a section that isn't a list. Layout data comes from
237
+ * people: what isn't the right shape is skipped here and named by `validateLayout`.
238
+ */
239
+ function entriesOf(bar, name) {
240
+ const entries = bar[name];
241
+ return Array.isArray(entries) ? entries : [];
242
+ }
243
+ /** A section entry's items: a group's, or the lone item. */
244
+ function itemsOfEntry(entry) {
245
+ if (Array.isArray(entry)) return entry;
246
+ if (isGroupObject(entry)) return Array.isArray(entry.items) ? entry.items : [];
247
+ return [entry];
248
+ }
249
+ /** What an item is called in edits: its command id, your component's id, or `''` for a non-item. */
250
+ function itemIdOf(item) {
251
+ if (typeof item === "string") return item;
252
+ if (typeof item !== "object" || item === null) return "";
253
+ return "custom" in item ? item.custom : item.command;
254
+ }
255
+ /** The command an item runs or stands for in menus, or `null` for your component without one. */
256
+ function itemCommandOf(item) {
257
+ if (typeof item === "string") return item;
258
+ if (typeof item !== "object" || item === null) return null;
259
+ return "custom" in item ? item.command ?? null : item.command;
260
+ }
261
+ /** Whether `value` has an item's shape: a command id, `{ command }` or `{ custom }`. */
262
+ function isLayoutItem(value) {
263
+ if (typeof value === "string") return value.length > 0;
264
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
265
+ const item = value;
266
+ return typeof item.custom === "string" || typeof item.command === "string";
267
+ }
268
+ /** Every item id in a bar, in order, once each. */
269
+ function itemIdsOfBar(bar) {
270
+ const ids = /* @__PURE__ */ new Set();
271
+ for (const name of SECTION_NAMES) for (const entry of entriesOf(bar, name)) for (const item of itemsOfEntry(entry)) if (isLayoutItem(item)) ids.add(itemIdOf(item));
272
+ return [...ids];
273
+ }
274
+ /** A menu section's items; none for a section that isn't one. */
275
+ function itemsOfMenuSection(section) {
276
+ if (Array.isArray(section)) return section;
277
+ const items = section?.items;
278
+ return Array.isArray(items) ? items : [];
279
+ }
280
+ /** A menu's sections; none for a menu that isn't a list. */
281
+ const sectionsOfMenu = (menu) => Array.isArray(menu) ? menu : [];
282
+ /** Every item in a menu, in order, once each. */
283
+ const itemIdsOfMenu = (menu) => [...new Set(sectionsOfMenu(menu).flatMap((section) => itemsOfMenuSection(section)))];
284
+ const barsOf$1 = (data) => data.bars ?? {};
285
+ const menusOf = (data) => data.menus ?? {};
286
+ const panelsOf = (data) => data.panels ?? {};
287
+ function withRecord(data, key, id, value) {
161
288
  return {
162
- ...schema,
163
- [coll]: {
164
- ...schema[coll],
165
- [key]: bar
289
+ ...data,
290
+ [key]: {
291
+ ...data[key],
292
+ [id]: value
166
293
  }
167
294
  };
168
295
  }
169
- /** Insert `spec.item` into a group of one bar. See AddItemSpec for semantics. */
170
- function addItem(schema, spec) {
171
- const hits = locate(schema, spec.bar);
172
- if (hits.length === 0) throw new Error(`[ui-core] addItem: no bar "${spec.bar}" in bars/modeBars/strips`);
173
- if (hits.length > 1) throw new Error(`[ui-core] addItem: bar id "${spec.bar}" is ambiguous (${hits.map(([c]) => c).join(", ")})`);
174
- const [coll, key] = hits[0];
175
- const bar = schema[coll][key];
296
+ function withoutRecord(data, key, id) {
297
+ const { [id]: _removed, ...rest } = data[key] ?? {};
298
+ return {
299
+ ...data,
300
+ [key]: rest
301
+ };
302
+ }
303
+ /**
304
+ * The entry with its items replaced: `null` when the edit took its last one, so the group goes.
305
+ * A group that held none to begin with stays: the `modes` group, whose tabs are derived.
306
+ */
307
+ function withEntryItems(entry, items) {
308
+ if (items.length === 0) return itemsOfEntry(entry).length === 0 ? entry : null;
309
+ if (isGroupObject(entry)) return {
310
+ ...entry,
311
+ items
312
+ };
313
+ if (Array.isArray(entry)) return items;
314
+ return items.length === 1 ? items[0] : items;
315
+ }
316
+ /** The bar with each entry mapped; entries mapped to `null` and sections left empty go. */
317
+ function mapEntries(bar, map) {
318
+ const next = {};
176
319
  for (const name of SECTION_NAMES) {
177
- if (name === spec.section) continue;
178
- if (bar.sections[name]?.some((g) => g.id === spec.group)) throw new Error(`[ui-core] addItem: group "${spec.group}" lives in section "${name}", not "${spec.section}"`);
179
- }
180
- const groups = bar.sections[spec.section] ?? [];
181
- const idx = groups.findIndex((g) => g.id === spec.group);
182
- const nextGroups = idx === -1 ? [...groups, group(spec.group, [spec.item])] : groups.map((g, i) => {
183
- if (i !== idx) return g;
184
- const items = [...g.items];
185
- items.splice(spec.at ?? items.length, 0, spec.item);
186
- return {
187
- ...g,
188
- items
189
- };
190
- });
191
- return withBar(schema, coll, key, {
320
+ const entries = entriesOf(bar, name);
321
+ if (entries.length === 0) continue;
322
+ const mapped = entries.map((entry, index) => map(entry, name, index)).filter((entry) => entry !== null);
323
+ if (mapped.length > 0) next[name] = mapped;
324
+ }
325
+ const { start: _start, center: _center, end: _end, ...rest } = bar;
326
+ return {
327
+ ...rest,
328
+ ...next
329
+ };
330
+ }
331
+ function withMenuSectionItems(section, items) {
332
+ if (items.length === 0) return null;
333
+ return Array.isArray(section) ? items : {
334
+ ...section,
335
+ items
336
+ };
337
+ }
338
+ const notFound = (verb, id, where, candidates) => `${verb}: "${id}" is not in "${where}"${didYouMean(id, candidates)}`;
339
+ function placeBar(data, id, place) {
340
+ const bar = barsOf$1(data)[id];
341
+ if (!bar) return [data, [`place: no bar "${id}"${didYouMean(id, Object.keys(barsOf$1(data)))}`]];
342
+ return [withRecord(data, "bars", id, {
192
343
  ...bar,
193
- sections: {
194
- ...bar.sections,
195
- [spec.section]: nextGroups
344
+ place
345
+ }), []];
346
+ }
347
+ function removeBar(data, id) {
348
+ if (!barsOf$1(data)[id]) return [data, [`removeBar: no bar "${id}"${didYouMean(id, Object.keys(barsOf$1(data)))}`]];
349
+ return [withoutRecord(data, "bars", id), []];
350
+ }
351
+ function addItem(data, item, options) {
352
+ if (!isLayoutItem(item)) return [data, [`add: ${JSON.stringify(item)} is not an item`]];
353
+ const to = options?.to;
354
+ if (!to) return [data, [`add: "${itemIdOf(item)}" needs a bar or menu to go to ({ to })`]];
355
+ const bar = barsOf$1(data)[to];
356
+ if (bar) return addToBar(data, to, bar, item, options);
357
+ const menu = menusOf(data)[to];
358
+ if (menu) return addToMenu(data, to, menu, item, options);
359
+ return [data, [`add: no bar or menu "${to}"${didYouMean(to, [...Object.keys(barsOf$1(data)), ...Object.keys(menusOf(data))])}`]];
360
+ }
361
+ function addToBar(data, barId, bar, item, options) {
362
+ const id = itemIdOf(item);
363
+ const warnings = [];
364
+ const twice = (items) => items.some((each) => itemIdOf(each) === id);
365
+ const already = `add: "${id}" is already in that group of "${barId}"`;
366
+ const anchor = options.before ?? options.after;
367
+ if (anchor !== void 0) {
368
+ for (const name of SECTION_NAMES) {
369
+ const entries = entriesOf(bar, name);
370
+ for (let index = 0; index < entries.length; index++) {
371
+ const items = itemsOfEntry(entries[index]);
372
+ const at = items.findIndex((each) => itemIdOf(each) === anchor);
373
+ if (at === -1) continue;
374
+ if (twice(items)) return [data, [already]];
375
+ const cut = options.before !== void 0 ? at : at + 1;
376
+ const nextItems = [
377
+ ...items.slice(0, cut),
378
+ item,
379
+ ...items.slice(cut)
380
+ ];
381
+ return [withRecord(data, "bars", barId, mapEntries(bar, (entry, section, i) => section === name && i === index ? withEntryItems(entry, nextItems) : entry)), []];
382
+ }
196
383
  }
197
- });
384
+ warnings.push(notFound("add", anchor, barId, itemIdsOfBar(bar)));
385
+ }
386
+ if (options.group !== void 0) for (const name of SECTION_NAMES) {
387
+ const entries = entriesOf(bar, name);
388
+ const index = entries.findIndex((entry) => isGroupObject(entry) && entry.id === options.group);
389
+ if (index === -1) continue;
390
+ const items = itemsOfEntry(entries[index]);
391
+ if (twice(items)) return [data, [already]];
392
+ return [withRecord(data, "bars", barId, mapEntries(bar, (entry, section, i) => section === name && i === index ? withEntryItems(entry, [...items, item]) : entry)), warnings];
393
+ }
394
+ let section = options.section ?? "end";
395
+ if (!SECTION_NAMES.includes(section)) {
396
+ warnings.push(`add: "${section}" is not a section (start, center or end); using "end"`);
397
+ section = "end";
398
+ }
399
+ const entry = options.group !== void 0 ? {
400
+ id: options.group,
401
+ items: [item]
402
+ } : [item];
403
+ return [withRecord(data, "bars", barId, {
404
+ ...bar,
405
+ [section]: [...entriesOf(bar, section), entry]
406
+ }), warnings];
198
407
  }
408
+ function addToMenu(data, menuId, menu, item, options) {
409
+ const command = typeof item === "string" ? item : "custom" in item ? null : item.command;
410
+ if (command === null) return [data, [`add: "${menuId}" is a menu, which holds commands, not your own components`]];
411
+ if (itemIdsOfMenu(menu).includes(command)) return [data, [`add: "${command}" is already in "${menuId}"`]];
412
+ const warnings = [];
413
+ const anchor = options.before ?? options.after;
414
+ if (anchor !== void 0) {
415
+ const sections = sectionsOfMenu(menu);
416
+ const index = sections.findIndex((section) => itemsOfMenuSection(section).includes(anchor));
417
+ if (index !== -1) {
418
+ const items = itemsOfMenuSection(sections[index]);
419
+ const at = items.indexOf(anchor);
420
+ const cut = options.before !== void 0 ? at : at + 1;
421
+ const nextItems = [
422
+ ...items.slice(0, cut),
423
+ command,
424
+ ...items.slice(cut)
425
+ ];
426
+ return [withRecord(data, "menus", menuId, sections.map((section, i) => i === index ? withMenuSectionItems(section, nextItems) : section)), []];
427
+ }
428
+ warnings.push(notFound("add", anchor, menuId, itemIdsOfMenu(menu)));
429
+ }
430
+ return [withRecord(data, "menus", menuId, [...sectionsOfMenu(menu), [command]]), warnings];
431
+ }
432
+ function removeItems(data, ids, options) {
433
+ const from = options?.from;
434
+ if (!from) return [data, [`remove: ${ids.map((id) => `"${id}"`).join(", ")} needs a bar or menu to come out of ({ from }); to leave something out everywhere, turn it off with \`disable\``]];
435
+ const gone = new Set(ids);
436
+ const found = /* @__PURE__ */ new Set();
437
+ const bar = barsOf$1(data)[from];
438
+ if (bar) {
439
+ const nextBar = mapEntries(bar, (entry) => withEntryItems(entry, itemsOfEntry(entry).filter((each) => {
440
+ const id = itemIdOf(each);
441
+ if (!gone.has(id)) return true;
442
+ found.add(id);
443
+ return false;
444
+ })));
445
+ const missing = ids.filter((id) => !found.has(id));
446
+ return [found.size > 0 ? withRecord(data, "bars", from, nextBar) : data, missing.map((id) => notFound("remove", id, from, itemIdsOfBar(bar)))];
447
+ }
448
+ const menu = menusOf(data)[from];
449
+ if (menu) {
450
+ const nextMenu = sectionsOfMenu(menu).map((section) => withMenuSectionItems(section, itemsOfMenuSection(section).filter((id) => {
451
+ if (!gone.has(id)) return true;
452
+ found.add(id);
453
+ return false;
454
+ }))).filter((section) => section !== null);
455
+ const missing = ids.filter((id) => !found.has(id));
456
+ return [found.size > 0 ? withRecord(data, "menus", from, nextMenu) : data, missing.map((id) => notFound("remove", id, from, itemIdsOfMenu(menu)))];
457
+ }
458
+ return [data, [`remove: no bar or menu "${from}"${didYouMean(from, [...Object.keys(barsOf$1(data)), ...Object.keys(menusOf(data))])}`]];
459
+ }
460
+ function replaceItem(data, id, item) {
461
+ if (!isLayoutItem(item)) return [data, [`replace: ${JSON.stringify(item)} is not an item`]];
462
+ let found = false;
463
+ const bars = {};
464
+ for (const [barId, bar] of Object.entries(barsOf$1(data))) {
465
+ if (!itemIdsOfBar(bar).includes(id)) {
466
+ bars[barId] = bar;
467
+ continue;
468
+ }
469
+ found = true;
470
+ bars[barId] = mapEntries(bar, (entry) => withEntryItems(entry, itemsOfEntry(entry).map((each) => itemIdOf(each) === id ? item : each)));
471
+ }
472
+ const command = typeof item === "string" ? item : "custom" in item ? null : item.command;
473
+ const menus = {};
474
+ for (const [menuId, menu] of Object.entries(menusOf(data))) {
475
+ if (command === null || !itemIdsOfMenu(menu).includes(id)) {
476
+ menus[menuId] = menu;
477
+ continue;
478
+ }
479
+ found = true;
480
+ menus[menuId] = sectionsOfMenu(menu).map((section) => withMenuSectionItems(section, itemsOfMenuSection(section).map((each) => each === id ? command : each)));
481
+ }
482
+ if (!found) return [data, [`replace: "${id}" is in no bar or menu${didYouMean(id, [...Object.values(barsOf$1(data)).flatMap(itemIdsOfBar), ...Object.values(menusOf(data)).flatMap(itemIdsOfMenu)])}`]];
483
+ return [{
484
+ ...data,
485
+ bars,
486
+ menus
487
+ }, []];
488
+ }
489
+ /** A panel's `present` as a map of sizes: one way at every size, or the sizes it names. */
490
+ const panelPresentationsOf = (present) => typeof present === "string" ? {
491
+ compact: present,
492
+ medium: present,
493
+ expanded: present
494
+ } : present;
495
+ /** A panel's `width` as its fields: a number is where it starts. */
496
+ const panelWidthFieldsOf = (width) => typeof width === "number" ? { initial: width } : width;
199
497
  /**
200
- * Purge commands (or custom slots, by slot name) from every bar, mode bar,
201
- * strip, and menu. Groups and menu sections that empty out are dropped;
202
- * emptied menus keep their key (commands may still target them) with no rows.
498
+ * `panel` with `changes`: each field replaced, except `present` and `width`, which change only
499
+ * what the change names (a map of sizes keeps the other sizes, `{ initial }` keeps the limits).
203
500
  */
204
- function removeItems(schema, ids) {
205
- const gone = new Set(ids);
206
- const keep = (child) => {
207
- const cmd = commandOf(child);
208
- return cmd !== null ? !gone.has(cmd) : !gone.has(child.slot);
209
- };
210
- const filterGroups = (groups) => groups?.map((g) => ({
211
- ...g,
212
- items: g.items.filter(keep)
213
- })).filter((g) => g.items.length > 0);
214
- const filterBarSchema = (bar) => ({
215
- ...bar,
216
- sections: Object.fromEntries(SECTION_NAMES.flatMap((name) => {
217
- const groups = filterGroups(bar.sections[name]);
218
- return groups && groups.length > 0 ? [[name, groups]] : [];
219
- }))
220
- });
221
- const filterColl = (coll) => coll && Object.fromEntries(Object.entries(coll).map(([k, b]) => [k, filterBarSchema(b)]));
501
+ function changedPanel(panel, changes) {
502
+ const next = { ...panel };
503
+ for (const [field, value] of Object.entries(changes)) if (value !== void 0) next[field] = value;
504
+ const { present, width } = changes;
505
+ if (typeof present === "object" && panel.present !== void 0) next.present = {
506
+ ...panelPresentationsOf(panel.present),
507
+ ...present
508
+ };
509
+ if (width !== void 0 && panel.width !== void 0 && (typeof width === "object" || typeof panel.width === "object")) next.width = {
510
+ ...panelWidthFieldsOf(panel.width),
511
+ ...panelWidthFieldsOf(width)
512
+ };
513
+ return next;
514
+ }
515
+ function updatePanel(data, id, changes) {
516
+ const panel = panelsOf(data)[id];
517
+ if (!panel) return [data, [`panel: no panel "${id}"${didYouMean(id, Object.keys(panelsOf(data)))}`]];
518
+ return [withRecord(data, "panels", id, changedPanel(panel, changes)), []];
519
+ }
520
+ function updatePanels(data, changes) {
521
+ const panels = Object.fromEntries(Object.entries(panelsOf(data)).map(([id, panel]) => [id, changedPanel(panel, changes)]));
222
522
  return {
223
- ...schema,
224
- bars: filterColl(schema.bars),
225
- modeBars: filterColl(schema.modeBars),
226
- strips: filterColl(schema.strips),
227
- menus: schema.menus && Object.fromEntries(Object.entries(schema.menus).map(([k, menu]) => [k, {
228
- ...menu,
229
- sections: menu.sections.map((s) => ({
230
- ...s,
231
- items: s.items.filter((c) => !gone.has(c))
232
- })).filter((s) => s.items.length > 0)
233
- }]))
523
+ ...data,
524
+ panels
234
525
  };
235
526
  }
527
+ function removePanel(data, id) {
528
+ if (!panelsOf(data)[id]) return [data, [`removePanel: no panel "${id}"${didYouMean(id, Object.keys(panelsOf(data)))}`]];
529
+ const button = `panel:${id}`;
530
+ const bars = Object.fromEntries(Object.entries(barsOf$1(data)).map(([barId, bar]) => [barId, itemIdsOfBar(bar).includes(button) ? mapEntries(bar, (entry) => withEntryItems(entry, itemsOfEntry(entry).filter((each) => itemIdOf(each) !== button))) : bar]));
531
+ const menus = Object.fromEntries(Object.entries(menusOf(data)).map(([menuId, menu]) => [menuId, itemIdsOfMenu(menu).includes(button) ? sectionsOfMenu(menu).map((section) => withMenuSectionItems(section, itemsOfMenuSection(section).filter((each) => each !== button))).filter((section) => section !== null) : menu]));
532
+ return [{
533
+ ...withoutRecord(data, "panels", id),
534
+ bars,
535
+ menus
536
+ }, []];
537
+ }
538
+ /** The narrowest a medium viewer is, in px. */
539
+ const MEDIUM_MIN_WIDTH_PX = 600;
540
+ /** The narrowest an expanded viewer is, in px. */
541
+ const EXPANDED_MIN_WIDTH_PX = 1024;
542
+ /** The size class of a viewer this wide. */
543
+ function sizeOf(widthPx) {
544
+ if (widthPx < 600) return "compact";
545
+ return widthPx < 1024 ? "medium" : "expanded";
546
+ }
547
+ /** Whether two envs are the same: the layout function runs again only when one of the fields changes. */
548
+ const sameLayoutEnv = (left, right) => left.size === right.size && left.width === right.width && left.touch === right.touch && left.dir === right.dir;
236
549
  /**
237
- * Swap a command in place everywhere it appears — bars, mode bars, strips,
238
- * and (when the replacement is itself a plain command) menu rows.
550
+ * The layout an option gives at this env: the default without one, the function's result, or
551
+ * the option itself. A function that throws or returns no layout gives the default, with a
552
+ * warning saying so.
239
553
  */
240
- function replaceItem(schema, id, item) {
241
- const replacementCommand = commandOf(item);
242
- const swap = (child) => commandOf(child) === id ? item : child;
243
- const mapBar = (bar) => ({
244
- ...bar,
245
- sections: Object.fromEntries(SECTION_NAMES.flatMap((name) => {
246
- const groups = bar.sections[name];
247
- return groups ? [[name, groups.map((g) => ({
248
- ...g,
249
- items: g.items.map(swap)
250
- }))]] : [];
251
- }))
252
- });
253
- const mapColl = (coll) => coll && Object.fromEntries(Object.entries(coll).map(([k, b]) => [k, mapBar(b)]));
554
+ function resolveLayout(option, defaults, env) {
555
+ if (option === void 0 || option === null) return defaults;
556
+ if (typeof option === "function") {
557
+ let result;
558
+ try {
559
+ result = option(defaults, env);
560
+ } catch (error) {
561
+ return withLayoutWarning(defaults, `the layout function threw (${error instanceof Error ? error.message : String(error)}); showing the default layout`);
562
+ }
563
+ return toLayout(result, defaults, "the layout function returned");
564
+ }
565
+ return toLayout(option, defaults, "layout is");
566
+ }
567
+ function toLayout(value, defaults, what) {
568
+ if (isLayout(value)) return value;
569
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) return createLayout(value);
570
+ return withLayoutWarning(defaults, `${what} ${value === void 0 ? "nothing" : typeof value}, not a layout; showing the default layout`);
571
+ }
572
+ //#endregion
573
+ //#region src/layout-compile.ts
574
+ /** The id of the group that holds the mode tabs. */
575
+ const MODES_GROUP = "modes";
576
+ /** The command of the mode with no bar of its own: viewing. */
577
+ const VIEW_MODE_COMMAND = "mode:view";
578
+ /** The command that opens a mode, and its tab. */
579
+ const modeCommandOf = (mode) => `mode:${mode}`;
580
+ /** The command that opens and closes a panel, and its button. */
581
+ const panelCommandOf = (panel) => `panel:${panel}`;
582
+ const EDGES$1 = [
583
+ "top",
584
+ "bottom",
585
+ "start",
586
+ "end"
587
+ ];
588
+ /** Every place a bar can float over the pages: each corner, and the middle of each side. */
589
+ const FLOAT_POSITIONS = [
590
+ "top-start",
591
+ "top",
592
+ "top-end",
593
+ "start",
594
+ "end",
595
+ "bottom-start",
596
+ "bottom",
597
+ "bottom-end"
598
+ ];
599
+ /** The layout's bars, by id, in order: the ones that are objects (`validateLayout` names the rest). */
600
+ const barsOf = (data) => Object.entries(data.bars ?? {}).filter((entry) => typeof entry[1] === "object" && entry[1] !== null);
601
+ /** The edge a place is on, or `null` for a place that isn't an edge. */
602
+ function edgeOf(place) {
603
+ if (typeof place === "string") return EDGES$1.includes(place) ? place : null;
604
+ if (typeof place === "object" && place !== null && "edge" in place) return EDGES$1.includes(place.edge) ? place.edge : null;
605
+ return null;
606
+ }
607
+ /** The bar a place rides along, or `null`. */
608
+ function hostOf(place) {
609
+ return typeof place === "object" && place !== null && "with" in place ? place.with : null;
610
+ }
611
+ /** What a place follows when it is over a selection, or `null`. */
612
+ function selectionOf(place) {
613
+ return typeof place === "object" && place !== null && "at" in place ? place.at : null;
614
+ }
615
+ /** Where a place floats over the pages, or `null` for a place that doesn't float. */
616
+ function floatOf(place) {
617
+ return typeof place === "object" && place !== null && "float" in place && FLOAT_POSITIONS.includes(place.float) ? place.float : null;
618
+ }
619
+ /**
620
+ * The bars along an edge, from the outside in: in the order they were added, then each bar
621
+ * with `before` or `after` put next to the bar it names. A bar that names a bar not on this
622
+ * edge goes innermost.
623
+ */
624
+ function barsOnEdge(data, edge) {
625
+ const placed = barsOf(data).filter(([, bar]) => edgeOf(bar.place) === edge);
626
+ const anchorOf = (place) => typeof place === "object" && place !== null && "edge" in place ? place.before !== void 0 ? {
627
+ id: place.before,
628
+ after: false
629
+ } : place.after !== void 0 ? {
630
+ id: place.after,
631
+ after: true
632
+ } : null : null;
633
+ const stack = placed.filter(([, bar]) => anchorOf(bar.place) === null).map(([id]) => id);
634
+ let pending = placed.filter(([, bar]) => anchorOf(bar.place) !== null);
635
+ for (let placedOne = true; placedOne && pending.length > 0;) {
636
+ placedOne = false;
637
+ pending = pending.filter(([id, bar]) => {
638
+ const anchor = anchorOf(bar.place);
639
+ const index = stack.indexOf(anchor.id);
640
+ if (index === -1) return true;
641
+ stack.splice(anchor.after ? index + 1 : index, 0, id);
642
+ placedOne = true;
643
+ return false;
644
+ });
645
+ }
646
+ return [...stack, ...pending.map(([id]) => id)];
647
+ }
648
+ /** The bars that ride along `host`, in the order they were added, nearest first. */
649
+ function barsWith(data, host) {
650
+ return barsOf(data).filter(([, bar]) => hostOf(bar.place) === host).map(([id]) => id);
651
+ }
652
+ /** `bars` in order, each followed by the bars that ride along it (nearest first), and theirs. */
653
+ function withRiders(data, bars) {
654
+ const stack = [];
655
+ const visit = (id) => {
656
+ if (stack.includes(id)) return;
657
+ stack.push(id);
658
+ for (const rider of barsWith(data, id)) visit(rider);
659
+ };
660
+ for (const id of bars) visit(id);
661
+ return stack;
662
+ }
663
+ /**
664
+ * Every bar drawn along an edge, from the outside in: each bar on the edge, followed by the bars
665
+ * that ride along it (nearest first) and the bars that ride along those.
666
+ */
667
+ function edgeStackOf(data, edge) {
668
+ return withRiders(data, barsOnEdge(data, edge));
669
+ }
670
+ /** The bars floating at `position`, in the order they were added. */
671
+ function barsFloating(data, position) {
672
+ return barsOf(data).filter(([, bar]) => floatOf(bar.place) === position).map(([id]) => id);
673
+ }
674
+ /**
675
+ * Every bar drawn floating at `position`, from the viewer's edge in: each bar floating there,
676
+ * followed by the bars that ride along it, as {@link edgeStackOf} orders an edge.
677
+ */
678
+ function floatStackOf(data, position) {
679
+ return withRiders(data, barsFloating(data, position));
680
+ }
681
+ /** The bars over a selection of this kind, in the order they were added. */
682
+ function barsAt(data, target) {
683
+ return barsOf(data).filter(([, bar]) => selectionOf(bar.place) === target).map(([id]) => id);
684
+ }
685
+ /** Every mode of the layout's bars, once each, in bar order. */
686
+ function modesOf(data) {
687
+ const modes = /* @__PURE__ */ new Set();
688
+ for (const [, bar] of barsOf(data)) if (typeof bar.mode === "string" && bar.mode.length > 0) modes.add(bar.mode);
689
+ return [...modes];
690
+ }
691
+ /** The bars shown in a mode, in bar order. */
692
+ function barsInMode(data, mode) {
693
+ return barsOf(data).filter(([, bar]) => bar.mode === mode).map(([id]) => id);
694
+ }
695
+ const NOTHING = /* @__PURE__ */ new Set();
696
+ const toBarChild = (item) => {
697
+ if (typeof item === "string") return item;
698
+ if ("custom" in item) {
699
+ const custom = { slot: item.custom };
700
+ if (item.command !== void 0) custom.terminal = item.command;
701
+ if (item.variants !== void 0) custom.variants = item.variants;
702
+ if (item.importance !== void 0) custom.importance = item.importance;
703
+ return custom;
704
+ }
705
+ const command = { command: item.command };
706
+ if (item.variants !== void 0) command.variants = item.variants;
707
+ if (item.importance !== void 0) command.importance = item.importance;
708
+ return command;
709
+ };
710
+ /** The mode tabs, each a label at importance 1, so the tab strip is the first to compact. */
711
+ function modeTabsOf(data, omit) {
712
+ const modes = modesOf(data).map(modeCommandOf).filter((command) => !omit.has(command));
713
+ if (modes.length === 0) return [];
714
+ return (omit.has("mode:view") ? modes : [VIEW_MODE_COMMAND, ...modes]).map((command) => ({
715
+ command,
716
+ variants: ["label"],
717
+ importance: 1
718
+ }));
719
+ }
720
+ /**
721
+ * A bar as the toolbar fits it, or `null` for a bar the layout doesn't have. Groups written as
722
+ * arrays and lone items get ids of their own (`start-0`, …); an item twice in one group shows
723
+ * once; a group with nothing left goes.
724
+ */
725
+ function compileBar(data, id, options = {}) {
726
+ const bar = data.bars?.[id];
727
+ if (typeof bar !== "object" || bar === null) return null;
728
+ const omit = options.omit ?? NOTHING;
729
+ const used = /* @__PURE__ */ new Set();
730
+ const uniqueId = (wanted) => {
731
+ let groupId = wanted;
732
+ for (let count = 2; used.has(groupId); count++) groupId = `${wanted}-${count}`;
733
+ used.add(groupId);
734
+ return groupId;
735
+ };
736
+ const sections = {};
737
+ for (const name of SECTION_NAMES) {
738
+ const groups = [];
739
+ entriesOf(bar, name).forEach((entry, index) => {
740
+ const object = isGroupObject(entry) ? entry : null;
741
+ const seen = /* @__PURE__ */ new Set();
742
+ let items = itemsOfEntry(entry).filter((item) => {
743
+ if (!isLayoutItem(item)) return false;
744
+ const itemId = itemIdOf(item);
745
+ if (seen.has(itemId)) return false;
746
+ seen.add(itemId);
747
+ return typeof item === "object" && "custom" in item ? true : !omit.has(itemId);
748
+ });
749
+ const isModes = object?.id === MODES_GROUP;
750
+ if (isModes) items = [...modeTabsOf(data, omit).filter((tab) => !seen.has(tab.command)), ...items];
751
+ if (items.length === 0) return;
752
+ const group = {
753
+ id: uniqueId(object?.id ?? `${name}-${index}`),
754
+ items: items.map(toBarChild)
755
+ };
756
+ if (isModes) group.role = "tabs";
757
+ if (object?.titleKey !== void 0) group.labelKey = object.titleKey;
758
+ if (object?.shed !== void 0) group.shed = object.shed;
759
+ if (object?.collapse !== void 0) group.collapse = object.collapse;
760
+ if (object?.importance !== void 0) group.importance = object.importance;
761
+ if (object?.dependent === true) group.dependent = true;
762
+ groups.push(group);
763
+ });
764
+ if (groups.length > 0) sections[name] = groups;
765
+ }
254
766
  return {
255
- ...schema,
256
- bars: mapColl(schema.bars),
257
- modeBars: mapColl(schema.modeBars),
258
- strips: mapColl(schema.strips),
259
- menus: replacementCommand === null ? schema.menus : schema.menus && Object.fromEntries(Object.entries(schema.menus).map(([k, menu]) => [k, {
260
- ...menu,
261
- sections: menu.sections.map((s) => ({
262
- ...s,
263
- items: s.items.map((c) => c === id ? replacementCommand : c)
264
- }))
265
- }]))
767
+ id,
768
+ sections
266
769
  };
267
770
  }
771
+ /** When the document tabs show: as the layout says, else while more than one document is open. */
772
+ function tabsOf(data) {
773
+ return data.tabs ?? "multiple";
774
+ }
775
+ /** A menu's sections, or `null` for a menu the layout doesn't have. An empty section goes. */
776
+ function menuOf(data, id) {
777
+ const menu = data.menus?.[id];
778
+ if (!menu) return null;
779
+ const commandsOf = (section) => [...new Set(itemsOfMenuSection(section).filter((item) => typeof item === "string"))];
780
+ return sectionsOfMenu(menu).map((section) => {
781
+ if (Array.isArray(section) || typeof section !== "object" || section === null) return { items: commandsOf(section) };
782
+ const titled = section;
783
+ const read = { items: commandsOf(section) };
784
+ if (titled.title !== void 0) read.title = titled.title;
785
+ if (titled.titleKey !== void 0) read.titleKey = titled.titleKey;
786
+ return read;
787
+ }).filter((section) => section.items.length > 0);
788
+ }
789
+ const reachByData = /* @__PURE__ */ new WeakMap();
790
+ function reachOf(data) {
791
+ const known = reachByData.get(data);
792
+ if (known) return known;
793
+ const modes = modesOf(data).map((mode) => [mode, barsInMode(data, mode).map((id) => normalizeBar(compileBar(data, id)))]);
794
+ const menus = new Map(Object.keys(data.menus ?? {}).map((id) => [id, menuOf(data, id).flatMap((section) => section.items)]));
795
+ const buttons = /* @__PURE__ */ new Set();
796
+ for (const [, bar] of barsOf(data)) for (const name of SECTION_NAMES) for (const entry of entriesOf(bar, name)) for (const item of itemsOfEntry(entry)) if (typeof item === "string") buttons.add(item);
797
+ else if (isLayoutItem(item) && !("custom" in item)) buttons.add(item.command);
798
+ const reach = {
799
+ modes,
800
+ menus,
801
+ buttons: [...buttons]
802
+ };
803
+ reachByData.set(data, reach);
804
+ return reach;
805
+ }
806
+ /**
807
+ * The commands the bars leave out because what they lead to shows nothing: the tab of a mode
808
+ * whose bars have nothing visible ({@link hasVisibleUnits}: dependent groups alone don't count),
809
+ * and a button whose menu has nothing visible (or that the layout has no menu for). Sorted, so
810
+ * two equal results compare equal; pass it to {@link compileBar} as `omit`. Cheap to call on every change: what it reads of the layout is
811
+ * read once per layout data.
812
+ */
813
+ function omittedCommandsOf(data, resolve) {
814
+ const reach = reachOf(data);
815
+ const isVisible = (id) => resolve(id)?.visible === true;
816
+ const omitted = [];
817
+ for (const [mode, bars] of reach.modes) if (!bars.some((bar) => hasVisibleUnits(bar, (unit) => {
818
+ const command = commandOfUnit(unit);
819
+ return command !== null && isVisible(command);
820
+ }))) omitted.push(modeCommandOf(mode));
821
+ for (const button of reach.buttons) {
822
+ const menu = resolve(button)?.menu;
823
+ if (menu !== void 0 && !(reach.menus.get(menu) ?? []).some(isVisible)) omitted.push(button);
824
+ }
825
+ return omitted.sort();
826
+ }
268
827
  //#endregion
269
- //#region src/helpers.ts
828
+ //#region src/layout-validate.ts
829
+ const EDGES = [
830
+ "top",
831
+ "bottom",
832
+ "start",
833
+ "end"
834
+ ];
835
+ const FLOAT_POSITIONS$1 = [
836
+ "top-start",
837
+ "top",
838
+ "top-end",
839
+ "start",
840
+ "end",
841
+ "bottom-start",
842
+ "bottom",
843
+ "bottom-end"
844
+ ];
845
+ const SELECTION_TARGETS = ["selection", "annotation"];
846
+ const PRESENTATIONS = [
847
+ "dock",
848
+ "overlay",
849
+ "sheet"
850
+ ];
851
+ const SIZES = [
852
+ "compact",
853
+ "medium",
854
+ "expanded"
855
+ ];
856
+ const WIDTH_FIELDS = [
857
+ "initial",
858
+ "min",
859
+ "max"
860
+ ];
861
+ const TABS = [
862
+ "always",
863
+ "multiple",
864
+ "never"
865
+ ];
270
866
  /**
271
- * The helpers bundle handed to `chrome: (base, h) => …` transforms — the
272
- * surgical edits plus the authoring sugar, so simple customizations need no
273
- * imports at all. One object, frozen shape: it IS public API (the vanilla
274
- * snippet passes it across the config boundary), so additions are features
275
- * and removals are breaking.
867
+ * Every problem with a layout, as sentences: what its edits couldn't do first, then what its
868
+ * data names that the viewer doesn't have. Empty when there is none.
276
869
  */
277
- const chromeHelpers = {
278
- addItem,
279
- removeItems,
280
- replaceItem,
281
- item,
282
- group,
283
- custom,
284
- defineChrome
285
- };
870
+ function validateLayout(layout, known) {
871
+ const problems = isLayout(layout) ? [...layoutWarningsOf(layout)] : [];
872
+ const data = isLayout(layout) ? layout.toJSON() : layout;
873
+ if (typeof data !== "object" || data === null) return [...problems, "the layout is not an object"];
874
+ const bars = data.bars ?? {};
875
+ const knownCommands = new Set(known.commands);
876
+ const commands = new Set(knownCommands);
877
+ for (const mode of modesOf(data)) commands.add(modeCommandOf(mode));
878
+ if (modesOf(data).length > 0) commands.add(VIEW_MODE_COMMAND);
879
+ for (const panel of Object.keys(data.panels ?? {})) commands.add(panelCommandOf(panel));
880
+ const checkCommand = (where, command) => {
881
+ if (!commands.has(command) && known.hasCommand?.(command) !== true) problems.push(`${where}: no command "${command}"${didYouMean(command, commands)}`);
882
+ };
883
+ for (const [id, bar] of Object.entries(bars)) {
884
+ const where = `bar "${id}"`;
885
+ if (typeof bar !== "object" || bar === null) {
886
+ problems.push(`${where} is not a bar`);
887
+ continue;
888
+ }
889
+ problems.push(...placeProblems(where, id, bar.place, data));
890
+ if (bar.mode !== void 0 && bar.title === void 0 && bar.titleKey === void 0) {
891
+ if (!knownCommands.has(modeCommandOf(bar.mode))) problems.push(`${where}: its mode "${bar.mode}" has no title, so its tab reads "${bar.mode}"`);
892
+ }
893
+ problems.push(...itemProblems(where, bar, checkCommand));
894
+ }
895
+ for (const [id, menu] of Object.entries(data.menus ?? {})) problems.push(...menuProblems(`menu "${id}"`, menu, checkCommand));
896
+ for (const [id, panel] of Object.entries(data.panels ?? {})) problems.push(...panelProblems(`panel "${id}"`, panel));
897
+ if (data.tabs !== void 0 && !TABS.includes(data.tabs)) problems.push(`tabs: "${data.tabs}" is not one of always, multiple or never`);
898
+ return problems;
899
+ }
900
+ function placeProblems(where, id, place, data) {
901
+ const bars = data.bars ?? {};
902
+ const notAPlace = (shown) => `${where}: ${shown} is not a place (an edge, { edge, before | after }, { float }, { with } or { at })`;
903
+ if (place === void 0) return [`${where} has no place`];
904
+ if (typeof place === "string") return EDGES.includes(place) ? [] : [notAPlace(`"${place}"`)];
905
+ if (typeof place !== "object" || place === null) return [notAPlace(String(place))];
906
+ if ("edge" in place) {
907
+ if (!EDGES.includes(place.edge)) return [`${where}: "${place.edge}" is not an edge`];
908
+ const anchor = place.before ?? place.after;
909
+ if (anchor === void 0) return [];
910
+ const side = place.before !== void 0 ? "before" : "after";
911
+ const onEdge = Object.keys(bars).filter((other) => other !== id && edgeOf(bars[other].place) === place.edge);
912
+ if (onEdge.includes(anchor)) return [];
913
+ if (bars[anchor]) return [`${where} goes ${side} "${anchor}", which is not on the ${place.edge} edge`];
914
+ return [`${where} goes ${side} "${anchor}": no such bar${didYouMean(anchor, onEdge)}`];
915
+ }
916
+ if ("float" in place) return FLOAT_POSITIONS$1.includes(place.float) ? [] : [`${where}: "${place.float}" is not a floating position${didYouMean(place.float, FLOAT_POSITIONS$1)}`];
917
+ if ("with" in place) {
918
+ if (place.with === id) return [`${where} rides along itself`];
919
+ if (!bars[place.with]) return [`${where} rides along "${place.with}": no such bar${didYouMean(place.with, Object.keys(bars))}`];
920
+ const seen = /* @__PURE__ */ new Set([id]);
921
+ for (let host = place.with; host !== null; host = hostOf(bars[host]?.place)) {
922
+ if (seen.has(host)) return [`${where} rides along a bar that rides along it`];
923
+ seen.add(host);
924
+ }
925
+ return [];
926
+ }
927
+ if ("at" in place) return SELECTION_TARGETS.includes(place.at) ? [] : [`${where}: "${place.at}" is not selection or annotation`];
928
+ return [notAPlace(JSON.stringify(place))];
929
+ }
930
+ function itemProblems(where, bar, checkCommand) {
931
+ const problems = [];
932
+ const groupIds = /* @__PURE__ */ new Set();
933
+ let groups = 0;
934
+ let dependentGroups = 0;
935
+ for (const name of SECTION_NAMES) {
936
+ const entries = bar[name];
937
+ if (entries === void 0) continue;
938
+ if (!Array.isArray(entries)) {
939
+ problems.push(`${where}: its ${name} section is not a list of groups`);
940
+ continue;
941
+ }
942
+ for (const entry of entries) {
943
+ if (isGroupObject(entry) && entry.id !== void 0) {
944
+ if (groupIds.has(entry.id)) problems.push(`${where}: two groups are called "${entry.id}"`);
945
+ groupIds.add(entry.id);
946
+ }
947
+ groups++;
948
+ if (isGroupObject(entry) && entry.dependent !== void 0) {
949
+ const dependent = entry.dependent;
950
+ if (dependent === true) dependentGroups++;
951
+ else if (dependent !== false) problems.push(`${where}: ${entry.id === void 0 ? "a group" : `group "${entry.id}"`} has dependent ${JSON.stringify(dependent)}, which is not true or false`);
952
+ }
953
+ const seen = /* @__PURE__ */ new Set();
954
+ for (const item of itemsOfEntry(entry)) {
955
+ if (!isLayoutItem(item)) {
956
+ problems.push(`${where}: ${JSON.stringify(item)} is not an item`);
957
+ continue;
958
+ }
959
+ const id = itemIdOf(item);
960
+ if (seen.has(id)) problems.push(`${where}: "${id}" is twice in one group`);
961
+ seen.add(id);
962
+ const command = itemCommandOf(item);
963
+ if (command !== null) checkCommand(where, command);
964
+ }
965
+ }
966
+ }
967
+ if (groups > 0 && dependentGroups === groups) problems.push(`${where}: every group in it is dependent, so it never shows`);
968
+ return problems;
969
+ }
970
+ function menuProblems(where, menu, checkCommand) {
971
+ if (!Array.isArray(menu)) return [`${where} is not a list of sections`];
972
+ const problems = [];
973
+ const seen = /* @__PURE__ */ new Set();
974
+ for (const section of menu) for (const command of itemsOfMenuSection(section)) {
975
+ if (typeof command !== "string") {
976
+ problems.push(`${where}: ${JSON.stringify(command)} is not a command id`);
977
+ continue;
978
+ }
979
+ if (seen.has(command)) problems.push(`${where}: "${command}" is in it twice`);
980
+ seen.add(command);
981
+ checkCommand(where, command);
982
+ }
983
+ return problems;
984
+ }
985
+ const isWidthPx = (value) => typeof value === "number" && Number.isFinite(value) && value > 0;
986
+ function presentProblems(where, present) {
987
+ if (typeof present === "string") return PRESENTATIONS.includes(present) ? [] : [`${where}: present "${present}" is not dock, overlay or sheet`];
988
+ if (typeof present !== "object" || present === null || Array.isArray(present)) return [`${where}: present ${JSON.stringify(present)} is not a way to open, or one per size`];
989
+ const problems = [];
990
+ for (const [size, way] of Object.entries(present)) if (!SIZES.includes(size)) problems.push(`${where}: present has "${size}", not a size${didYouMean(size, SIZES)}`);
991
+ else if (way !== void 0 && !PRESENTATIONS.includes(way)) problems.push(`${where}: present.${size} "${way}" is not dock, overlay or sheet`);
992
+ return problems;
993
+ }
994
+ function widthProblems(where, width) {
995
+ if (typeof width === "number") return isWidthPx(width) ? [] : [`${where}: width ${width} is not a width in px`];
996
+ if (typeof width !== "object" || width === null || Array.isArray(width)) return [`${where}: width ${JSON.stringify(width)} is not a width in px, or { initial, min, max }`];
997
+ const problems = [];
998
+ const fields = width;
999
+ for (const [field, value] of Object.entries(fields)) if (!WIDTH_FIELDS.includes(field)) problems.push(`${where}: width has "${field}", not initial, min or max`);
1000
+ else if (value !== void 0 && !isWidthPx(value)) problems.push(`${where}: width.${field} ${JSON.stringify(value)} is not a width in px`);
1001
+ if (isWidthPx(fields.min) && isWidthPx(fields.max) && fields.min > fields.max) problems.push(`${where}: width.min ${fields.min} is wider than width.max ${fields.max}`);
1002
+ return problems;
1003
+ }
1004
+ function panelProblems(where, panel) {
1005
+ if (typeof panel !== "object" || panel === null) return [`${where} is not a panel`];
1006
+ const problems = [];
1007
+ if (panel.side !== void 0 && panel.side !== "start" && panel.side !== "end") problems.push(`${where}: side "${panel.side}" is not start or end`);
1008
+ if (panel.present !== void 0) problems.push(...presentProblems(where, panel.present));
1009
+ if (panel.width !== void 0) problems.push(...widthProblems(where, panel.width));
1010
+ if (panel.resizable !== void 0 && typeof panel.resizable !== "boolean") problems.push(`${where}: resizable ${JSON.stringify(panel.resizable)} is not true or false`);
1011
+ return problems;
1012
+ }
286
1013
  //#endregion
287
1014
  //#region src/solver.ts
288
1015
  /** variant < shed < collapse < overflow when importance ties. */
@@ -296,17 +1023,17 @@ const STEP_RANK = {
296
1023
  function buildState(bar) {
297
1024
  const groups = [];
298
1025
  let position = 0;
299
- for (const section of bar.sections) for (const g of section.groups) {
1026
+ for (const section of bar.sections) for (const group of section.groups) {
300
1027
  const gs = {
301
- group: g,
1028
+ group,
302
1029
  section: section.name,
303
1030
  collapsed: false,
304
1031
  overflowed: false,
305
1032
  units: []
306
1033
  };
307
- for (const u of g.units) {
1034
+ for (const unit of group.units) {
308
1035
  gs.units.push({
309
- unit: u,
1036
+ unit,
310
1037
  group: gs,
311
1038
  variantIndex: 0,
312
1039
  shed: false,
@@ -337,7 +1064,7 @@ function nextStep(groups) {
337
1064
  continue;
338
1065
  }
339
1066
  let childrenExhausted = true;
340
- const visibleCount = gs.units.filter((u) => !u.overflowed && !u.shed).length;
1067
+ const visibleCount = gs.units.filter((unit) => !unit.overflowed && !unit.shed).length;
341
1068
  for (const us of gs.units) {
342
1069
  if (us.overflowed || us.shed) continue;
343
1070
  if (us.variantIndex < us.unit.variants.length - 1) {
@@ -394,32 +1121,32 @@ function applyStep(step) {
394
1121
  return;
395
1122
  case "overflow-unit":
396
1123
  step.unit.overflowed = true;
397
- if (step.unit.group.units.every((u) => u.overflowed)) step.unit.group.overflowed = true;
1124
+ if (step.unit.group.units.every((unit) => unit.overflowed)) step.unit.group.overflowed = true;
398
1125
  return;
399
1126
  case "overflow-group":
400
1127
  step.group.overflowed = true;
401
- for (const u of step.group.units) u.overflowed = true;
1128
+ for (const unit of step.group.units) unit.overflowed = true;
402
1129
  return;
403
1130
  }
404
1131
  }
405
1132
  /**
406
- * Visible width of the current assignment: unit widths + gaps + derived
407
- * separators. Gaps are counted between ALL adjacent visible units, including
1133
+ * Visible length of the current assignment: unit lengths + gaps + derived
1134
+ * separators. Gaps are counted between all adjacent visible units, including
408
1135
  * across section boundaries — deliberately conservative: the adapter's layout
409
1136
  * spaces sections with collapsing auto margins, so the cross-section gap
410
1137
  * allowance (≤ 2×gap) is slack those margins absorb, never an overlap risk.
411
1138
  */
412
1139
  function measure(groups, metrics, hasTrigger) {
413
- let width = 0;
1140
+ let length = 0;
414
1141
  let complete = true;
415
1142
  let visibleUnits = 0;
416
1143
  const visibleBySection = /* @__PURE__ */ new Map();
417
1144
  for (const gs of groups) {
418
1145
  if (gs.overflowed) continue;
419
1146
  if (gs.collapsed) {
420
- const w = metrics.groupCollapsed(gs.group.id);
421
- if (w === void 0) complete = false;
422
- width += w ?? 0;
1147
+ const collapsedLength = metrics.groupCollapsed(gs.group.id);
1148
+ if (collapsedLength === void 0) complete = false;
1149
+ length += collapsedLength ?? 0;
423
1150
  visibleUnits += 1;
424
1151
  visibleBySection.set(gs.section, (visibleBySection.get(gs.section) ?? 0) + 1);
425
1152
  continue;
@@ -432,39 +1159,39 @@ function measure(groups, metrics, hasTrigger) {
432
1159
  shedCount += 1;
433
1160
  continue;
434
1161
  }
435
- const w = metrics.unit(us.unit.key, us.unit.variants[us.variantIndex]);
436
- if (w === void 0) complete = false;
437
- width += w ?? 0;
1162
+ const unitLength = metrics.unit(us.unit.key, us.unit.variants[us.variantIndex]);
1163
+ if (unitLength === void 0) complete = false;
1164
+ length += unitLength ?? 0;
438
1165
  visibleUnits += 1;
439
1166
  groupVisible = true;
440
1167
  }
441
1168
  if (shedCount > 0) {
442
- const w = metrics.groupTrigger(gs.group.id);
443
- if (w === void 0) complete = false;
444
- width += w ?? 0;
1169
+ const triggerLength = metrics.groupTrigger(gs.group.id);
1170
+ if (triggerLength === void 0) complete = false;
1171
+ length += triggerLength ?? 0;
445
1172
  visibleUnits += 1;
446
1173
  groupVisible = true;
447
1174
  }
448
1175
  if (groupVisible) visibleBySection.set(gs.section, (visibleBySection.get(gs.section) ?? 0) + 1);
449
1176
  }
450
1177
  if (hasTrigger) {
451
- width += metrics.overflowTrigger;
1178
+ length += metrics.overflowTrigger;
452
1179
  visibleUnits += 1;
453
1180
  }
454
- if (visibleUnits > 1) width += metrics.gap * (visibleUnits - 1);
455
- for (const count of visibleBySection.values()) if (count > 1) width += metrics.separator * (count - 1);
1181
+ if (visibleUnits > 1) length += metrics.gap * (visibleUnits - 1);
1182
+ for (const count of visibleBySection.values()) if (count > 1) length += metrics.separator * (count - 1);
456
1183
  return {
457
- width,
1184
+ length,
458
1185
  complete
459
1186
  };
460
1187
  }
461
1188
  function anyOverflow(groups) {
462
- return groups.some((gs) => gs.units.some((u) => u.overflowed));
1189
+ return groups.some((gs) => gs.units.some((unit) => unit.overflowed));
463
1190
  }
464
1191
  function solvePass(bar, metrics, budget, hasTrigger) {
465
1192
  const groups = buildState(bar);
466
1193
  let measured = measure(groups, metrics, hasTrigger);
467
- while (measured.width > budget) {
1194
+ while (measured.length > budget) {
468
1195
  const step = nextStep(groups);
469
1196
  if (!step) break;
470
1197
  applyStep(step);
@@ -475,13 +1202,14 @@ function solvePass(bar, metrics, budget, hasTrigger) {
475
1202
  measured
476
1203
  };
477
1204
  }
478
- function solve(bar, metrics, containerWidth) {
479
- let pass = solvePass(bar, metrics, containerWidth, false);
480
- if (anyOverflow(pass.groups)) pass = solvePass(bar, metrics, containerWidth, true);
1205
+ /** Fit `bar` into `containerLength`, the room along its axis. */
1206
+ function solve(bar, metrics, containerLength) {
1207
+ let pass = solvePass(bar, metrics, containerLength, false);
1208
+ if (anyOverflow(pass.groups)) pass = solvePass(bar, metrics, containerLength, true);
481
1209
  const units = /* @__PURE__ */ new Map();
482
1210
  const groupAssignments = /* @__PURE__ */ new Map();
483
1211
  for (const gs of pass.groups) {
484
- const shedCount = gs.units.filter((u) => u.shed && !u.overflowed && !gs.collapsed).length;
1212
+ const shedCount = gs.units.filter((unit) => unit.shed && !unit.overflowed && !gs.collapsed).length;
485
1213
  groupAssignments.set(gs.group.id, {
486
1214
  shedCount,
487
1215
  collapsed: gs.collapsed,
@@ -496,28 +1224,30 @@ function solve(bar, metrics, containerWidth) {
496
1224
  units,
497
1225
  groups: groupAssignments,
498
1226
  hasOverflow: anyOverflow(pass.groups),
499
- width: pass.measured.width,
1227
+ length: pass.measured.length,
500
1228
  complete: pass.measured.complete
501
1229
  };
502
1230
  }
503
1231
  //#endregion
504
1232
  //#region src/projection.ts
1233
+ /** A unit's menu form; a custom item's is its terminal command's. A standalone item has none. */
505
1234
  function projectUnit(unit, resolve) {
506
- const command = unit.kind === "custom" ? unit.terminal : unit.command;
1235
+ const command = commandOfUnit(unit);
1236
+ if (command === null) return [];
507
1237
  const menu = resolve(command)?.menu;
508
- return menu ? {
1238
+ return [menu ? {
509
1239
  type: "submenu",
510
1240
  command,
511
1241
  menu
512
1242
  } : {
513
1243
  type: "command",
514
1244
  command
515
- };
1245
+ }];
516
1246
  }
517
1247
  function projectOverflow(bar, fit, resolve) {
518
1248
  const sections = [];
519
1249
  for (const section of bar.sections) for (const group of section.groups) {
520
- const rows = group.units.filter((u) => fit.units.get(u.key)?.kind === "overflow").map((u) => projectUnit(u, resolve));
1250
+ const rows = group.units.filter((unit) => fit.units.get(unit.key)?.kind === "overflow").flatMap((unit) => projectUnit(unit, resolve));
521
1251
  if (rows.length === 0) continue;
522
1252
  sections.push({
523
1253
  labelKey: group.labelKey,
@@ -529,26 +1259,30 @@ function projectOverflow(bar, fit, resolve) {
529
1259
  }
530
1260
  /**
531
1261
  * The content of a group's disclosure trigger: its shed units, in bar order,
532
- * in menu form — same totality rules as the global overflow. This is v2's
533
- * hand-written `mode-tabs-overflow-menu`, derived.
1262
+ * in menu form — same totality rules as the global overflow.
534
1263
  */
535
1264
  function projectShed(group, fit, resolve) {
536
- return group.units.filter((u) => fit.units.get(u.key)?.kind === "shed").map((u) => projectUnit(u, resolve));
1265
+ return group.units.filter((unit) => fit.units.get(unit.key)?.kind === "shed").flatMap((unit) => projectUnit(unit, resolve));
537
1266
  }
538
1267
  /**
539
- * Strip projection — a bar projected for UN-measured rendering (a contextual
1268
+ * Strip projection — a bar projected for un-measured rendering (a contextual
540
1269
  * strip anchored to a selection has no fit pressure to solve). The schema
541
- * declares what COULD appear; `visible` (the command registry's runtime truth)
542
- * decides what DOES: hidden commands drop out, groups that empty out vanish,
543
- * and an all-hidden bar projects to [] — "render nothing" falls out. Custom
1270
+ * declares what could appear; `visible` (the command registry's runtime truth)
1271
+ * decides what does: hidden commands drop out, groups that empty out vanish,
1272
+ * and an all-hidden bar projects to [] — "render nothing" falls out, as it
1273
+ * does for a bar with only dependent groups left ({@link filterBar}). Custom
544
1274
  * units project through their `terminal` command, same totality rule as the
545
1275
  * overflow. Upgrading a strip to measured/overflowing rendering later is a
546
1276
  * renderer swap (<Toolbar>), never a schema change.
547
1277
  */
548
1278
  function projectStrip(bar, visible) {
1279
+ const shown = filterBar(bar, (unit) => {
1280
+ const command = commandOfUnit(unit);
1281
+ return command !== null && visible(command);
1282
+ });
549
1283
  const groups = [];
550
- for (const section of bar.sections) for (const group of section.groups) {
551
- const commands = group.units.map((u) => u.kind === "custom" ? u.terminal : u.command).filter(visible);
1284
+ for (const section of shown.sections) for (const group of section.groups) {
1285
+ const commands = group.units.map(commandOfUnit).filter((command) => command !== null);
552
1286
  if (commands.length === 0) continue;
553
1287
  groups.push({
554
1288
  id: group.id,
@@ -559,6 +1293,182 @@ function projectStrip(bar, visible) {
559
1293
  return groups;
560
1294
  }
561
1295
  //#endregion
1296
+ //#region src/toolbar-layout.ts
1297
+ const SECTIONS = [
1298
+ "start",
1299
+ "center",
1300
+ "end"
1301
+ ];
1302
+ const ICON_ONLY = ["icon"];
1303
+ /** The bar as a vertical toolbar draws it: every command an icon; custom items keep their variants. */
1304
+ function iconsOnly(bar) {
1305
+ return {
1306
+ ...bar,
1307
+ sections: bar.sections.map((section) => ({
1308
+ ...section,
1309
+ groups: section.groups.map((group) => ({
1310
+ ...group,
1311
+ units: group.units.map((unit) => unit.kind === "command" ? {
1312
+ ...unit,
1313
+ variants: ICON_ONLY
1314
+ } : unit)
1315
+ }))
1316
+ }))
1317
+ };
1318
+ }
1319
+ /** Fit the bar into `containerLength` and describe what to draw. */
1320
+ function layoutToolbar(input) {
1321
+ const { resolve, execute, measureKey } = input;
1322
+ /** A unit's command, resolved; null for a standalone item or an unregistered command. */
1323
+ const resolveUnit = (unit) => {
1324
+ const command = commandOfUnit(unit);
1325
+ return command === null ? null : resolve(command);
1326
+ };
1327
+ const visible = filterBar(input.bar, (unit) => resolveUnit(unit)?.visible === true);
1328
+ const bar = input.orientation === "vertical" ? iconsOnly(visible) : visible;
1329
+ const fit = solve(bar, input.metrics, input.containerLength);
1330
+ const visibleCommands = (units) => units.map(resolveUnit).filter((command) => command !== null && command.visible);
1331
+ const parts = {
1332
+ unit: (unit, variant) => {
1333
+ if (unit.kind === "custom") return {
1334
+ kind: "custom",
1335
+ key: unit.key,
1336
+ measureKey: measureKey.unit(unit.key, variant),
1337
+ name: unit.slot,
1338
+ variant,
1339
+ terminal: resolveUnit(unit),
1340
+ runTerminal: () => {
1341
+ if (unit.terminal !== null) execute(unit.terminal);
1342
+ }
1343
+ };
1344
+ const command = resolve(unit.command);
1345
+ return {
1346
+ kind: "command",
1347
+ key: unit.key,
1348
+ measureKey: measureKey.unit(unit.key, variant),
1349
+ command: command ?? input.unregistered(unit.command),
1350
+ variant,
1351
+ run: command ? () => execute(unit.command) : () => {}
1352
+ };
1353
+ },
1354
+ collapsed: (group) => ({
1355
+ kind: "collapsed",
1356
+ key: group.id,
1357
+ measureKey: measureKey.group(group.id),
1358
+ view: {
1359
+ id: group.id,
1360
+ labelKey: group.labelKey,
1361
+ collapse: group.collapse ?? "menu",
1362
+ role: group.role,
1363
+ commands: visibleCommands(group.units),
1364
+ execute
1365
+ }
1366
+ }),
1367
+ disclosure: (group, allChildren) => ({
1368
+ kind: "disclosure",
1369
+ key: `${group.id}::trigger`,
1370
+ measureKey: measureKey.groupTrigger(group.id),
1371
+ view: {
1372
+ id: group.id,
1373
+ labelKey: group.labelKey,
1374
+ role: group.role,
1375
+ commands: visibleCommands(group.units.filter((unit) => allChildren || fit.units.get(unit.key)?.kind === "shed")),
1376
+ execute
1377
+ }
1378
+ })
1379
+ };
1380
+ return {
1381
+ sections: SECTIONS.map((name) => liveSection(name, bar, fit, parts)),
1382
+ measured: measuredParts(bar, parts),
1383
+ hasOverflow: fit.hasOverflow,
1384
+ overflow: projectOverflow(bar, fit, input.menuTarget)
1385
+ };
1386
+ }
1387
+ function liveSection(name, bar, fit, parts) {
1388
+ const section = bar.sections.find((each) => each.name === name);
1389
+ const groups = [];
1390
+ for (const group of section?.groups ?? []) {
1391
+ const assignment = fit.groups.get(group.id);
1392
+ if (!assignment || assignment.overflowed) continue;
1393
+ if (assignment.collapsed) {
1394
+ groups.push({
1395
+ id: group.id,
1396
+ parts: [parts.collapsed(group)]
1397
+ });
1398
+ continue;
1399
+ }
1400
+ const drawn = [];
1401
+ for (const unit of group.units) {
1402
+ const placed = fit.units.get(unit.key);
1403
+ if (placed?.kind === "variant") drawn.push(parts.unit(unit, placed.variant));
1404
+ }
1405
+ if (assignment.shedCount > 0) drawn.push(parts.disclosure(group, false));
1406
+ if (drawn.length > 0) groups.push({
1407
+ id: group.id,
1408
+ parts: drawn
1409
+ });
1410
+ }
1411
+ return {
1412
+ name,
1413
+ groups
1414
+ };
1415
+ }
1416
+ function measuredParts(bar, parts) {
1417
+ const measured = [];
1418
+ for (const section of bar.sections) for (const group of section.groups) {
1419
+ for (const unit of group.units) for (const variant of unit.variants) measured.push(parts.unit(unit, variant));
1420
+ if (group.collapse) measured.push(parts.collapsed(group));
1421
+ if (group.shed) measured.push(parts.disclosure(group, true));
1422
+ }
1423
+ return measured;
1424
+ }
1425
+ /**
1426
+ * The menu a default folded group or group trigger opens: one section of the
1427
+ * group's commands, a radio group when the group is tabs.
1428
+ */
1429
+ function groupMenuView(view, isOpen, close) {
1430
+ return {
1431
+ sections: [{
1432
+ labelKey: view.labelKey,
1433
+ role: view.role === "tabs" ? "radio" : void 0,
1434
+ rows: view.commands.map((command) => ({
1435
+ type: "command",
1436
+ command: command.id
1437
+ }))
1438
+ }],
1439
+ isOpen,
1440
+ close,
1441
+ resolve: (id) => view.commands.find((command) => command.id === id) ?? null,
1442
+ execute: (id) => view.execute(id)
1443
+ };
1444
+ }
1445
+ /**
1446
+ * A bar as a strip shows it now (`projectStrip`), each command resolved: the
1447
+ * schema says what could appear, each command's `visible` decides what does.
1448
+ * Empty while nothing applies.
1449
+ */
1450
+ function stripGroupsOf(bar, resolve) {
1451
+ const resolved = /* @__PURE__ */ new Map();
1452
+ const visible = (id) => {
1453
+ const command = resolve(id);
1454
+ if (command) resolved.set(id, command);
1455
+ return command?.visible === true;
1456
+ };
1457
+ return projectStrip(bar, visible).map((group) => ({
1458
+ id: group.id,
1459
+ labelKey: group.labelKey,
1460
+ commands: group.commands.map((id) => resolved.get(id))
1461
+ }));
1462
+ }
1463
+ /**
1464
+ * Whether two resolved strips show the same, group by group and command by
1465
+ * command (`sameCommand` is plugin-commands' `resolvedCommandsEqual`): a
1466
+ * strip's readers pass it on only when this is false.
1467
+ */
1468
+ function sameStripGroups(left, right, sameCommand) {
1469
+ return left.length === right.length && left.every((group, i) => group.id === right[i].id && group.commands.length === right[i].commands.length && group.commands.every((command, j) => sameCommand(command, right[i].commands[j])));
1470
+ }
1471
+ //#endregion
562
1472
  //#region src/shortcuts.ts
563
1473
  const MODIFIERS = /* @__PURE__ */ new Set([
564
1474
  "mod",
@@ -572,7 +1482,7 @@ const MODIFIERS = /* @__PURE__ */ new Set([
572
1482
  "shift"
573
1483
  ]);
574
1484
  function parseShortcut(shortcut) {
575
- const tokens = shortcut.split("+").map((t) => t.trim());
1485
+ const tokens = shortcut.split("+").map((token) => token.trim());
576
1486
  const parsed = {
577
1487
  key: (tokens[tokens.length - 1] === "" ? "+" : tokens[tokens.length - 1]).toLowerCase(),
578
1488
  mod: false,
@@ -582,34 +1492,74 @@ function parseShortcut(shortcut) {
582
1492
  shift: false
583
1493
  };
584
1494
  for (const token of tokens.slice(0, -1)) {
585
- const t = token.toLowerCase();
586
- if (t === "") continue;
587
- if (!MODIFIERS.has(t)) throw new Error(`[ui-core] unknown modifier "${token}" in "${shortcut}"`);
588
- if (t === "mod") parsed.mod = true;
589
- else if (t === "ctrl" || t === "control") parsed.ctrl = true;
590
- else if (t === "meta" || t === "cmd" || t === "command") parsed.meta = true;
591
- else if (t === "alt" || t === "option") parsed.alt = true;
1495
+ const modifier = token.toLowerCase();
1496
+ if (modifier === "") continue;
1497
+ if (!MODIFIERS.has(modifier)) throw new Error(`[ui-core] unknown modifier "${token}" in "${shortcut}"`);
1498
+ if (modifier === "mod") parsed.mod = true;
1499
+ else if (modifier === "ctrl" || modifier === "control") parsed.ctrl = true;
1500
+ else if (modifier === "meta" || modifier === "cmd" || modifier === "command") parsed.meta = true;
1501
+ else if (modifier === "alt" || modifier === "option") parsed.alt = true;
592
1502
  else parsed.shift = true;
593
1503
  }
594
1504
  if (parsed.key === "") throw new Error(`[ui-core] empty key in shortcut "${shortcut}"`);
595
1505
  return parsed;
596
1506
  }
597
- function matchShortcut(parsed, stroke, opts) {
598
- const wantCtrl = parsed.ctrl || parsed.mod && !opts.isMac;
599
- const wantMeta = parsed.meta || parsed.mod && opts.isMac;
1507
+ function matchShortcut(parsed, stroke, options) {
1508
+ const wantCtrl = parsed.ctrl || parsed.mod && !options.isMac;
1509
+ const wantMeta = parsed.meta || parsed.mod && options.isMac;
600
1510
  if (stroke.ctrlKey !== wantCtrl) return false;
601
1511
  if (stroke.metaKey !== wantMeta) return false;
602
1512
  if (stroke.altKey !== parsed.alt) return false;
603
1513
  if (parsed.shift && !stroke.shiftKey) return false;
604
- if (!parsed.shift && stroke.shiftKey && parsed.key.length > 1) return false;
1514
+ const hasCase = parsed.key !== parsed.key.toUpperCase();
1515
+ if (!parsed.shift && stroke.shiftKey && (parsed.key.length > 1 || hasCase)) return false;
605
1516
  if (stroke.key.toLowerCase() === parsed.key) return true;
606
1517
  return parsed.key.length > 1 && stroke.code !== void 0 && stroke.code.toLowerCase() === parsed.key;
607
1518
  }
608
- /** Display form for menu rows: '⌘K' on mac, 'Ctrl+K' elsewhere. */
609
- function formatShortcut(shortcut, opts) {
1519
+ /**
1520
+ * Whether two shortcuts are pressed with the same keys on this platform: `'Mod+S'` and
1521
+ * `'Ctrl+s'` are where `Mod` is Ctrl, and not on a Mac.
1522
+ */
1523
+ function sameShortcut(left, right, options) {
1524
+ const keysOf = (shortcut) => {
1525
+ const parsed = parseShortcut(shortcut);
1526
+ return [
1527
+ parsed.key,
1528
+ parsed.ctrl || parsed.mod && !options.isMac,
1529
+ parsed.meta || parsed.mod && options.isMac,
1530
+ parsed.alt,
1531
+ parsed.shift
1532
+ ].join(" ");
1533
+ };
1534
+ return keysOf(left) === keysOf(right);
1535
+ }
1536
+ /**
1537
+ * How a key shows, by its lowercase name, where that isn't the name itself:
1538
+ * on a Mac, then elsewhere. Matches what each platform's own menus show.
1539
+ */
1540
+ const KEY_NAMES = {
1541
+ arrowleft: ["←", "←"],
1542
+ arrowright: ["→", "→"],
1543
+ arrowup: ["↑", "↑"],
1544
+ arrowdown: ["↓", "↓"],
1545
+ backspace: ["⌫", "Backspace"],
1546
+ delete: ["⌦", "Delete"],
1547
+ enter: ["↩", "Enter"],
1548
+ escape: ["Esc", "Esc"],
1549
+ tab: ["⇥", "Tab"],
1550
+ " ": ["Space", "Space"],
1551
+ space: ["Space", "Space"],
1552
+ pageup: ["Page Up", "Page Up"],
1553
+ pagedown: ["Page Down", "Page Down"],
1554
+ numpadadd: ["Num +", "Num +"],
1555
+ numpadsubtract: ["Num -", "Num -"]
1556
+ };
1557
+ /** A shortcut as a menu or a tooltip shows it: `'⌘K'` on a Mac, `'Ctrl+K'` elsewhere. */
1558
+ function formatShortcut(shortcut, options) {
610
1559
  const parsed = parseShortcut(shortcut);
611
- const key = parsed.key.length === 1 ? parsed.key.toUpperCase() : capitalize(parsed.key);
612
- if (opts.isMac) return `${[
1560
+ const named = KEY_NAMES[parsed.key];
1561
+ const key = named ? named[options.isMac ? 0 : 1] : parsed.key.length === 1 ? parsed.key.toUpperCase() : capitalize(parsed.key);
1562
+ if (options.isMac) return `${[
613
1563
  parsed.ctrl ? "⌃" : "",
614
1564
  parsed.alt ? "⌥" : "",
615
1565
  parsed.shift ? "⇧" : "",
@@ -622,29 +1572,146 @@ function formatShortcut(shortcut, opts) {
622
1572
  parsed.shift ? "Shift" : ""
623
1573
  ].filter(Boolean), key].join("+");
624
1574
  }
625
- function capitalize(s) {
626
- return s.charAt(0).toUpperCase() + s.slice(1);
1575
+ function capitalize(text) {
1576
+ return text.charAt(0).toUpperCase() + text.slice(1);
1577
+ }
1578
+ //#endregion
1579
+ //#region src/tree-keys.ts
1580
+ /** The row `row` sits under: the nearest one above it that is one level up, or -1 at the top. */
1581
+ function parentOf(rows, row) {
1582
+ const depth = rows[row].depth;
1583
+ for (let i = row - 1; i >= 0; i -= 1) if (rows[i].depth < depth) return i;
1584
+ return -1;
1585
+ }
1586
+ /** The rows that share `row`'s parent, `row` among them, in order. */
1587
+ function siblingsOf(rows, row) {
1588
+ const depth = rows[row].depth;
1589
+ const siblings = [];
1590
+ for (let i = parentOf(rows, row) + 1; i < rows.length; i += 1) {
1591
+ if (rows[i].depth < depth) break;
1592
+ if (rows[i].depth === depth) siblings.push(i);
1593
+ }
1594
+ return siblings;
1595
+ }
1596
+ function treeKeyAction(rows, focused, key, options = {}) {
1597
+ if (rows.length === 0) return null;
1598
+ const last = rows.length - 1;
1599
+ if (focused < 0 || focused > last) {
1600
+ if (key === "ArrowDown" || key === "Home") return {
1601
+ type: "focus",
1602
+ row: 0
1603
+ };
1604
+ if (key === "ArrowUp" || key === "End") return {
1605
+ type: "focus",
1606
+ row: last
1607
+ };
1608
+ return null;
1609
+ }
1610
+ const current = rows[focused];
1611
+ const focus = (row) => row === focused ? null : {
1612
+ type: "focus",
1613
+ row
1614
+ };
1615
+ switch (key) {
1616
+ case "ArrowDown": return focus(Math.min(focused + 1, last));
1617
+ case "ArrowUp": return focus(Math.max(focused - 1, 0));
1618
+ case "Home": return focus(0);
1619
+ case "End": return focus(last);
1620
+ case "ArrowRight":
1621
+ if (!current.hasChildren) return null;
1622
+ return current.expanded ? focus(focused + 1) : {
1623
+ type: "expand",
1624
+ rows: [focused]
1625
+ };
1626
+ case "ArrowLeft": {
1627
+ if (current.hasChildren && current.expanded) return {
1628
+ type: "collapse",
1629
+ row: focused
1630
+ };
1631
+ const parent = parentOf(rows, focused);
1632
+ return parent < 0 ? null : focus(parent);
1633
+ }
1634
+ case "*": {
1635
+ const collapsed = siblingsOf(rows, focused).filter((row) => rows[row].hasChildren && !rows[row].expanded);
1636
+ return collapsed.length > 0 ? {
1637
+ type: "expand",
1638
+ rows: collapsed
1639
+ } : null;
1640
+ }
1641
+ case "Enter": return {
1642
+ type: "activate",
1643
+ row: focused
1644
+ };
1645
+ case "F2": return options.editable ? {
1646
+ type: "rename",
1647
+ row: focused
1648
+ } : null;
1649
+ case "Delete": return options.editable ? {
1650
+ type: "delete",
1651
+ row: focused
1652
+ } : null;
1653
+ default: return null;
1654
+ }
627
1655
  }
628
1656
  //#endregion
629
1657
  exports.DEFAULT_IMPORTANCE = DEFAULT_IMPORTANCE;
1658
+ exports.EXPANDED_MIN_WIDTH_PX = EXPANDED_MIN_WIDTH_PX;
1659
+ exports.FLOAT_POSITIONS = FLOAT_POSITIONS;
1660
+ exports.MEDIUM_MIN_WIDTH_PX = MEDIUM_MIN_WIDTH_PX;
1661
+ exports.MODES_GROUP = MODES_GROUP;
630
1662
  exports.PINNED = PINNED;
631
- exports.addItem = addItem;
632
- exports.chromeHelpers = chromeHelpers;
1663
+ exports.VIEW_MODE_COMMAND = VIEW_MODE_COMMAND;
1664
+ exports.barsAt = barsAt;
1665
+ exports.barsFloating = barsFloating;
1666
+ exports.barsInMode = barsInMode;
1667
+ exports.barsOnEdge = barsOnEdge;
1668
+ exports.barsWith = barsWith;
1669
+ exports.closestMatch = closestMatch;
1670
+ exports.commandOfUnit = commandOfUnit;
1671
+ exports.compileBar = compileBar;
1672
+ exports.createLayout = createLayout;
633
1673
  exports.custom = custom;
634
- exports.defineChrome = defineChrome;
1674
+ exports.didYouMean = didYouMean;
1675
+ exports.edgeOf = edgeOf;
1676
+ exports.edgeStackOf = edgeStackOf;
1677
+ exports.editDistance = editDistance;
635
1678
  exports.filterBar = filterBar;
1679
+ exports.floatOf = floatOf;
1680
+ exports.floatStackOf = floatStackOf;
636
1681
  exports.formatShortcut = formatShortcut;
637
1682
  exports.group = group;
1683
+ exports.groupMenuView = groupMenuView;
1684
+ exports.hasVisibleUnits = hasVisibleUnits;
1685
+ exports.hostOf = hostOf;
1686
+ exports.isLayout = isLayout;
1687
+ exports.isStandaloneUnit = isStandaloneUnit;
638
1688
  exports.item = item;
1689
+ exports.itemCommandOf = itemCommandOf;
1690
+ exports.itemIdOf = itemIdOf;
1691
+ exports.layoutToolbar = layoutToolbar;
639
1692
  exports.matchShortcut = matchShortcut;
1693
+ exports.menuOf = menuOf;
1694
+ exports.modeCommandOf = modeCommandOf;
1695
+ exports.modesOf = modesOf;
640
1696
  exports.normalizeBar = normalizeBar;
1697
+ exports.omittedCommandsOf = omittedCommandsOf;
1698
+ exports.panelCommandOf = panelCommandOf;
1699
+ exports.panelPresentationsOf = panelPresentationsOf;
1700
+ exports.panelWidthFieldsOf = panelWidthFieldsOf;
641
1701
  exports.parseShortcut = parseShortcut;
642
1702
  exports.projectOverflow = projectOverflow;
643
1703
  exports.projectShed = projectShed;
644
1704
  exports.projectStrip = projectStrip;
645
- exports.removeItems = removeItems;
646
- exports.replaceItem = replaceItem;
1705
+ exports.resolveLayout = resolveLayout;
1706
+ exports.sameLayoutEnv = sameLayoutEnv;
1707
+ exports.sameShortcut = sameShortcut;
1708
+ exports.sameStripGroups = sameStripGroups;
1709
+ exports.selectionOf = selectionOf;
1710
+ exports.sizeOf = sizeOf;
647
1711
  exports.solve = solve;
648
- exports.validateChrome = validateChrome;
1712
+ exports.stripGroupsOf = stripGroupsOf;
1713
+ exports.tabsOf = tabsOf;
1714
+ exports.treeKeyAction = treeKeyAction;
1715
+ exports.validateLayout = validateLayout;
649
1716
 
650
1717
  //# sourceMappingURL=index.cjs.map