@gjsify/adwaita-web 0.64.13 → 0.64.15-next.287

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.
Files changed (36) hide show
  1. package/lib/types/application.d.ts +3 -3
  2. package/lib/types/elements/adw-application-window.d.ts +4 -4
  3. package/lib/types/elements/adw-split-button.d.ts +19 -28
  4. package/lib/types/elements/adw-tab-overview.d.ts +4 -4
  5. package/lib/types/elements/gtk-menu-button.d.ts +30 -42
  6. package/lib/types/elements/gtk-popover-bin.d.ts +18 -11
  7. package/lib/types/elements/gtk-popover-menu-bar.d.ts +15 -8
  8. package/lib/types/elements/gtk-popover-menu.d.ts +13 -15
  9. package/lib/types/elements/menu-slot.d.ts +49 -0
  10. package/lib/types/elements/popover-menu.d.ts +11 -7
  11. package/lib/types/index.d.ts +2 -1
  12. package/lib/types/namespace/gio.d.ts +1 -1
  13. package/package.json +13 -12
  14. package/src/adw-application-window.spec.ts +5 -2
  15. package/src/application.ts +4 -4
  16. package/src/elements/adw-application-window.ts +17 -21
  17. package/src/elements/adw-split-button.ts +35 -59
  18. package/src/elements/adw-tab-overview.ts +18 -28
  19. package/src/elements/gtk-menu-button.ts +60 -82
  20. package/src/elements/gtk-popover-bin.ts +32 -38
  21. package/src/elements/gtk-popover-menu-bar.ts +41 -46
  22. package/src/elements/gtk-popover-menu.ts +35 -54
  23. package/src/elements/menu-slot.ts +139 -0
  24. package/src/elements/popover-menu.ts +26 -16
  25. package/src/gio-menu.spec.ts +173 -34
  26. package/src/gtk-image.spec.ts +3 -2
  27. package/src/gtk-menu-button.spec.ts +19 -16
  28. package/src/gtk-popover-bin.spec.ts +6 -5
  29. package/src/gtk-popover-menu-bar.spec.ts +11 -6
  30. package/src/gtk-popover-menu.spec.ts +22 -22
  31. package/src/index.ts +10 -2
  32. package/src/keyboard-operable.spec.ts +6 -5
  33. package/src/menu-actions.spec.ts +6 -1
  34. package/src/namespace/gio.ts +19 -2
  35. package/src/shared-tree-builder.ts +6 -1
  36. package/src/split-button.spec.ts +25 -8
@@ -32,7 +32,8 @@
32
32
  // handle-input — boolean, default FALSE — GTK's default. When set, a right click and a
33
33
  // long press open the popover at the pointer.
34
34
  // Properties:
35
- // menuModel — the model, normalised (get/set). Setting it REPLACES any popover.
35
+ // menuModel — the real `Gio.MenuModel`, or `null` (get/set; ADR 0128). Setting it
36
+ // REPLACES any popover.
36
37
  // popoverElement — the `<gtk-popover>` this bin presents (get/set; setting it drops
37
38
  // the model, as GTK does). `GtkPopoverBin`'s own name for it is `popover`,
38
39
  // which `HTMLElement` already spells as a string attribute for the DOM
@@ -60,14 +61,9 @@
60
61
  // Copyright (c) GNOME contributors (GTK, libadwaita). LGPLv2.1+
61
62
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
62
63
 
63
- import {
64
- ADW_MENU_SURFACE_WEB,
65
- assertMenuRenderable,
66
- menuRefusals,
67
- normalizeMenuModel,
68
- parseMenuModel,
69
- } from '@gjsify/adwaita-core';
70
- import type { AdwMenuActions, AdwMenuInput, AdwMenuModel } from '@gjsify/adwaita-core';
64
+ import type { AdwMenuActions, AdwMenuModel } from '@gjsify/adwaita-core';
65
+
66
+ import { MenuSlot } from './menu-slot.js';
71
67
 
72
68
  // Both definitions have to be there before this module's own can build them in
73
69
  // `connectedCallback`; a combined `import { GtkPopoverMenu }` would be elided, because the
@@ -82,7 +78,14 @@ import { WidgetElement } from '../widget-element.js';
82
78
  const LONG_PRESS_MS = 500;
83
79
 
84
80
  export class GtkPopoverBin extends WidgetElement {
85
- private _model: AdwMenuModel = [];
81
+ /**
82
+ * The model's door and `items-changed` subscription (ADR 0128), with NOTHING to do
83
+ * when the menu it holds mutates: this bin draws no rows. The `<gtk-popover-menu>` it
84
+ * built holds the SAME model and watches it through a slot of its own, so the rows
85
+ * follow one level down — and GTK sends no `notify::menu-model` for a mutation either
86
+ * (measured, `MENU_MODEL_NOTIFY_VECTORS`).
87
+ */
88
+ private readonly _slot = new MenuSlot(() => undefined);
86
89
  private _popover: GtkPopoverMenu | null = null;
87
90
  private _actions: AdwMenuActions | null = null;
88
91
  private _initialized = false;
@@ -92,9 +95,9 @@ export class GtkPopoverBin extends WidgetElement {
92
95
  return ['menu-model', 'handle-input'];
93
96
  }
94
97
 
95
- /** The menu model, normalised (ADR 0042). */
96
- get menuModel(): AdwMenuModel {
97
- return this._model;
98
+ /** The menu this bin pops up, by identity, or `null` (ADR 0128). */
99
+ get menuModel(): AdwMenuModel | null {
100
+ return this._slot.model;
98
101
  }
99
102
 
100
103
  /**
@@ -102,9 +105,8 @@ export class GtkPopoverBin extends WidgetElement {
102
105
  * setters clear each other (gtkpopoverbin.c:474-500, :543-551). A model REPLACES the
103
106
  * popover; a popover DROPS the model, and both notify for both properties.
104
107
  */
105
- set menuModel(value: AdwMenuInput | AdwMenuModel) {
106
- this._model = normalizeMenuModel(value as AdwMenuInput);
107
- assertMenuRenderable(this._model, ADW_MENU_SURFACE_WEB);
108
+ set menuModel(value: AdwMenuModel | string | null) {
109
+ this._slot.write(value);
108
110
  this._adoptPopover(this._buildPopover());
109
111
  this._notifyModel();
110
112
  }
@@ -123,7 +125,7 @@ export class GtkPopoverBin extends WidgetElement {
123
125
  }
124
126
 
125
127
  set popoverElement(value: GtkPopoverMenu | null) {
126
- this._model = [];
128
+ this._slot.write(null);
127
129
  this._adoptPopover(value);
128
130
  this._notifyModel();
129
131
  }
@@ -157,8 +159,8 @@ export class GtkPopoverBin extends WidgetElement {
157
159
  this.addEventListener('contextmenu', (event) => this._onContextMenu(event));
158
160
  this.addEventListener('keydown', (event) => this._onKeyDown(event));
159
161
  this._wireGestures();
160
- if (this._model.length === 0) this._model = this._parseMenuAttr();
161
- if (this._model.length > 0) this._adoptPopover(this._buildPopover());
162
+ if (this._slot.model === null) this._slot.writeMarkup(this.getAttribute('menu-model'));
163
+ this._adoptPopover(this._buildPopover());
162
164
  }
163
165
 
164
166
  disconnectedCallback() {
@@ -176,8 +178,8 @@ export class GtkPopoverBin extends WidgetElement {
176
178
  return;
177
179
  }
178
180
  if (name === 'menu-model') {
179
- this._model = this._parseMenuAttr();
180
- this._adoptPopover(this._model.length > 0 ? this._buildPopover() : null);
181
+ this._slot.writeMarkup(this.getAttribute('menu-model'));
182
+ this._adoptPopover(this._buildPopover());
181
183
  this._notifyModel();
182
184
  }
183
185
  }
@@ -265,14 +267,20 @@ export class GtkPopoverBin extends WidgetElement {
265
267
  this.popup();
266
268
  }
267
269
 
270
+ /**
271
+ * The popover follows the PRESENCE of a model, not its length: an empty non-null
272
+ * `Gio.Menu` at `menu-model` still builds a `GtkPopoverMenu` in GTK and still
273
+ * dissociates an application-set popover (measured, `MENU_MODEL_DOOR_VECTORS`), and
274
+ * `null` is the one spelling of "no menu".
275
+ */
268
276
  private _buildPopover(): GtkPopoverMenu | null {
269
- if (this._model.length === 0) return null;
277
+ if (this._slot.model === null) return null;
270
278
  const popover = document.createElement('gtk-popover-menu') as GtkPopoverMenu;
271
279
  // `gtk_popover_set_position (popover, GTK_POS_BOTTOM)` in the same function
272
280
  // (gtkpopoverbin.c:493), on a popover `gtk_popover_menu_new_from_model` made.
273
281
  popover.setAttribute('position', 'bottom');
274
282
  popover.setAttribute('align', 'start');
275
- popover.menuModel = this._model;
283
+ popover.menuModel = this._slot.model;
276
284
  popover.actions = this._actions;
277
285
  popover.anchor = this;
278
286
  return popover;
@@ -313,24 +321,10 @@ export class GtkPopoverBin extends WidgetElement {
313
321
  this.dispatchEvent(
314
322
  new CustomEvent('notify::menu-model', {
315
323
  bubbles: true,
316
- detail: { menuModel: this._model, popover: this._popover },
324
+ detail: { menuModel: this._slot.model, popover: this._popover },
317
325
  }),
318
326
  );
319
327
  }
320
-
321
- /**
322
- * The `menu-model` ATTRIBUTE through the core parser, refusing a `custom` item the way
323
- * every menu-bearing surface here does: markup is parsed by the browser, so a throw from
324
- * `connectedCallback` would reach nobody and the menu would be silently empty.
325
- */
326
- private _parseMenuAttr(): AdwMenuModel {
327
- const model = normalizeMenuModel(parseMenuModel(this.getAttribute('menu-model')));
328
- const refusals = menuRefusals(model, ADW_MENU_SURFACE_WEB);
329
- if (refusals.length === 0) return model;
330
- console.error(`${ADW_MENU_SURFACE_WEB.name} cannot render this menu, so it has none:`);
331
- for (const refusal of refusals) console.error(` [${refusal.path.join('.')}] ${refusal.message}`);
332
- return [];
333
- }
334
328
  }
335
329
 
336
330
  customElements.define('gtk-popover-bin', GtkPopoverBin);
@@ -37,7 +37,8 @@
37
37
  // menu-model — the portable menu model as JSON (ADR 0042), whose TOPLEVEL entries must
38
38
  // all be submenus.
39
39
  // Properties:
40
- // menuModel — the model, normalised (get/set; a `custom` item throws here).
40
+ // menuModel — the real `Gio.MenuModel`, or `null` (get/set; ADR 0128 — a `custom` item
41
+ // throws here).
41
42
  // actions — what the action group publishes about the actions the menus name.
42
43
  // activeItem — the focused/selected item's button, or `null`. GTK's `active_item`.
43
44
  // Methods:
@@ -57,16 +58,10 @@
57
58
  // Copyright (c) GNOME contributors (GTK, libadwaita). LGPLv2.1+
58
59
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
59
60
 
60
- import {
61
- ADW_MENU_SURFACE_WEB,
62
- assertMenuRenderable,
63
- menuItemAt,
64
- menuLabelText,
65
- menuRefusals,
66
- normalizeMenuModel,
67
- parseMenuModel,
68
- } from '@gjsify/adwaita-core';
69
- import type { AdwMenuActions, AdwMenuInput, AdwMenuModel, AdwMenuPath, AdwMenuSubmenu } from '@gjsify/adwaita-core';
61
+ import { buildMenu, menuItemAt, menuLabelText } from '@gjsify/adwaita-core';
62
+ import type { AdwMenuActions, AdwMenuModel, AdwMenuPath, AdwMenuSubmenu } from '@gjsify/adwaita-core';
63
+
64
+ import { MenuSlot } from './menu-slot.js';
70
65
 
71
66
  // The definition has to be there before this module's own can build one in
72
67
  // `connectedCallback`; a combined `import { GtkPopoverMenu }` would be elided, because the
@@ -88,7 +83,8 @@ interface MenuBarItem {
88
83
  }
89
84
 
90
85
  export class GtkPopoverMenuBar extends WidgetElement {
91
- private _model: AdwMenuModel = [];
86
+ /** The model, its tree snapshot and its `items-changed` subscription (ADR 0128). */
87
+ private readonly _slot = new MenuSlot(() => this._menuChanged());
92
88
  private _actions: AdwMenuActions | null = null;
93
89
  private _items: MenuBarItem[] = [];
94
90
  private _active: MenuBarItem | null = null;
@@ -98,14 +94,29 @@ export class GtkPopoverMenuBar extends WidgetElement {
98
94
  return ['menu-model'];
99
95
  }
100
96
 
101
- /** The menu model, normalised (ADR 0042). */
102
- get menuModel(): AdwMenuModel {
103
- return this._model;
97
+ /** The menu this bar draws, by identity, or `null` (ADR 0128). */
98
+ get menuModel(): AdwMenuModel | null {
99
+ return this._slot.model;
100
+ }
101
+
102
+ set menuModel(value: AdwMenuModel | string | null) {
103
+ this._slot.write(value);
104
+ this._menuChanged();
104
105
  }
105
106
 
106
- set menuModel(value: AdwMenuInput) {
107
- this._model = normalizeMenuModel(value);
108
- assertMenuRenderable(this._model, ADW_MENU_SURFACE_WEB);
107
+ /**
108
+ * The one place a changed menu is answered — from either door AND from a mutation of
109
+ * the menu the bar is holding, which is why the `console.warn` below is here rather
110
+ * than beside the attribute parse it used to live in: a toplevel entry that is not a
111
+ * submenu can arrive by `append_item` just as easily as by markup.
112
+ */
113
+ private _menuChanged(): void {
114
+ for (const [index, node] of this._slot.tree.entries()) {
115
+ if (node.kind === 'submenu') continue;
116
+ // A WARNING and not an error, and the distinction is GTK's: `g_warning` is
117
+ // recoverable, and the submenus beside the offending entry still work.
118
+ console.warn(`Don't know how to handle this item: top-level entry ${index} is a ${node.kind}.`);
119
+ }
109
120
  if (this._initialized) this._rebuild();
110
121
  }
111
122
 
@@ -136,14 +147,14 @@ export class GtkPopoverMenuBar extends WidgetElement {
136
147
  this.addEventListener('pointerleave', () => {
137
148
  if (this._active !== null && !this._active.popover.open) this._setActive(null, false);
138
149
  });
139
- if (this._model.length === 0) this._model = this._parseMenuAttr();
140
- this._rebuild();
150
+ if (this._slot.model === null) this._slot.writeMarkup(this.getAttribute('menu-model'));
151
+ this._menuChanged();
141
152
  }
142
153
 
143
154
  attributeChangedCallback(name?: string) {
144
155
  if (!this._initialized || name !== 'menu-model') return;
145
- this._model = this._parseMenuAttr();
146
- this._rebuild();
156
+ this._slot.writeMarkup(this.getAttribute('menu-model'));
157
+ this._menuChanged();
147
158
  }
148
159
 
149
160
  /** `gtk_popover_menu_bar_select_first` — activate and OPEN the first item. */
@@ -213,7 +224,7 @@ export class GtkPopoverMenuBar extends WidgetElement {
213
224
  this._items = [];
214
225
  this._active = null;
215
226
  const built: MenuBarItem[] = [];
216
- this._model.forEach((node, index) => {
227
+ this._slot.tree.forEach((node, index) => {
217
228
  if (node.kind !== 'submenu') return;
218
229
  built.push(this._buildItem(node, [index]));
219
230
  });
@@ -237,8 +248,8 @@ export class GtkPopoverMenuBar extends WidgetElement {
237
248
  /**
238
249
  * One `menubar > item > popover` (the node tree in gtkpopovermenubar.c:38-46).
239
250
  *
240
- * A toplevel node that is not a submenu is refused by `_parseMenuAttr` with GTK's own
241
- * wording rather than drawn: `tracker_insert` builds nothing for it
251
+ * A toplevel node that is not a submenu is WARNED about by {@link _menuChanged} with
252
+ * GTK's own wording rather than drawn: `tracker_insert` builds nothing for it
242
253
  * (gtkpopovermenubar.c:466), and a row that opens nothing is the same lie in another
243
254
  * shape.
244
255
  */
@@ -269,9 +280,10 @@ export class GtkPopoverMenuBar extends WidgetElement {
269
280
  // (gtkpopovermenubar.c:435-436).
270
281
  popover.setAttribute('position', 'bottom');
271
282
  popover.setAttribute('align', 'start');
272
- // The submenu's OWN items, handed back as they are: `normalizeMenuModel` is
273
- // idempotent and explicitly takes an already-normalised node (ADR 0042).
274
- popover.menuModel = node.items;
283
+ // The submenu's OWN items, as a menu OF THEIR OWN: since ADR 0128 the popover takes
284
+ // a real `Gio.MenuModel`, and `buildMenu` is how a tree becomes one. Handing over
285
+ // the bar's own model instead would give the popover the whole bar.
286
+ popover.menuModel = buildMenu(node.items);
275
287
  popover.actions = this._actions;
276
288
  popover.anchor = button;
277
289
  // `gtk_accessible_update_relation (…, LABELLED_BY, item->label, CONTROLS,
@@ -324,7 +336,7 @@ export class GtkPopoverMenuBar extends WidgetElement {
324
336
  * row is not the row that opened a submenu (gtkpopovermenu.c:257-259).
325
337
  */
326
338
  private _activate(item: MenuBarItem, path: AdwMenuPath): void {
327
- const target = menuItemAt(this._model, path);
339
+ const target = menuItemAt(this._slot.tree, path);
328
340
  item.popover.popdown();
329
341
  item.button.focus();
330
342
  if (target === null) return;
@@ -335,23 +347,6 @@ export class GtkPopoverMenuBar extends WidgetElement {
335
347
  }),
336
348
  );
337
349
  }
338
-
339
- private _parseMenuAttr(): AdwMenuModel {
340
- const model = normalizeMenuModel(parseMenuModel(this.getAttribute('menu-model')));
341
- const refusals = menuRefusals(model, ADW_MENU_SURFACE_WEB);
342
- if (refusals.length > 0) {
343
- console.error(`${ADW_MENU_SURFACE_WEB.name} cannot render this menu, so it has none:`);
344
- for (const refusal of refusals) console.error(` [${refusal.path.join('.')}] ${refusal.message}`);
345
- return [];
346
- }
347
- for (const [index, node] of model.entries()) {
348
- if (node.kind === 'submenu') continue;
349
- // A WARNING and not an error, and the distinction is GTK's: `g_warning` is
350
- // recoverable, and the submenus beside the offending entry still work.
351
- console.warn(`Don't know how to handle this item: top-level entry ${index} is a ${node.kind}.`);
352
- }
353
- return model;
354
- }
355
350
  }
356
351
 
357
352
  customElements.define('gtk-popover-menu-bar', GtkPopoverMenuBar);
@@ -40,9 +40,9 @@
40
40
  // is its name. `gtk_popover_menu_open_submenu(popover, "main")` is
41
41
  // the way back, so this is writeable and readable.
42
42
  // Properties:
43
- // menuModel — the model, normalised (get/set; a `custom` item throws here). Takes
44
- // an already-normalised `AdwMenuModel` too, which is what a menu bar
45
- // hands over for one of its submenus.
43
+ // menuModel — the real `Gio.MenuModel` (ADR 0128), or `null` (get/set; a `custom`
44
+ // item throws here). A menu bar hands one of these over for a submenu
45
+ // of its own; a JSON string is the authored spelling.
46
46
  // actions — what the action group publishes about the actions the model names
47
47
  // (ADR 0042), the source of every row's enabled/checked state.
48
48
  // visibleSubmenu — the open page's name (get/set).
@@ -72,26 +72,22 @@
72
72
  // Copyright (c) GNOME contributors (GTK, libadwaita). LGPLv2.1+
73
73
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
74
74
 
75
- import {
76
- ADW_MENU_SURFACE_WEB,
77
- assertMenuRenderable,
78
- menuItemAt,
79
- menuNodeAt,
80
- menuRefusals,
81
- normalizeMenuModel,
82
- parseMenuModel,
83
- } from '@gjsify/adwaita-core';
75
+ import { menuItemAt, menuNodeAt } from '@gjsify/adwaita-core';
84
76
  import { activateElementMenuItem, menuActionsOfElement } from '../application.js';
85
- import type { AdwMenuActions, AdwMenuInput, AdwMenuModel, AdwMenuPath } from '@gjsify/adwaita-core';
77
+ import type { AdwMenuActions, AdwMenuModel, AdwMenuPath, AdwMenuTree } from '@gjsify/adwaita-core';
86
78
 
87
79
  import { GtkPopover } from './gtk-popover.js';
80
+ import { MenuSlot } from './menu-slot.js';
88
81
  import { PopoverMenuView } from './popover-menu.js';
89
82
 
90
83
  /** The name the ROOT page carries — `gtk_popover_menu_open_submenu`'s own "main". */
91
84
  const MAIN_PAGE = 'main';
92
85
 
93
86
  export class GtkPopoverMenu extends GtkPopover {
94
- private _model: AdwMenuModel = [];
87
+ /** The model, its tree snapshot and its `items-changed` subscription (ADR 0128). */
88
+ private readonly _slot = new MenuSlot(() => {
89
+ if (this._menuInitialized) this._renderMenu();
90
+ });
95
91
  private _actions: AdwMenuActions | null = null;
96
92
  private _menuView!: PopoverMenuView;
97
93
  /** Named apart from `GtkPopover`'s own: two `private` fields of one name are two fields. */
@@ -101,26 +97,23 @@ export class GtkPopoverMenu extends GtkPopover {
101
97
  return [...GtkPopover.observedAttributes, 'menu-model', 'menu-title', 'visible-submenu'];
102
98
  }
103
99
 
104
- /** The menu, normalised (ADR 0042). */
105
- get menuModel(): AdwMenuModel {
106
- return this._model;
100
+ /** The menu this popover draws, by identity, or `null` (ADR 0128). */
101
+ get menuModel(): AdwMenuModel | null {
102
+ return this._slot.model;
107
103
  }
108
104
 
109
105
  /**
110
- * Set the menu from anything the portable model accepts, refusing a `custom` item
111
- * HERE — at the assignment, where it is a call the caller can catch. An ATTRIBUTE is
112
- * markup the browser parses, so the attribute path refuses the whole menu instead and
113
- * says why on `console.error`, exactly as `<gtk-menu-button>` does.
106
+ * Take a real model, a JSON string in the portable spelling, or `null`, refusing a
107
+ * `custom` item HERE — at the assignment, where it is a call the caller can catch.
108
+ * An ATTRIBUTE is markup the browser parses, so that path refuses the whole menu and
109
+ * says why on `console.error`; {@link MenuSlot} owns both doors.
114
110
  *
115
- * `AdwMenuModel` is in the union because a menu bar hands over a submenu's own `items`
116
- * untouched, and `normalizeMenuModel` is documented to be IDEMPOTENT over an
117
- * already-normalised node — that is what lets a renderer hand a model it is holding
118
- * straight back. The one cast is where the core's parameter type (`AdwMenuInput`, whose
119
- * entries are the AUTHORED shapes) is narrower than the behaviour it documents.
111
+ * A REAL MODEL IS WHAT A MENU BAR HANDS OVER for one of its submenus, and the model
112
+ * it hands over is `buildMenu(node.items)` — a menu of its own rather than this
113
+ * element's authored one, so the two cannot overwrite each other.
120
114
  */
121
- set menuModel(value: AdwMenuInput | AdwMenuModel) {
122
- this._model = normalizeMenuModel(value as AdwMenuInput);
123
- assertMenuRenderable(this._model, ADW_MENU_SURFACE_WEB);
115
+ set menuModel(value: AdwMenuModel | string | null) {
116
+ this._slot.write(value);
124
117
  if (this._menuInitialized) this._renderMenu();
125
118
  }
126
119
 
@@ -183,10 +176,10 @@ export class GtkPopoverMenu extends GtkPopover {
183
176
  this._menuView = new PopoverMenuView(this, 'adw-popover-menu', (path) => this._activate(path));
184
177
  this._menuView.setActions(this._actions);
185
178
 
186
- if (this._model.length === 0) this._model = this._parseMenuAttr();
187
- // The VIEW is what draws, and it holds the model it walks — seeding `_model`
188
- // directly and rendering without handing the view the same value would leave it
189
- // drawing an EMPTY page over a model the element believes it has.
179
+ if (this._slot.model === null) this._slot.writeMarkup(this.getAttribute('menu-model'));
180
+ // The VIEW is what draws, and it holds the TREE it walks — seeding the slot
181
+ // without handing the view the same snapshot would leave it drawing an EMPTY
182
+ // page over a model the element believes it has.
190
183
  this._renderMenu();
191
184
 
192
185
  // The visible page RESETS when the popover closes, as `gtk_popover_menu_show` does
@@ -194,7 +187,7 @@ export class GtkPopoverMenu extends GtkPopover {
194
187
  // a menu that reopens three levels deep is one the reader cannot get out of.
195
188
  this.subscribe((open) => {
196
189
  if (open) {
197
- this._menuView.setActions(this._actions ?? menuActionsOfElement(this, this._model));
190
+ this._menuView.setActions(this._actions ?? menuActionsOfElement(this, this._slot.tree));
198
191
  this._menuView.render();
199
192
  this._menuView.focusableRows[0]?.focus();
200
193
  } else this._menuView.reset();
@@ -205,7 +198,7 @@ export class GtkPopoverMenu extends GtkPopover {
205
198
  super.attributeChangedCallback(name, previous, value);
206
199
  if (!this._menuInitialized) return;
207
200
  if (name === 'menu-model') {
208
- this._model = this._parseMenuAttr();
201
+ this._slot.writeMarkup(this.getAttribute('menu-model'));
209
202
  this._renderMenu();
210
203
  return;
211
204
  }
@@ -258,7 +251,7 @@ export class GtkPopoverMenu extends GtkPopover {
258
251
  } else {
259
252
  const path = this._findSubmenu(name);
260
253
  if (path === null) return false;
261
- this._menuView.setMenuModel(this._model);
254
+ this._menuView.setMenuTree(this._slot.tree);
262
255
  this._menuView.showPage(path);
263
256
  }
264
257
  this._menuView.render();
@@ -276,12 +269,12 @@ export class GtkPopoverMenu extends GtkPopover {
276
269
  private _openPageLabel(): string | null {
277
270
  const path = this._menuView.page;
278
271
  if (path.length === 0) return null;
279
- const node = menuNodeAt(this._model, path);
272
+ const node = menuNodeAt(this._slot.tree, path);
280
273
  return node !== null && node.kind !== 'item' ? node.label : null;
281
274
  }
282
275
 
283
276
  private _findSubmenu(label: string): AdwMenuPath | null {
284
- const walk = (nodes: AdwMenuModel, path: AdwMenuPath): AdwMenuPath | null => {
277
+ const walk = (nodes: AdwMenuTree, path: AdwMenuPath): AdwMenuPath | null => {
285
278
  for (const [index, node] of nodes.entries()) {
286
279
  if (node.kind === 'item') continue;
287
280
  const here = [...path, index];
@@ -291,7 +284,7 @@ export class GtkPopoverMenu extends GtkPopover {
291
284
  }
292
285
  return null;
293
286
  };
294
- return walk(this._model, []);
287
+ return walk(this._slot.tree, []);
295
288
  }
296
289
 
297
290
  /**
@@ -313,7 +306,7 @@ export class GtkPopoverMenu extends GtkPopover {
313
306
  * open by choosing from it.
314
307
  */
315
308
  private _activate(path: AdwMenuPath): void {
316
- const target = menuItemAt(this._model, path);
309
+ const target = menuItemAt(this._slot.tree, path);
317
310
  this.popdown();
318
311
  this.anchor?.focus();
319
312
  if (target === null) return;
@@ -327,22 +320,10 @@ export class GtkPopoverMenu extends GtkPopover {
327
320
  }
328
321
 
329
322
  private _renderMenu(): void {
330
- this._menuView.setMenuModel(this._model);
323
+ this._menuView.setMenuTree(this._slot.tree);
331
324
  this._menuView.setTitle(this.menuTitle);
332
325
  this._menuView.render();
333
- if (this._model.length === 0) this.popdown();
334
- }
335
-
336
- private _parseMenuAttr(): AdwMenuModel {
337
- const model = normalizeMenuModel(parseMenuModel(this.getAttribute('menu-model')));
338
- const refusals = menuRefusals(model, ADW_MENU_SURFACE_WEB);
339
- if (refusals.length === 0) return model;
340
- // A throw from `connectedCallback` is reported as an uncaught PAGE error and
341
- // reaches nobody, so markup refuses the menu and says why. See
342
- // `<gtk-menu-button>`'s `_acceptMenu` for the same two doors.
343
- console.error(`${ADW_MENU_SURFACE_WEB.name} cannot render this menu, so it has none:`);
344
- for (const refusal of refusals) console.error(` [${refusal.path.join('.')}] ${refusal.message}`);
345
- return [];
326
+ if (this._slot.isEmpty) this.popdown();
346
327
  }
347
328
  }
348
329
 
@@ -0,0 +1,139 @@
1
+ // The `menu-model` SLOT the menu-bearing elements of this package share.
2
+ //
3
+ // ADR 0128 made `menu-model` a real `Gio.MenuModel`, and that turned one assignment into
4
+ // four steps that only work in one order:
5
+ //
6
+ // 1. read the write — `readMenuModelWrite` is the core's one door, and it is what tells a
7
+ // real model apart from the JSON string an attribute carries (ADR 0128 § 4);
8
+ // 2. REFUSE what this surface cannot draw, before the element renders it;
9
+ // 3. take a TREE SNAPSHOT, because every walker (`flattenMenu`, `menuNodeAt`,
10
+ // `menuItemAt`, `menuRefusals`) takes an `AdwMenuTree` and not a model — one snapshot
11
+ // per change rather than a chain of `get_item_link` calls per row;
12
+ // 4. connect `items-changed` — on the root AND on every section and submenu it links,
13
+ // which is what `watchMenuModel` does, because a link's emission does NOT bubble.
14
+ //
15
+ // THE STEP THAT IS EASIEST TO GET WRONG IS THE LAST ONE: the watcher has to be RE-RUN after
16
+ // every snapshot, since a mutation can add a section that was not there to connect to. Six
17
+ // elements here need all four steps, so the arithmetic lives once and there is no sixth
18
+ // place to forget it.
19
+ //
20
+ // THE TWO WRITES REFUSE DIFFERENTLY, and the difference is which side can hear it, exactly
21
+ // as it was before the model became real. {@link MenuSlot.write} is a property assignment,
22
+ // so it is a CALL and it THROWS; {@link MenuSlot.writeMarkup} is an attribute the browser
23
+ // parsed, where a throw out of `connectedCallback` reaches nobody and is reported as an
24
+ // uncaught page error (measured: it broke `adwaita-upgrade-order.spec.ts`, which counts
25
+ // exactly those) — so markup refuses the MENU and says why on `console.error`.
26
+ //
27
+ // THE WATCHER'S LIFETIME IS THE ELEMENT'S, with no `release()` to call. GTK disconnects in
28
+ // `dispose`, and a custom element has no dispose: it has `disconnectedCallback`, which also
29
+ // fires when a node is MOVED, and an element re-inserted after dropping its watcher would
30
+ // be deaf to the menu it still holds. A held model keeping its element alive is the same
31
+ // shape GTK has, where the widget owns a reference to the model it reads.
32
+
33
+ import {
34
+ ADW_MENU_SURFACE_WEB,
35
+ GjsifyMenu,
36
+ assertMenuRenderable,
37
+ menuRefusals,
38
+ menuTree,
39
+ readMenuModelWrite,
40
+ watchMenuModel,
41
+ } from '@gjsify/adwaita-core';
42
+ import type { AdwMenuTree, GioMenuModel } from '@gjsify/adwaita-core';
43
+
44
+ export class MenuSlot {
45
+ /**
46
+ * The menu this element OWNS, so an edit to the same `menu-model="[…]"` attribute is a
47
+ * content change rather than a new menu — the popover already open follows it instead of
48
+ * being rebuilt (ADR 0128 § 5, the same rule `GjsifyOptionList` has for a list).
49
+ */
50
+ private readonly _authored = new GjsifyMenu();
51
+ private _model: GioMenuModel | null = null;
52
+ private _tree: AdwMenuTree = [];
53
+ private _unwatch: (() => void) | null = null;
54
+
55
+ /** @param onItemsChanged what to do when the HELD menu mutates under the element. */
56
+ constructor(private readonly onItemsChanged: () => void) {}
57
+
58
+ /** The model itself, by identity — what `menuModel` answers and what a child is handed. */
59
+ get model(): GioMenuModel | null {
60
+ return this._model;
61
+ }
62
+
63
+ /** The snapshot every walker reads. Re-taken on each `items-changed`, never aliased. */
64
+ get tree(): AdwMenuTree {
65
+ return this._tree;
66
+ }
67
+
68
+ /**
69
+ * Whether there is anything to show — NOT whether there is a menu.
70
+ *
71
+ * `null` and an empty `Gio.Menu` are different menus and GTK treats them differently: an
72
+ * empty non-null model still builds a `GtkPopoverMenu` and still dissociates an
73
+ * application-set popover (measured, `MENU_MODEL_DOOR_VECTORS`). This answers the
74
+ * question the ROWS ask — "is there a row to draw" — and the elements ask
75
+ * `model === null` where GTK asks about the model.
76
+ */
77
+ get isEmpty(): boolean {
78
+ return this._tree.length === 0;
79
+ }
80
+
81
+ /**
82
+ * A PROPERTY write: a real model by identity, a JSON string into the authored menu,
83
+ * `null` for no menu — and a `TypeError` for an array, which this door no longer takes
84
+ * (`buildMenu` is how an array becomes a model now).
85
+ *
86
+ * A REFUSAL PUTS THE AUTHORED MENU BACK, because the door has already reconciled it by
87
+ * the time the tree can be refused: without the restore a refused string would leave the
88
+ * element holding a menu it threw over, and the next render would draw it.
89
+ */
90
+ write(value: unknown): void {
91
+ const before = this._tree;
92
+ const next = readMenuModelWrite(value, this._authored);
93
+ try {
94
+ if (next !== null) assertMenuRenderable(menuTree(next), ADW_MENU_SURFACE_WEB);
95
+ } catch (error) {
96
+ if (next === this._authored) this._authored.reconcile(before);
97
+ throw error;
98
+ }
99
+ this._adopt(next);
100
+ }
101
+
102
+ /**
103
+ * The `menu-model` ATTRIBUTE, through the same door. A `null` attribute is no menu.
104
+ *
105
+ * A REFUSAL AND A TYPO ARE STILL DIFFERENT THINGS: `parseMenuModel` is total because
106
+ * malformed JSON is an author slip that must not stop the element upgrading, while a
107
+ * `custom` item is well-formed, deliberate, and unhonourable on this surface.
108
+ */
109
+ writeMarkup(value: string | null): void {
110
+ const next = readMenuModelWrite(value, this._authored);
111
+ const refusals = next === null ? [] : menuRefusals(menuTree(next), ADW_MENU_SURFACE_WEB);
112
+ if (refusals.length === 0) {
113
+ this._adopt(next);
114
+ return;
115
+ }
116
+ // `console.error` is the browser's own channel for "this is wrong, the page
117
+ // continues" — and the alternative here is a row that lies.
118
+ console.error(`${ADW_MENU_SURFACE_WEB.name} cannot render this menu, so it has none:`);
119
+ for (const refusal of refusals) console.error(` [${refusal.path.join('.')}] ${refusal.message}`);
120
+ this._adopt(null);
121
+ }
122
+
123
+ /** Hold `next`, snapshot it, and listen to it — the three that always happen together. */
124
+ private _adopt(next: GioMenuModel | null): void {
125
+ this._unwatch?.();
126
+ this._unwatch = null;
127
+ this._model = next;
128
+ this._tree = next === null ? [] : menuTree(next);
129
+ if (next === null) return;
130
+ // Re-adopting from inside the handler re-snapshots AND re-watches, which is what
131
+ // makes a section added by the mutation audible. The port's `emit` copies its
132
+ // handler list and skips ones disconnected mid-emission, so disconnecting here is
133
+ // safe (`gobject.ts`, `emit`).
134
+ this._unwatch = watchMenuModel(next, () => {
135
+ this._adopt(this._model);
136
+ this.onItemsChanged();
137
+ });
138
+ }
139
+ }