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