@gjsify/adwaita-web 0.62.0 → 0.63.0

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/README.md CHANGED
@@ -130,6 +130,37 @@ auto-dismiss runs on the browser's timers by default; assign
130
130
  defines) to drive toast lifetimes from your own clock — that is how the
131
131
  package's own regression tests advance time without real timers.
132
132
 
133
+ ## Pango markup (`use-markup`)
134
+
135
+ `<adw-banner>`'s `title`, `<adw-alert-dialog>`'s `heading` and `body` and
136
+ `<gtk-label>` / `<gtk-inscription>`'s `markup` read Pango markup — GTK's eleven
137
+ tags (`b`, `i`, `u`, `s`, `tt`, `big`, `small`, `sub`, `sup`, `span`,
138
+ `markup`), `<span>` attributes, and `&amp;`/`&lt;`/`&gt;`/`&quot;`/`&apos;`
139
+ plus numeric character references.
140
+
141
+ Pango markup is **not** HTML, and nothing here is handed to `innerHTML`:
142
+ `renderPangoMarkup` (in `src/pango-markup.ts`) builds elements and text nodes
143
+ from the tree `parsePangoMarkup` (`@gjsify/adwaita-core`) returns. The banner
144
+ and the alert dialog therefore DRAW the runs:
145
+
146
+ ```html
147
+ <adw-banner title="<b>Metered</b> connection" use-markup revealed></adw-banner>
148
+ ```
149
+
150
+ Anything pango cannot parse comes back as **text**, exactly as
151
+ `gtk_label_set_markup` keeps an unparseable string: a bare `&`, a `<` in prose,
152
+ a mismatched tag — and, importantly, every tag outside pango's set. An
153
+ `<img src=x onerror=…>` in a banner title is shown, not run; there is no code
154
+ path that creates an element for a name the parser did not recognise, and a
155
+ `<span>` attribute becomes a CSS declaration (or is dropped), never a DOM
156
+ attribute.
157
+
158
+ Without `use-markup` every one of these strings is literal text — that is
159
+ `Adw.Banner:use-markup`'s deliberate departure from the libadwaita default of
160
+ `TRUE`, documented in `src/elements/adw-banner.ts`.
161
+ `scripts/check-adwaita-no-markup-sinks.mjs` holds the no-`innerHTML` rule for
162
+ the package's shipping sources.
163
+
133
164
  ## Theming a page without `<adw-window>`
134
165
 
135
166
  The components read their colours from the `--window-*` CSS custom properties,
@@ -13,6 +13,12 @@ export declare class GtkWindow extends HTMLElement {
13
13
  private _titleLabel;
14
14
  /** Watches the strip for an author's titlebar arriving after the build. */
15
15
  private _titlebarObserver;
16
+ /** `is-active` and `focus-widget`; `maximized` stays the attribute above. */
17
+ private readonly _members;
18
+ private readonly _closing;
19
+ private readonly _syncActive;
20
+ private readonly _onFocusIn;
21
+ private readonly _onFocusOut;
16
22
  static get observedAttributes(): string[];
17
23
  /**
18
24
  * `Gtk.Window:title` — the window's title.
@@ -54,6 +60,9 @@ export declare class GtkWindow extends HTMLElement {
54
60
  private _offByDefault;
55
61
  connectedCallback(): void;
56
62
  disconnectedCallback(): void;
63
+ private _watchActivity;
64
+ private _documentIsActive;
65
+ private _notifyMember;
57
66
  /**
58
67
  * A `slot="titlebar"` child adopted AFTER the build has to take the strip over from the
59
68
  * server-side decoration, and `_render` is the one that notices — nothing else here runs
@@ -90,8 +99,32 @@ export declare class GtkWindow extends HTMLElement {
90
99
  * it, hide the window when `hide-on-close` is set and DESTROY it otherwise. "Destroy" is
91
100
  * the one word this port cannot mean — a page has no unref — so the default detaches the
92
101
  * frame, which is the same observable outcome for everything above it.
102
+ *
103
+ * A frame that is not in the page is not realized, so there is nothing to close; a listener
104
+ * that calls `close()` again is ignored, as GTK ignores it. Answers whether the window went
105
+ * away (hidden or detached), `false` when a listener took the request over or it was ignored.
93
106
  */
94
107
  close(): boolean;
108
+ /**
109
+ * `gtk_window_present`: show the frame. GTK also raises and focuses the toplevel, which is
110
+ * the window manager's to grant; a page owns no such surface, so this is only the first half.
111
+ */
112
+ present(): void;
113
+ /** `Gtk.Window:is-active` — see the header: the document's focus, while this frame is shown. */
114
+ get isActive(): boolean;
115
+ /** `gtk_window_is_active`. */
116
+ is_active(): boolean;
117
+ /** `Gtk.Window:focus-widget`, read live from the document. */
118
+ get focusWidget(): Element | null;
119
+ /** `gtk_window_get_focus`: the element inside the frame that has focus, or `null`. */
120
+ get_focus(): Element | null;
121
+ /**
122
+ * `gtk_window_set_focus`. `null` clears the focus. An element outside the frame is refused:
123
+ * GTK's window only focuses its own descendants.
124
+ */
125
+ set_focus(widget: Element | null): void;
126
+ /** `gtk_window_is_maximized`: the FRAME's state, not the browser window's. */
127
+ is_maximized(): boolean;
95
128
  /**
96
129
  * `gtk_window_maximize`. ASYNCHRONOUS in GTK — the GIR says so at gtkwindow.c:1044 — so
97
130
  * the property is the thing a caller watches, and this writes it directly rather than
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Render Pango markup into `target`, replacing its content — the one door every
3
+ * `use-markup` surface in this package goes through.
4
+ *
5
+ * The `null` branch is the C fallback and is the SECURITY property: markup that does not
6
+ * parse is written as TEXT, so an unrecognised tag can never become an element. Callers
7
+ * that want markup only when a caller has asked for it read that from their own flag and
8
+ * pass plain text here — this function never decides whether markup is meant.
9
+ */
10
+ export declare function renderPangoMarkup(target: HTMLElement, markup: string): void;
@@ -0,0 +1 @@
1
+ export declare const PangoMarkupTest: () => Promise<void>;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A recognisable copy glyph rendered inline so the opt-in `source-view` subpath needs no
3
+ * icon CSS. `doc` is a parameter so a caller outside the main document (a shadow root in
4
+ * another frame) gets nodes its own tree can adopt.
5
+ */
6
+ export declare function createCopyGlyph(doc?: Document): SVGSVGElement;
@@ -0,0 +1 @@
1
+ export declare const CopyGlyphTest: () => Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/adwaita-web",
3
- "version": "0.62.0",
3
+ "version": "0.63.0",
4
4
  "description": "Adwaita/Libadwaita web components for browser targets",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -58,14 +58,14 @@
58
58
  "@codemirror/language": "^6.12.4",
59
59
  "@codemirror/state": "^6.7.0",
60
60
  "@codemirror/view": "^6.43.4",
61
- "@gjsify/adwaita-core": "^0.62.0",
62
- "@gjsify/adwaita-fonts": "^0.62.0",
63
- "@gjsify/adwaita-icons": "^0.62.0",
64
- "@gjsify/gtksource-core": "^0.62.0",
61
+ "@gjsify/adwaita-core": "^0.63.0",
62
+ "@gjsify/adwaita-fonts": "^0.63.0",
63
+ "@gjsify/adwaita-icons": "^0.63.0",
64
+ "@gjsify/gtksource-core": "^0.63.0",
65
65
  "@lezer/highlight": "^1.2.3"
66
66
  },
67
67
  "peerDependencies": {
68
- "@gjsify/app-settings": "^0.62.0"
68
+ "@gjsify/app-settings": "^0.63.0"
69
69
  },
70
70
  "peerDependenciesMeta": {
71
71
  "@gjsify/app-settings": {
@@ -73,11 +73,11 @@
73
73
  }
74
74
  },
75
75
  "devDependencies": {
76
- "@gjsify/app-settings": "^0.62.0",
77
- "@gjsify/blueprint": "^0.62.0",
78
- "@gjsify/cli": "^0.62.0",
79
- "@gjsify/unit": "^0.62.0",
80
- "@gjsify/vite-plugin-blueprint": "^0.62.0",
76
+ "@gjsify/app-settings": "^0.63.0",
77
+ "@gjsify/blueprint": "^0.63.0",
78
+ "@gjsify/cli": "^0.63.0",
79
+ "@gjsify/unit": "^0.63.0",
80
+ "@gjsify/vite-plugin-blueprint": "^0.63.0",
81
81
  "sass": "^1.101.0",
82
82
  "typescript": "^6.0.3"
83
83
  },
@@ -63,6 +63,39 @@ export const ApplicationTest = async () => {
63
63
  await it('Adw.Application is a Gtk.Application', () => {
64
64
  expect(Adw.Application.prototype instanceof Gtk.Application).toBe(true);
65
65
  });
66
+ await it('a keydown in the window runs the accelerator, with its target', () => {
67
+ const app = new Adw.Application({ application_id: 'org.gjsify.vec.WebAccel' });
68
+ const window = scene.window(app as never) as Element;
69
+ const hits: unknown[] = [];
70
+ for (const [owner, name] of [
71
+ [app, 'save'],
72
+ [window, 'open'],
73
+ [app, 'view'],
74
+ ] as const) {
75
+ const action = new Gio.SimpleAction({ name });
76
+ action.connect('activate', (_a: unknown, parameter: unknown) => hits.push([name, parameter ?? null]));
77
+ (owner as unknown as { add_action(a: object): void }).add_action(action);
78
+ }
79
+ app.set_accels_for_action('app.save', ['<Primary>s']);
80
+ app.set_accels_for_action('win.open', ['<Primary><Shift>o']);
81
+ app.set_accels_for_action('app.view::list', ['<Primary>l']);
82
+ const press = (init: KeyboardEventInit): boolean => {
83
+ const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
84
+ window.dispatchEvent(event);
85
+ return event.defaultPrevented;
86
+ };
87
+ expect(press({ key: 's', ctrlKey: true })).toBe(true);
88
+ expect(press({ key: 'O', ctrlKey: true, shiftKey: true })).toBe(true);
89
+ expect(press({ key: 'l', ctrlKey: true })).toBe(true);
90
+ expect(press({ key: 's' })).toBe(false);
91
+ expect(hits).toStrictEqual([
92
+ ['save', null],
93
+ ['open', null],
94
+ ['view', 'list'],
95
+ ]);
96
+ (window as unknown as { application: object | null }).application = null;
97
+ expect(press({ key: 's', ctrlKey: true })).toBe(false);
98
+ });
66
99
  await it('quit() takes the windows it added off the page', async () => {
67
100
  const app = new Adw.Application({ application_id: 'org.gjsify.vec.WebQuit' });
68
101
  const window = scene.window(app as never) as Element;
@@ -11,6 +11,7 @@ import {
11
11
  activateWidgetAction as activateIn,
12
12
  activateMenuItem,
13
13
  applicationOfWindow,
14
+ dispatchAccelerator,
14
15
  menuActionsFor,
15
16
  installActionMap,
16
17
  type AdwMenuItem,
@@ -18,6 +19,7 @@ import {
18
19
  type AdwMenuModel,
19
20
  type ApplicationHost,
20
21
  } from '@gjsify/adwaita-core';
22
+ import { webPlatformHost } from './platform-host.js';
21
23
 
22
24
  export const webHost: ApplicationHost = {
23
25
  name: 'adwaita-web',
@@ -70,6 +72,19 @@ export class AdwApplication extends GtkApplication {}
70
72
 
71
73
  type WindowApplication = { add_window(window: object): void; remove_window(window: object): void };
72
74
 
75
+ const ACCEL_LISTENERS = new WeakMap<object, () => void>();
76
+
77
+ /** Key presses in a window of an application run its accelerators; keydown of descendants bubbles here. */
78
+ function followAccelerators(window: object, application: WindowApplication | null): void {
79
+ ACCEL_LISTENERS.get(window)?.();
80
+ ACCEL_LISTENERS.delete(window);
81
+ if (application === null) return;
82
+ ACCEL_LISTENERS.set(
83
+ window,
84
+ webPlatformHost.listenKeys(window, (event) => dispatchAccelerator(window, event, parentOfElement)),
85
+ );
86
+ }
87
+
73
88
  /**
74
89
  * `Gtk.Window:application` and the window's `Gio.ActionMap` (`win.` actions) on an element class.
75
90
  * Setting the property is `add_window`, as on GTK; setting it to `null` is `remove_window`.
@@ -87,6 +102,7 @@ export function defineWindowApplication(proto: object): void {
87
102
  if (current === value) return;
88
103
  current?.remove_window(this);
89
104
  value?.add_window(this);
105
+ followAccelerators(this, value);
90
106
  },
91
107
  });
92
108
  }
@@ -25,9 +25,11 @@
25
25
  // `Adw.AlertDialog:heading-use-markup` and `:body-use-markup` both default to FALSE
26
26
  // (`Adw.Banner:use-markup` is the one that defaults to TRUE, which is why
27
27
  // `adw-banner.ts` documents a deliberate departure and this file does not need to).
28
- // Opting in renders through `innerHTML`, which is the author asserting the string is
29
- // trusted — Pango markup has a fixed tag set and no scripting, HTML has neither
30
- // property.
28
+ // Opting in renders PANGO MARKUP through `renderPangoMarkup`: the string is PARSED by
29
+ // `@gjsify/adwaita-core` and turned into nodes one at a time, never handed to
30
+ // `innerHTML`. So opting in is no longer an assertion that the string is trusted —
31
+ // `<b>` is drawn bold whether or not the author vouched for it, and a tag pango does
32
+ // not know comes back as text.
31
33
  //
32
34
  // Buttons are stacked vertically by default (the Adw.AlertDialog default at medium
33
35
  // sizes) and laid out horizontally when prefer-wide-layout is set and there are no more
@@ -56,6 +58,7 @@
56
58
  import { AdwAlertResponses } from '@gjsify/adwaita-core';
57
59
  import type { AdwResponseAppearance } from '@gjsify/adwaita-core';
58
60
 
61
+ import { renderPangoMarkup } from '../pango-markup.js';
59
62
  import { bindEmptySections } from '../empty-sections.js';
60
63
  import { bindSlottedChildren } from '../slotted-children.js';
61
64
  import { AdwModalSurface } from './modal-surface.js';
@@ -463,12 +466,12 @@ export class AdwAlertDialog extends HTMLElement {
463
466
  this.classList.toggle('open', this.open);
464
467
 
465
468
  const heading = this.heading;
466
- if (this.headingUseMarkup) this._headingEl.innerHTML = heading;
469
+ if (this.headingUseMarkup) renderPangoMarkup(this._headingEl, heading);
467
470
  else this._headingEl.textContent = heading;
468
471
  this._headingEl.hidden = heading.length === 0;
469
472
 
470
473
  const body = this.body;
471
- if (this.bodyUseMarkup) this._bodyEl.innerHTML = body;
474
+ if (this.bodyUseMarkup) renderPangoMarkup(this._bodyEl, body);
472
475
  else this._bodyEl.textContent = body;
473
476
  this._bodyEl.hidden = body.length === 0;
474
477
 
@@ -20,6 +20,8 @@ import {
20
20
  parseBannerButtonStyle,
21
21
  } from '@gjsify/adwaita-core';
22
22
 
23
+ import { renderPangoMarkup } from '../pango-markup.js';
24
+
23
25
  export class AdwBanner extends HTMLElement {
24
26
  private _titleEl!: HTMLSpanElement;
25
27
  private _button!: HTMLButtonElement;
@@ -52,17 +54,20 @@ export class AdwBanner extends HTMLElement {
52
54
 
53
55
  private _render() {
54
56
  const title = this.getAttribute('title') ?? '';
55
- // DELIBERATE DEPARTURE FROM THE C DEFAULT. `Adw.Banner:use-markup`
56
- // defaults to TRUE and `@gjsify/adwaita-core`
57
- // carries that as the spec value — but Pango markup is not HTML. Pango
58
- // has a fixed tag set and no scripting; `innerHTML` will run an
59
- // `onerror` handler. Adopting the C default here would turn every
60
- // `title` attribute into an injection sink for a widget that is not one
61
- // in GTK, so on this renderer markup is OPT-IN and the default is text.
62
- // The opt-in is the developer asserting the title is trusted.
57
+ // DELIBERATE DEPARTURE FROM THE C DEFAULT, and the departure is now about
58
+ // SAFETY rather than about how markup is spelt. `Adw.Banner:use-markup`
59
+ // defaults to TRUE and `@gjsify/adwaita-core` carries that as the spec
60
+ // value — but a `title` attribute is an ATTRIBUTE, and a renderer that
61
+ // parsed it as markup would be one an author (or an injected value) could
62
+ // drive. Adopting the C default here would make every banner title markup
63
+ // by default for a widget that is not one in GTK, so on this renderer
64
+ // markup is OPT-IN and the default is text.
65
+ // Opting in renders PANGO markup — parsed by `renderPangoMarkup`, never
66
+ // handed to `innerHTML`, so nothing the title carries can become a node
67
+ // beyond the eleven tags pango itself knows.
63
68
  // The title label carries `use-underline=False` in the template
64
69
  //, so its underscores are literal — no stripping here.
65
- if (this.hasAttribute('use-markup')) this._titleEl.innerHTML = title;
70
+ if (this.hasAttribute('use-markup')) renderPangoMarkup(this._titleEl, title);
66
71
  else this._titleEl.textContent = title;
67
72
 
68
73
  // `label && label[0]` — a first-character test, so a label of
@@ -47,7 +47,7 @@
47
47
  // difference a reader of the DOM can see.
48
48
  //
49
49
  // NOT PORTED, and why — these are the KNOWN_GAPS of this element:
50
- // `application`, `child`, `default-widget`, `display`, `focus-widget` — WIDGET- or
50
+ // `application`, `child`, `default-widget`, `display` — WIDGET- or
51
51
  // DISPLAY-typed, so a slot or an id here rather than an attribute; `child` and
52
52
  // `titlebar` ARE slots and `transient-for` only has to be PRESENT.
53
53
  // `destroy-with-parent` — a DOM node has no destroy, and a page has no parent to destroy.
@@ -59,15 +59,32 @@
59
59
  // business and needs the `Gio.MenuModel` it is built from (:1150).
60
60
  // `startup-id` — write-only, and written by the launcher that started the application (:883).
61
61
  //
62
+ // THE STATE MEMBERS, and what each is in a page (the shared rules — notify only on change, the
63
+ // close sequence — live in `@gjsify/adwaita-core`'s `window-members.ts`):
64
+ // `is-active` — the DOCUMENT has focus and this frame is connected and shown. A page has one
65
+ // focus for all its windows, so every shown frame is active together; that is
66
+ // the honest limit of a frame (`document.hasFocus()`, window focus/blur and
67
+ // `visibilitychange` feed it). Read-only, as in GTK.
68
+ // `focus-widget` — the element inside the frame that holds `document.activeElement`, live.
69
+ // `set_focus(el)` calls `el.focus()` and refuses an element outside the frame;
70
+ // `set_focus(null)` blurs. `focusin`/`focusout` notify; focus that merely
71
+ // leaves with the window (blur) keeps its widget, as GTK keeps it.
72
+ // `maximized` — the FRAME's state (square corners), not the browser window's: a page cannot
73
+ // maximize its browser window. `is_maximized()` reads it.
74
+ // `present()` — un-hides the frame; a page cannot raise or focus a window it does not own.
75
+ //
62
76
  // Events: `notify::<prop>` (CustomEvent, bubbles, detail `{ <prop>: value }`) on every real
63
- // change, and `close-request` (CustomEvent, bubbles, CANCELLED, no detail), which is
64
- // `Gtk.Window::close-request`.
77
+ // change, and `close-request` (CustomEvent, bubbles, CANCELLABLE, no detail), which is
78
+ // `Gtk.Window::close-request`: a listener that calls `preventDefault()` is a GTK handler that
79
+ // returns true. `window.close()` runs it through `CloseRequestState`.
65
80
  //
66
81
  // Reference: refs/gtk/gtk/gtkwindow.c
67
82
  // Reference: refs/libadwaita/src/stylesheet/widgets/_window.scss (GtkWindow)
68
83
  // Copyright (c) The GTK Team. LGPLv2.1+.
69
84
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
70
85
 
86
+ import { CloseRequestState, WindowMemberState, type WindowMemberProperty } from '@gjsify/adwaita-core';
87
+
71
88
  import { bindSlottedChildren, type AdwSlot } from '../slotted-children.js';
72
89
 
73
90
  // Registers <gtk-window-controls>: a frame with no titlebar of its own draws the two of them
@@ -88,6 +105,20 @@ export class GtkWindow extends HTMLElement {
88
105
  private _titleLabel: HTMLElement | null = null;
89
106
  /** Watches the strip for an author's titlebar arriving after the build. */
90
107
  private _titlebarObserver: MutationObserver | null = null;
108
+ /** `is-active` and `focus-widget`; `maximized` stays the attribute above. */
109
+ private readonly _members = new WindowMemberState<Element>((property) => this._notifyMember(property));
110
+ private readonly _closing = new CloseRequestState();
111
+ private readonly _syncActive = (): void => void this._members.reportActive(this._documentIsActive());
112
+ private readonly _onFocusIn = (event: FocusEvent): void => {
113
+ if (event.target instanceof Element) this._members.reportFocus(event.target);
114
+ };
115
+ private readonly _onFocusOut = (event: FocusEvent): void => {
116
+ const next = event.relatedTarget;
117
+ if (next instanceof Node && this.contains(next)) return;
118
+ // Focus leaving WITH the window (tab switch, another app) keeps the widget; GTK does too.
119
+ if (next === null && !document.hasFocus()) return;
120
+ this._members.reportFocus(null);
121
+ };
91
122
 
92
123
  static get observedAttributes() {
93
124
  return [
@@ -103,6 +134,8 @@ export class GtkWindow extends HTMLElement {
103
134
  'resizable',
104
135
  'title',
105
136
  'transient-for',
137
+ // Not a window property: `is-active` depends on it, so it is watched but never notified.
138
+ 'hidden',
106
139
  ];
107
140
  }
108
141
 
@@ -186,11 +219,13 @@ export class GtkWindow extends HTMLElement {
186
219
 
187
220
  connectedCallback() {
188
221
  if (this._initialized) {
222
+ this._watchActivity();
189
223
  this._watchTitlebar();
190
224
  this._render();
191
225
  return;
192
226
  }
193
227
  this._initialized = true;
228
+ this._watchActivity();
194
229
 
195
230
  this._titlebar = document.createElement('div');
196
231
  this._titlebar.className = 'adw-gtk-window-titlebar';
@@ -216,6 +251,37 @@ export class GtkWindow extends HTMLElement {
216
251
  disconnectedCallback() {
217
252
  this._titlebarObserver?.disconnect();
218
253
  this._titlebarObserver = null;
254
+ window.removeEventListener('focus', this._syncActive);
255
+ window.removeEventListener('blur', this._syncActive);
256
+ document.removeEventListener('visibilitychange', this._syncActive);
257
+ this.removeEventListener('focusin', this._onFocusIn);
258
+ this.removeEventListener('focusout', this._onFocusOut);
259
+ // An unmapped window is neither active nor holding focus.
260
+ this._members.reportActive(false);
261
+ this._members.reportFocus(null);
262
+ }
263
+
264
+ private _watchActivity(): void {
265
+ window.addEventListener('focus', this._syncActive);
266
+ window.addEventListener('blur', this._syncActive);
267
+ document.addEventListener('visibilitychange', this._syncActive);
268
+ this.addEventListener('focusin', this._onFocusIn);
269
+ this.addEventListener('focusout', this._onFocusOut);
270
+ this._syncActive();
271
+ }
272
+
273
+ private _documentIsActive(): boolean {
274
+ return this.isConnected && !this.hidden && document.visibilityState !== 'hidden' && document.hasFocus();
275
+ }
276
+
277
+ private _notifyMember(property: WindowMemberProperty): void {
278
+ const detail =
279
+ property === 'is-active'
280
+ ? this._members.active
281
+ : property === 'focus-widget'
282
+ ? this._members.focus
283
+ : this.maximized;
284
+ this.dispatchEvent(new CustomEvent(`notify::${property}`, { bubbles: true, detail: { [property]: detail } }));
219
285
  }
220
286
 
221
287
  /**
@@ -248,8 +314,13 @@ export class GtkWindow extends HTMLElement {
248
314
  }
249
315
 
250
316
  attributeChangedCallback(name: string) {
251
- if (!this._initialized) return;
252
- this._render();
317
+ if (name === 'hidden') {
318
+ this._syncActive();
319
+ return;
320
+ }
321
+ // Before the first connect there is nothing to draw, but the property still moved: GTK
322
+ // notifies a window that was never mapped, and a listener on a detached frame hears it.
323
+ if (this._initialized) this._render();
253
324
  this.dispatchEvent(
254
325
  new CustomEvent(`notify::${name}`, { bubbles: true, detail: { [name]: this._property(name) } }),
255
326
  );
@@ -344,14 +415,78 @@ export class GtkWindow extends HTMLElement {
344
415
  * it, hide the window when `hide-on-close` is set and DESTROY it otherwise. "Destroy" is
345
416
  * the one word this port cannot mean — a page has no unref — so the default detaches the
346
417
  * frame, which is the same observable outcome for everything above it.
418
+ *
419
+ * A frame that is not in the page is not realized, so there is nothing to close; a listener
420
+ * that calls `close()` again is ignored, as GTK ignores it. Answers whether the window went
421
+ * away (hidden or detached), `false` when a listener took the request over or it was ignored.
347
422
  */
348
423
  close(): boolean {
349
- const request = new CustomEvent('close-request', { bubbles: true, cancelable: true });
350
- this.dispatchEvent(request);
351
- if (request.defaultPrevented) return false;
352
- if (this._offByDefault('hide-on-close')) this.hidden = true;
353
- else this.remove();
354
- return true;
424
+ const outcome = this._closing.close({
425
+ realized: this.isConnected,
426
+ hideOnClose: this._offByDefault('hide-on-close'),
427
+ emit: () => {
428
+ const request = new CustomEvent('close-request', { bubbles: true, cancelable: true });
429
+ this.dispatchEvent(request);
430
+ return request.defaultPrevented;
431
+ },
432
+ hide: () => void (this.hidden = true),
433
+ destroy: () => this.remove(),
434
+ });
435
+ return outcome === 'hidden' || outcome === 'destroyed';
436
+ }
437
+
438
+ /**
439
+ * `gtk_window_present`: show the frame. GTK also raises and focuses the toplevel, which is
440
+ * the window manager's to grant; a page owns no such surface, so this is only the first half.
441
+ */
442
+ present(): void {
443
+ this.hidden = false;
444
+ }
445
+
446
+ /** `Gtk.Window:is-active` — see the header: the document's focus, while this frame is shown. */
447
+ get isActive(): boolean {
448
+ return this._members.active;
449
+ }
450
+
451
+ /** `gtk_window_is_active`. */
452
+ is_active(): boolean {
453
+ return this._members.active;
454
+ }
455
+
456
+ /** `Gtk.Window:focus-widget`, read live from the document. */
457
+ get focusWidget(): Element | null {
458
+ return this.get_focus();
459
+ }
460
+
461
+ /** `gtk_window_get_focus`: the element inside the frame that has focus, or `null`. */
462
+ get_focus(): Element | null {
463
+ const active = document.activeElement;
464
+ return active !== null && active !== this && this.contains(active) ? active : null;
465
+ }
466
+
467
+ /**
468
+ * `gtk_window_set_focus`. `null` clears the focus. An element outside the frame is refused:
469
+ * GTK's window only focuses its own descendants.
470
+ */
471
+ set_focus(widget: Element | null): void {
472
+ if (widget === null) {
473
+ const current = this.get_focus();
474
+ if (current instanceof HTMLElement || current instanceof SVGElement) current.blur();
475
+ this._members.reportFocus(null);
476
+ return;
477
+ }
478
+ if (widget === this || !this.contains(widget)) {
479
+ throw new Error('<gtk-window>.set_focus: the widget is not inside this window');
480
+ }
481
+ if (widget instanceof HTMLElement || widget instanceof SVGElement) widget.focus();
482
+ // `focus()` fires no focusin while the document itself is unfocused, so report what the
483
+ // document now says; an equal write is dropped by the state.
484
+ this._members.reportFocus(this.get_focus());
485
+ }
486
+
487
+ /** `gtk_window_is_maximized`: the FRAME's state, not the browser window's. */
488
+ is_maximized(): boolean {
489
+ return this.maximized;
355
490
  }
356
491
 
357
492
  /**