@gjsify/adwaita-web 0.64.12-next.279 → 0.64.12-next.281

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 (51) hide show
  1. package/lib/types/elements/adw-alert-dialog.d.ts +0 -8
  2. package/lib/types/elements/adw-overlay-split-view.d.ts +5 -0
  3. package/lib/types/elements/adw-view-stack.d.ts +20 -0
  4. package/lib/types/elements/adw-view-switcher-bar.d.ts +4 -0
  5. package/lib/types/elements/gtk-box.d.ts +16 -0
  6. package/lib/types/elements/gtk-menu-button.d.ts +5 -0
  7. package/lib/types/elements/gtk-scrolled-window.d.ts +15 -2
  8. package/lib/types/elements/gtk-stack.d.ts +17 -0
  9. package/lib/types/elements/gtk-window.d.ts +14 -8
  10. package/lib/types/gobject-elements.d.ts +2 -0
  11. package/lib/types/gtksource/gtk-source-view.d.ts +2 -1
  12. package/lib/types/index.d.ts +1 -0
  13. package/lib/types/namespace/adw.d.ts +1 -1
  14. package/lib/types/namespace/gdk.d.ts +1 -0
  15. package/lib/types/namespace/gio.d.ts +1 -0
  16. package/lib/types/namespace/graphene.d.ts +8 -0
  17. package/lib/types/namespace/gtk.d.ts +1 -1
  18. package/lib/types/shared-tree-builder.d.ts +7 -0
  19. package/lib/types/widget-element.d.ts +25 -0
  20. package/package.json +12 -12
  21. package/src/adw-view-switcher-bar.spec.ts +12 -0
  22. package/src/elements/adw-alert-dialog.ts +0 -8
  23. package/src/elements/adw-overlay-split-view.ts +17 -0
  24. package/src/elements/adw-view-stack.ts +50 -0
  25. package/src/elements/adw-view-switcher-bar.ts +10 -0
  26. package/src/elements/gtk-box.ts +49 -1
  27. package/src/elements/gtk-menu-button.ts +17 -0
  28. package/src/elements/gtk-scrolled-window.ts +60 -15
  29. package/src/elements/gtk-stack.ts +42 -0
  30. package/src/elements/gtk-window.ts +43 -10
  31. package/src/gdk.spec.ts +9 -0
  32. package/src/gobject-door.spec.ts +134 -0
  33. package/src/gobject-elements.ts +26 -2
  34. package/src/gtk-box.spec.ts +38 -0
  35. package/src/gtk-menu-button.spec.ts +15 -0
  36. package/src/gtk-stack.spec.ts +26 -0
  37. package/src/gtk-window.spec.ts +35 -1
  38. package/src/gtksource/gtk-source-view.ts +18 -3
  39. package/src/index.ts +2 -0
  40. package/src/namespace/adw.ts +1 -1
  41. package/src/namespace/gdk.ts +2 -0
  42. package/src/namespace/gio.ts +3 -0
  43. package/src/namespace/graphene.ts +26 -0
  44. package/src/namespace/gtk.ts +13 -9
  45. package/src/shared-tree-builder.ts +42 -23
  46. package/src/split-views.spec.ts +11 -0
  47. package/src/style-manager.spec.ts +14 -0
  48. package/src/style-manager.ts +5 -0
  49. package/src/view-stack.spec.ts +41 -0
  50. package/src/widget-element.ts +53 -1
  51. package/src/widget-members.spec.ts +95 -0
@@ -210,7 +210,9 @@ export class GtkScrolledWindow extends WidgetElement {
210
210
  private _fadeTimer: ReturnType<typeof setTimeout> | null = null;
211
211
  private _built = false;
212
212
  /** What an unbuilt window answers before it has a viewport to read. */
213
- private readonly _standIn: { hadjustment?: AdwAdjustment; vadjustment?: AdwAdjustment } = {};
213
+ private readonly _standIn: { hadjustment?: GtkAdjustment; vadjustment?: GtkAdjustment } = {};
214
+ private readonly _heard = new WeakSet<object>();
215
+ private _pushing = false;
214
216
  private readonly _adopted: { hadjustment?: AdwAdjustment; vadjustment?: AdwAdjustment } = {};
215
217
 
216
218
  static get observedAttributes() {
@@ -265,21 +267,18 @@ export class GtkScrolledWindow extends WidgetElement {
265
267
  this.vscrollbarPolicy = v;
266
268
  }
267
269
 
268
- /** `GtkScrollable:hadjustment` as the window reports it: the one it was given, else the viewport's. */
270
+ /**
271
+ * `GtkScrollable:hadjustment` as the window reports it: the one it was given, else its own.
272
+ *
273
+ * Either is ONE object for the window's life, observable like GTK's: the viewport's
274
+ * numbers are configured into it, and a write to its value scrolls the content.
275
+ */
269
276
  get hadjustment(): AdwAdjustment {
270
- return (
271
- this._adopted.hadjustment ??
272
- (this._viewport as GtkViewport | undefined)?.hadjustment ??
273
- (this._standIn.hadjustment ??= new GtkAdjustment())
274
- );
277
+ return this._adjustmentOf('hadjustment');
275
278
  }
276
279
 
277
280
  get vadjustment(): AdwAdjustment {
278
- return (
279
- this._adopted.vadjustment ??
280
- (this._viewport as GtkViewport | undefined)?.vadjustment ??
281
- (this._standIn.vadjustment ??= new GtkAdjustment())
282
- );
281
+ return this._adjustmentOf('vadjustment');
283
282
  }
284
283
 
285
284
  get_hadjustment(): AdwAdjustment {
@@ -290,13 +289,55 @@ export class GtkScrolledWindow extends WidgetElement {
290
289
  return this.vadjustment;
291
290
  }
292
291
 
293
- /** Adopts `adjustment` as what `get_hadjustment` answers; the scroll position keeps following the viewport. */
292
+ /** Adopts `adjustment` as what `get_hadjustment` answers; it is kept in step with the viewport both ways. */
294
293
  set_hadjustment(adjustment: AdwAdjustment): void {
295
- this._adopted.hadjustment = adjustment;
294
+ this._adopt('hadjustment', adjustment);
296
295
  }
297
296
 
298
297
  set_vadjustment(adjustment: AdwAdjustment): void {
299
- this._adopted.vadjustment = adjustment;
298
+ this._adopt('vadjustment', adjustment);
299
+ }
300
+
301
+ private _adjustmentOf(key: 'hadjustment' | 'vadjustment'): AdwAdjustment {
302
+ const adopted = this._adopted[key];
303
+ if (adopted !== undefined) return adopted;
304
+ let own = this._standIn[key];
305
+ if (own === undefined) {
306
+ own = this._standIn[key] = new GtkAdjustment();
307
+ this._listen(key, own);
308
+ this._push(key);
309
+ }
310
+ return own;
311
+ }
312
+
313
+ private _adopt(key: 'hadjustment' | 'vadjustment', adjustment: AdwAdjustment): void {
314
+ this._adopted[key] = adjustment;
315
+ this._listen(key, adjustment);
316
+ this._push(key);
317
+ }
318
+
319
+ /** A write to the adjustment's value moves the scrollport; a plain object has no signal to hear. */
320
+ private _listen(key: 'hadjustment' | 'vadjustment', adjustment: AdwAdjustment): void {
321
+ if (!(adjustment instanceof GtkAdjustment) || this._heard.has(adjustment)) return;
322
+ this._heard.add(adjustment);
323
+ adjustment.connect('value-changed', () => {
324
+ if (this._pushing || this._adjustmentOf(key) !== adjustment || !this._viewport) return;
325
+ this._viewport.scrollAxis(key === 'hadjustment' ? 'horizontal' : 'vertical', adjustment.value);
326
+ });
327
+ }
328
+
329
+ /** Configures the window's adjustment of one axis from the viewport's numbers. */
330
+ private _push(key: 'hadjustment' | 'vadjustment'): void {
331
+ const viewport = this._viewport as GtkViewport | undefined;
332
+ const adjustment = this._adopted[key] ?? this._standIn[key];
333
+ if (!viewport?.isConnected || !(adjustment instanceof GtkAdjustment)) return;
334
+ const v = viewport[key];
335
+ this._pushing = true;
336
+ try {
337
+ adjustment.configure(v.value, v.lower, v.upper, v.stepIncrement, v.pageIncrement, v.pageSize);
338
+ } finally {
339
+ this._pushing = false;
340
+ }
300
341
  }
301
342
 
302
343
  /** `GtkScrolledWindow:window-placement` — where the contents sit against the bars. */
@@ -533,6 +574,8 @@ export class GtkScrolledWindow extends WidgetElement {
533
574
  // bars are not connected, so this pass has no adjustment to read and no geometry to
534
575
  // decide — `connectedCallback` ends with a `_sync()` of its own, once they are.
535
576
  if (bars === null || !this._viewport.isConnected || !bars.vertical.isConnected) return;
577
+ this._push('hadjustment');
578
+ this._push('vadjustment');
536
579
  const placement = this.windowPlacement;
537
580
  const indicators = this._useIndicators;
538
581
  this.classList.toggle('frame', this.hasFrame);
@@ -621,6 +664,8 @@ export class GtkScrolledWindow extends WidgetElement {
621
664
  const bars = this._bars;
622
665
  if (bars === null) return;
623
666
  const detail = (event as CustomEvent<{ hadjustment: AdwAdjustment; vadjustment: AdwAdjustment }>).detail;
667
+ this._push('hadjustment');
668
+ this._push('vadjustment');
624
669
  for (const axis of AXES) {
625
670
  const adjustment = detail[axis.key];
626
671
  (bars[axis.bar] as HTMLElement & { adjustment: AdwAdjustment }).adjustment = adjustment;
@@ -387,6 +387,48 @@ export class GtkStack extends WidgetElement {
387
387
  child.remove();
388
388
  }
389
389
 
390
+ /**
391
+ * `gtk_stack_remove` under its GIR name. With no argument it stays `ChildNode.remove()`:
392
+ * the stack leaves its own parent.
393
+ */
394
+ override remove(child?: HTMLElement): void {
395
+ if (child === undefined) {
396
+ super.remove();
397
+ return;
398
+ }
399
+ this.removePage(child);
400
+ }
401
+
402
+ /** `gtk_stack_add_titled` under its GIR name. */
403
+ add_titled(child: HTMLElement, name: string | null, title: string | null): HTMLElement {
404
+ return this.addTitled(child, name, title);
405
+ }
406
+
407
+ /** `gtk_stack_add_named` under its GIR name. */
408
+ add_named(child: HTMLElement, name: string | null): HTMLElement {
409
+ return this.addNamed(child, name);
410
+ }
411
+
412
+ /** `gtk_stack_get_visible_child_name`. */
413
+ get_visible_child_name(): string | null {
414
+ return this.visibleChildName;
415
+ }
416
+
417
+ /** `gtk_stack_set_visible_child_name`. */
418
+ set_visible_child_name(name: string): void {
419
+ this.setVisibleChildName(name);
420
+ }
421
+
422
+ /** `gtk_stack_get_visible_child`. */
423
+ get_visible_child(): HTMLElement | null {
424
+ return this.visibleChild;
425
+ }
426
+
427
+ /** `gtk_stack_set_visible_child`. */
428
+ set_visible_child(child: HTMLElement): void {
429
+ this.setVisibleChild(child);
430
+ }
431
+
390
432
  /**
391
433
  * `gtk_stack_set_visible_child` (gtkstack.c:2244-2281), with `gtk_stack_set_visible_
392
434
  * child_full`'s per-call transition (gtkstack.c:2306-2334). A child that is not in
@@ -96,7 +96,7 @@
96
96
  // Copyright (c) The GTK Team. LGPLv2.1+.
97
97
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
98
98
 
99
- import { CloseRequestState, WindowMemberState, type WindowMemberProperty } from '@gjsify/adwaita-core';
99
+ import { CloseRequestState, WindowMemberState, stepDefaultSize, type WindowMemberProperty } from '@gjsify/adwaita-core';
100
100
 
101
101
  import { defineWindowApplication, type GtkApplication } from '../application.js';
102
102
  import { installElementApi } from '../gobject-elements.js';
@@ -171,14 +171,6 @@ export class GtkWindow extends WidgetElement {
171
171
  declare get_application: () => GtkApplication | null;
172
172
  declare set_application: (application: GtkApplication | null) => void;
173
173
 
174
- /** `GObject.Object.connect`: installed per instance by `installElementApi`; refuses an undeclared signal. */
175
- declare connect: (signal: string, handler: (emitter: this, ...args: any[]) => unknown) => number;
176
- /** `GObject.Object.disconnect`: throws for an id that is not connected. */
177
- declare disconnect: (id: number) => void;
178
- /** `GObject.Object.emit`; `close-request` is refused, call `close()`. */
179
- declare emit: (signal: string, ...args: unknown[]) => void;
180
- /** `GObject.Object.notify`. */
181
- declare notify: (name: string) => void;
182
174
  /** `GObject.Object.get_property`, by GTK's dashed name. */
183
175
  declare get_property: (name: string) => unknown;
184
176
  /** `GObject.Object.set_property`; a read-only property throws. */
@@ -281,6 +273,26 @@ export class GtkWindow extends WidgetElement {
281
273
  this._write('deletable', String(value));
282
274
  }
283
275
 
276
+ /** `Gtk.Window:hide-on-close` — `close()` hides the window instead of destroying it. */
277
+ get hide_on_close(): boolean {
278
+ return this._offByDefault('hide-on-close');
279
+ }
280
+
281
+ set hide_on_close(value: boolean) {
282
+ if (this._offByDefault('hide-on-close') === value) return;
283
+ this._write('hide-on-close', String(value));
284
+ }
285
+
286
+ /** `gtk_window_get_hide_on_close`. */
287
+ get_hide_on_close(): boolean {
288
+ return this.hide_on_close;
289
+ }
290
+
291
+ /** `gtk_window_set_hide_on_close`. */
292
+ set_hide_on_close(setting: boolean): void {
293
+ this.hide_on_close = setting;
294
+ }
295
+
284
296
  /**
285
297
  * `setAttribute` for a write that changes nothing. Every GObject setter here returns
286
298
  * early when the value is already right (gtkwindow.c:1015-1017, :1045-1046, :894-895),
@@ -540,7 +552,7 @@ export class GtkWindow extends WidgetElement {
540
552
  close(): boolean {
541
553
  const outcome = this._closing.close({
542
554
  realized: this.isConnected,
543
- hideOnClose: this._offByDefault('hide-on-close'),
555
+ hideOnClose: this.hide_on_close,
544
556
  emit: () => {
545
557
  const request = new CustomEvent('close-request', { bubbles: true, cancelable: true });
546
558
  this.dispatchEvent(request);
@@ -613,6 +625,27 @@ export class GtkWindow extends WidgetElement {
613
625
  this._members.reportFocus(this.get_focus());
614
626
  }
615
627
 
628
+ /** `gtk_window_get_default_size`: `[width, height]`, `[0, 0]` while neither attribute is set. */
629
+ get_default_size(): [number, number] {
630
+ const read = (name: string): number => {
631
+ const value = Number.parseInt(this.getAttribute(name) ?? '', 10);
632
+ return Number.isFinite(value) ? value : 0;
633
+ };
634
+ return [read('default-width'), read('default-height')];
635
+ }
636
+
637
+ /**
638
+ * `gtk_window_set_default_size`: a value below -1 is refused (GTK's `g_return_if_fail`), an
639
+ * equal one does not notify, and `default-height` notifies before `default-width`.
640
+ */
641
+ set_default_size(width: number, height: number): void {
642
+ const step = stepDefaultSize(this.get_default_size(), width, height);
643
+ if (step === null) return;
644
+ for (const property of step.changed) {
645
+ this._write(property, String(property === 'default-width' ? step.width : step.height));
646
+ }
647
+ }
648
+
616
649
  /** `gtk_window_is_maximized`: the FRAME's state, not the browser window's. */
617
650
  is_maximized(): boolean {
618
651
  return this.maximized;
package/src/gdk.spec.ts CHANGED
@@ -6,6 +6,9 @@
6
6
 
7
7
  import {
8
8
  driveGdkClipboardVectors,
9
+ driveRGBAVectors,
10
+ RGBA_VECTORS,
11
+ type GdkRGBALike,
9
12
  GDK_CLIPBOARD_VECTORS,
10
13
  type GdkClipboardNamespaces,
11
14
  } from '@gjsify/adwaita-core/conformance';
@@ -51,6 +54,12 @@ export const GdkTest = async () => {
51
54
  ),
52
55
  );
53
56
 
57
+ await driveRGBAVectors(
58
+ { name: 'adwaita-web', isOracle: false, Gdk: Gdk as unknown as GdkRGBALike },
59
+ { describe, it, expect },
60
+ RGBA_VECTORS,
61
+ );
62
+
54
63
  await describe('adwaita-web: Gdk clipboard door', async () => {
55
64
  const mounted = () => {
56
65
  const view = document.createElement('gtk-source-view') as GtkSourceView;
@@ -5,6 +5,7 @@ import { describe, expect, it } from '@gjsify/unit';
5
5
 
6
6
  import { Adw, GObject } from './index.js';
7
7
  import { tagForTypeName } from './gobject-door.js';
8
+ import { listenElement } from './gobject-elements.js';
8
9
  import type { SharedTreeNode } from '@gjsify/adwaita-core/conformance';
9
10
  import { bindProperties } from '@gjsify/adwaita-core';
10
11
 
@@ -90,6 +91,45 @@ GObject.registerClass(
90
91
  GoWebSensitive as never,
91
92
  );
92
93
 
94
+ const SiblingsTemplate = tree(`template $GoWebSiblings : Adw.Bin {
95
+ child: Gtk.Box {
96
+ Gtk.Label caption { label: bind peer.title; }
97
+ };
98
+ }
99
+
100
+ Adw.AlertDialog unsavedChangesDialog { heading: "Save changes?"; }
101
+
102
+ $GoWebPeer peer { }`);
103
+
104
+ class GoWebPeer extends Adw.Bin {
105
+ declare title: string;
106
+ }
107
+ GObject.registerClass(
108
+ {
109
+ GTypeName: 'GoWebPeer',
110
+ Properties: { title: GObject.ParamSpec.string('title', '', '', GObject.ParamFlags.READWRITE, 'first') },
111
+ },
112
+ GoWebPeer as never,
113
+ );
114
+
115
+ class GoWebSiblings extends Adw.Bin {
116
+ declare _unsavedChangesDialog: HTMLElement & { heading: string };
117
+ declare _peer: GoWebPeer;
118
+ declare _caption: HTMLElement & { label: string };
119
+ constructor(params?: Record<string, unknown>) {
120
+ // @ts-expect-error the element's own constructor takes none; the registered class's takes the params
121
+ super(params);
122
+ }
123
+ }
124
+ GObject.registerClass(
125
+ {
126
+ GTypeName: 'GoWebSiblings',
127
+ Template: SiblingsTemplate,
128
+ InternalChildren: ['unsavedChangesDialog', 'peer', 'caption'],
129
+ },
130
+ GoWebSiblings as never,
131
+ );
132
+
93
133
  export const GObjectDoorTest = async () => {
94
134
  await driveGObjectVectors(subject, { describe, it, expect }, GOBJECT_VECTORS);
95
135
  await describe('adwaita-web: GObject.registerClass end to end (ADR 0096)', async () => {
@@ -97,6 +137,24 @@ export const GObjectDoorTest = async () => {
97
137
  expect(tagForTypeName('Hexdump')).toBe('gjsify-hexdump');
98
138
  expect(customElements.get('gjsify-go-web-hexdump')).toBe(GoWebHexdump);
99
139
  });
140
+ await it('reaches the objects written beside the template through InternalChildren, outside its children', () => {
141
+ const el = new GoWebSiblings();
142
+ expect(el._unsavedChangesDialog.localName).toBe('adw-alert-dialog');
143
+ expect(el._peer instanceof GoWebPeer).toBe(true);
144
+ document.body.append(el);
145
+ try {
146
+ // Independent objects, as in GTK: neither is a child of the template widget.
147
+ expect(el.contains(el._unsavedChangesDialog)).toBe(false);
148
+ expect(el.contains(el._peer)).toBe(false);
149
+ expect(el.contains(el._caption)).toBe(true);
150
+ // A template child names a sibling by id, and follows it.
151
+ expect(el._caption.label).toBe('first');
152
+ el._peer.title = 'second';
153
+ expect(el._caption.label).toBe('second');
154
+ } finally {
155
+ el.remove();
156
+ }
157
+ });
100
158
  await it('refuses a second class under the same tag', () => {
101
159
  expect(() =>
102
160
  GObject.registerClass({ GTypeName: 'GoWebHexdump' }, class extends Adw.Bin {} as never),
@@ -160,4 +218,80 @@ export const GObjectDoorTest = async () => {
160
218
  expect(el._sw.hasAttribute('disabled')).toBe(true);
161
219
  });
162
220
  });
221
+ await describe('adwaita-web: a signal is per instance, as in GObject', async () => {
222
+ const nested = (tag: string) => {
223
+ const parent = document.createElement(tag) as HTMLElement;
224
+ const child = document.createElement(tag) as HTMLElement;
225
+ parent.append(child);
226
+ document.body.append(parent);
227
+ return { parent, child };
228
+ };
229
+ await it('notify::visible-child-name of a child stack is not heard on the parent', () => {
230
+ const { parent, child } = nested('adw-view-stack');
231
+ let heard = 0;
232
+ listenElement(parent, 'notify::visible-child-name', () => heard++);
233
+ child.dispatchEvent(new CustomEvent('notify::visible-child-name', { bubbles: true }));
234
+ expect(heard).toBe(0);
235
+ parent.dispatchEvent(new CustomEvent('notify::visible-child-name', { bubbles: true }));
236
+ expect(heard).toBe(1);
237
+ parent.remove();
238
+ });
239
+ await it('activated of a child row is not heard on the parent, but still bubbles in the DOM', () => {
240
+ const { parent, child } = nested('adw-action-row');
241
+ let heard = 0;
242
+ let bubbled = 0;
243
+ listenElement(parent, 'activated', () => heard++);
244
+ parent.addEventListener('activated', () => bubbled++);
245
+ child.dispatchEvent(new CustomEvent('activated', { bubbles: true }));
246
+ expect(heard).toBe(0);
247
+ expect(bubbled).toBe(1);
248
+ parent.dispatchEvent(new CustomEvent('activated', { bubbles: true }));
249
+ expect(heard).toBe(1);
250
+ parent.remove();
251
+ });
252
+ await it('clicked of a nested button is not heard on the outer one; a click on plain content is', () => {
253
+ const { parent, child } = nested('gtk-button');
254
+ const inner = document.createElement('span');
255
+ parent.append(inner);
256
+ let heard = 0;
257
+ listenElement(parent, 'clicked', () => heard++);
258
+ child.dispatchEvent(new Event('click', { bubbles: true }));
259
+ expect(heard).toBe(0);
260
+ inner.dispatchEvent(new Event('click', { bubbles: true }));
261
+ expect(heard).toBe(1);
262
+ parent.remove();
263
+ });
264
+ });
265
+ await describe('adwaita-web: a bare element has the GObject signal API', async () => {
266
+ await it('connects to a signal on createElement, and disconnects by id', () => {
267
+ const stack = document.createElement('adw-view-stack') as HTMLElement & {
268
+ connect(signal: string, handler: () => void): number;
269
+ disconnect(id: number): void;
270
+ };
271
+ document.body.append(stack);
272
+ let heard = 0;
273
+ const id = stack.connect('notify::visible-child-name', () => heard++);
274
+ stack.dispatchEvent(new CustomEvent('notify::visible-child-name'));
275
+ expect(heard).toBe(1);
276
+ stack.disconnect(id);
277
+ stack.dispatchEvent(new CustomEvent('notify::visible-child-name'));
278
+ expect(heard).toBe(1);
279
+ stack.remove();
280
+ });
281
+ await it('connects to clicked on a bare button', () => {
282
+ const button = document.createElement('gtk-button') as HTMLElement & {
283
+ connect(signal: string, handler: () => void): number;
284
+ };
285
+ let heard = 0;
286
+ button.connect('clicked', () => heard++);
287
+ button.click();
288
+ expect(heard).toBe(1);
289
+ });
290
+ await it('refuses a signal the element does not declare', () => {
291
+ const button = document.createElement('gtk-button') as HTMLElement & {
292
+ connect(signal: string, handler: () => void): number;
293
+ };
294
+ expect(() => button.connect('no-such-signal', () => {})).toThrow();
295
+ });
296
+ });
163
297
  };
@@ -106,6 +106,18 @@ export function ensureNotifying(el: HTMLElement, property: string): void {
106
106
  });
107
107
  }
108
108
 
109
+ /**
110
+ * The element a bubbled DOM event is the GTK signal of: the nearest one from `target` up that
111
+ * declares `signal` as `event`. A click on plain content inside a button is the button's `clicked`;
112
+ * a click on a nested button is that button's, and a signal is per instance in GObject.
113
+ */
114
+ function signalOwner(target: EventTarget | null, signal: string, event: string): Element | null {
115
+ for (let at = target instanceof Element ? target : null; at !== null; at = at.parentElement) {
116
+ if (dispatchedSignalsOf(at)[signal] === event) return at;
117
+ }
118
+ return null;
119
+ }
120
+
109
121
  /** Calls `handler(el, ...args)` for `signal`; returns the disconnect. */
110
122
  export function listenElement(el: HTMLElement, signal: string, handler: Handler): () => void {
111
123
  const event = eventOf(el, signal);
@@ -114,6 +126,7 @@ export function listenElement(el: HTMLElement, signal: string, handler: Handler)
114
126
  const listener = (domEvent: Event) => {
115
127
  // A notification bubbles, and a parent must not hear its child's property as its own.
116
128
  if (notification && domEvent.target !== el) return;
129
+ if (!notification && !EMITTED.has(domEvent) && signalOwner(domEvent.target, signal, event) !== el) return;
117
130
  const handled = handler(el, ...(EMITTED.has(domEvent) ? ((domEvent as CustomEvent).detail as unknown[]) : []));
118
131
  // A GTK handler of a boolean-handled signal (`close-request`) returns true to take it over,
119
132
  // which on a cancelable DOM event is `preventDefault()`; the handlers after it do not run.
@@ -198,8 +211,10 @@ export function installVisible(el: HTMLElement): void {
198
211
 
199
212
  /** Gives `el` the instance API of a registered class, over the events it declares. */
200
213
  export function installElementApi(el: HTMLElement): void {
201
- // A registered class already has the core's four methods on its prototype.
202
- if (typeof (el as unknown as Record<string, unknown>).connect === 'function') return;
214
+ // A registered class already has the core's four methods on its prototype; the stubs of
215
+ // `defineLazyElementApi` are not those, they exist to run this.
216
+ const inherited = (el as unknown as Record<string, unknown>).connect;
217
+ if (typeof inherited === 'function' && !LAZY_API.has(inherited)) return;
203
218
  WITH_API.add(el);
204
219
  const connections = new Map<number, () => void>();
205
220
  const define = (name: string, value: unknown) =>
@@ -237,6 +252,15 @@ export function installElementApi(el: HTMLElement): void {
237
252
  define('has_css_class', (name: string): boolean => el.classList.contains(name));
238
253
  }
239
254
 
255
+ const LAZY_API = new WeakSet<object>();
256
+
257
+ /** Marks the self-installing `connect` / `disconnect` / `emit` / `notify` of `proto` ({@link WidgetElement}). */
258
+ export function markLazyElementApi(proto: object): void {
259
+ for (const name of ['connect', 'disconnect', 'emit', 'notify']) {
260
+ LAZY_API.add((proto as Record<string, object>)[name]);
261
+ }
262
+ }
263
+
240
264
  const API_MEMBERS = new Set(['connect', 'disconnect', 'emit', 'notify', 'get_property', 'set_property']);
241
265
 
242
266
  /**
@@ -33,6 +33,10 @@ function mount(count = 3): { box: GtkBox; kids: HTMLElement[]; host: HTMLElement
33
33
  return { box, kids, host };
34
34
  }
35
35
 
36
+ /** The children by position in `kids`, `x` for the one extra. */
37
+ const order = (box: Element, kids: Element[], extra?: Element): string =>
38
+ [...box.children].map((el) => (el === extra ? 'x' : kids.indexOf(el))).join(',');
39
+
36
40
  const rect = (el: Element) => el.getBoundingClientRect();
37
41
 
38
42
  /** Author `value` the way markup does: absent is no attribute at all. */
@@ -138,6 +142,40 @@ export const GtkBoxTest = async () => {
138
142
  ],
139
143
  };
140
144
 
145
+ await it('remove(child) detaches a direct child and leaves a stranger alone', () => {
146
+ const { box, kids, host } = mount(3);
147
+ const warn = console.warn;
148
+ const warnings: unknown[] = [];
149
+ console.warn = (...args: unknown[]) => void warnings.push(args);
150
+ try {
151
+ box.remove(kids[1]);
152
+ expect(order(box, kids)).toBe('0,2');
153
+ box.remove(document.createElement('div'));
154
+ expect(box.children.length).toBe(2);
155
+ expect(warnings.length).toBe(1);
156
+ box.remove();
157
+ expect(box.isConnected).toBe(false);
158
+ } finally {
159
+ console.warn = warn;
160
+ host.remove();
161
+ }
162
+ });
163
+
164
+ await it('insert_child_after and reorder_child_after follow the NULL = FIRST rule', () => {
165
+ const { box, kids, host } = mount(3);
166
+ try {
167
+ const extra = document.createElement('div');
168
+ expect(box.insert_child_after(extra, null)).toBe(true);
169
+ expect(order(box, kids, extra)).toBe('x,0,1,2');
170
+ expect(box.insert_child_after(extra, kids[0])).toBe(false);
171
+ expect(box.reorder_child_after(extra, kids[2])).toBe(true);
172
+ expect(order(box, kids, extra)).toBe('0,1,2,x');
173
+ expect(box.reorder_child_after(document.createElement('div'), null)).toBe(false);
174
+ } finally {
175
+ host.remove();
176
+ }
177
+ });
178
+
141
179
  await it('mounts upgraded elements, stacked by the authored orientation and spacing', () => {
142
180
  const { root, unmount } = mountSharedTree(tree);
143
181
  try {
@@ -177,6 +177,21 @@ export const GtkMenuButtonTest = async () => {
177
177
  unmountAll();
178
178
  });
179
179
 
180
+ await it('menu_model / get_menu_model / set_menu_model mirror menuModel', () => {
181
+ const el = document.createElement('gtk-menu-button') as MenuButtonElement & {
182
+ menu_model: AdwMenuInput;
183
+ get_menu_model(): unknown;
184
+ set_menu_model(v: AdwMenuInput): void;
185
+ };
186
+ document.body.appendChild(el);
187
+ el.set_menu_model(['Save', 'Quit']);
188
+ expect(el.get_menu_model()).toBe(el.menuModel);
189
+ expect(el.querySelectorAll('.adw-popover-item').length).toBe(2);
190
+ el.menu_model = ['Save'];
191
+ expect(el.querySelectorAll('.adw-popover-item').length).toBe(1);
192
+ unmountAll();
193
+ });
194
+
180
195
  await it('a malformed attribute is NOT a refusal — a typo may not stop the upgrade', () => {
181
196
  // The other half of the same rule: `parseMenuModel` stays total, so this
182
197
  // element upgrades with no menu instead of throwing.
@@ -599,4 +599,30 @@ export const GtkStackTest = async () => {
599
599
  }
600
600
  });
601
601
  });
602
+
603
+ await describe('<gtk-stack> GIR method names', async () => {
604
+ await it('add_named / add_titled / the visible-child accessors follow the C functions', () => {
605
+ const { el, host } = mount();
606
+ const [a, b] = [1, 2].map(() => document.createElement('div'));
607
+ el.add_named(a!, 'a');
608
+ el.add_titled(b!, 'b', 'Beta');
609
+ expect(el.get_visible_child_name()).toBe('a');
610
+ el.set_visible_child_name('b');
611
+ expect(el.get_visible_child()).toBe(b!);
612
+ el.set_visible_child(a!);
613
+ expect(el.get_visible_child_name()).toBe('a');
614
+ host.remove();
615
+ });
616
+
617
+ await it('remove(child) removes the page; remove() still detaches the stack', () => {
618
+ const { el, host } = mount();
619
+ const a = document.createElement('div');
620
+ el.add_named(a, 'a');
621
+ el.remove(a);
622
+ expect(a.parentElement).toBe(null);
623
+ el.remove();
624
+ expect(el.parentElement).toBe(null);
625
+ host.remove();
626
+ });
627
+ });
602
628
  };
@@ -36,7 +36,7 @@ export function webWindowSubject(tag: string): { subject: WindowSubject; dispose
36
36
  create({ hideOnClose }) {
37
37
  // Never in a page: not realized, which is GTK's state for a window before `present()`.
38
38
  const el = document.createElement(tag) as GtkWindow;
39
- if (hideOnClose) el.setAttribute('hide-on-close', 'true');
39
+ if (hideOnClose) el.hide_on_close = true;
40
40
  el.append(document.createElement('button'), document.createElement('button'));
41
41
  return el;
42
42
  },
@@ -65,6 +65,8 @@ export function webWindowSubject(tag: string): { subject: WindowSubject; dispose
65
65
  maximize: (w) => (w as GtkWindow).maximize(),
66
66
  unmaximize: (w) => (w as GtkWindow).unmaximize(),
67
67
  isMaximized: (w) => (w as GtkWindow).is_maximized(),
68
+ getDefaultSize: (w) => (w as GtkWindow).get_default_size(),
69
+ setDefaultSize: (w, width, height) => (w as GtkWindow).set_default_size(width, height),
68
70
  isVisible: (w) => (w as GtkWindow).isConnected && !(w as GtkWindow).hidden,
69
71
  focusables: (w) => [...(w as GtkWindow).querySelectorAll('button')] as unknown as [Element, Element],
70
72
  setFocus: (w, widget) => (w as GtkWindow).set_focus(widget as Element | null),
@@ -244,6 +246,38 @@ export const GtkWindowTest = async () => {
244
246
  host.remove();
245
247
  });
246
248
 
249
+ await it('`hide_on_close` is the real property, default false, and close() follows it', async () => {
250
+ const { el, host } = mount();
251
+ expect(el.hide_on_close).toBe(false);
252
+ expect(el.get_hide_on_close()).toBe(false);
253
+ el.set_hide_on_close(true);
254
+ expect(el.hide_on_close).toBe(true);
255
+ expect(el.getAttribute('hide-on-close')).toBe('true');
256
+ el.close();
257
+ expect(el.isConnected).toBe(true);
258
+ expect(el.hidden).toBe(true);
259
+ el.hidden = false;
260
+ el.hide_on_close = false;
261
+ expect(el.close()).toBe(true);
262
+ expect(el.isConnected).toBe(false);
263
+ host.remove();
264
+ });
265
+
266
+ await it('notify::hide-on-close fires on change only', async () => {
267
+ const { el, host } = mount();
268
+ let seen = 0;
269
+ el.connect('notify::hide-on-close', () => void seen++);
270
+ el.hide_on_close = false;
271
+ expect(seen).toBe(0);
272
+ el.set_hide_on_close(true);
273
+ expect(seen).toBe(1);
274
+ el.hide_on_close = true;
275
+ expect(seen).toBe(1);
276
+ el.hide_on_close = false;
277
+ expect(seen).toBe(2);
278
+ host.remove();
279
+ });
280
+
247
281
  await it('a handler that cancels `close-request` takes the whole thing over', async () => {
248
282
  // gtkwindow.c:1300-1320 — `g_signal_stop_emission_by_name` on the default handler.
249
283
  const { el, host } = mount();