@gjsify/adwaita-web 0.64.1-next.241 → 0.64.1-next.244

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 (49) hide show
  1. package/README.md +42 -4
  2. package/dist/adwaita-web.css +9 -1
  3. package/dist/adwaita-web.css.map +1 -1
  4. package/lib/types/canvas2d/canvas2d-bridge.d.ts +18 -0
  5. package/lib/types/canvas2d-bridge.spec.d.ts +1 -0
  6. package/lib/types/elements/adw-action-row.d.ts +1 -0
  7. package/lib/types/elements/adw-bottom-sheet.d.ts +1 -0
  8. package/lib/types/elements/adw-navigation-view.d.ts +1 -0
  9. package/lib/types/elements/adw-switch-row.d.ts +1 -0
  10. package/lib/types/elements/gtk-image.d.ts +12 -3
  11. package/lib/types/elements/gtk-scrolled-window.d.ts +10 -0
  12. package/lib/types/gobject-elements.d.ts +7 -0
  13. package/lib/types/icon-registry.d.ts +3 -1
  14. package/lib/types/icon-theme.d.ts +53 -0
  15. package/lib/types/icon-theme.spec.d.ts +1 -0
  16. package/lib/types/index.d.ts +1 -0
  17. package/lib/types/namespace/gtk.d.ts +1 -0
  18. package/lib/types/styles.generated.d.ts +1 -1
  19. package/package.json +12 -12
  20. package/scss/_about_dialog.scss +2 -2
  21. package/scss/_icon.scss +18 -0
  22. package/src/adw-window.spec.ts +7 -0
  23. package/src/application.spec.ts +3 -3
  24. package/src/canvas2d/canvas2d-bridge.ts +51 -1
  25. package/src/canvas2d-bridge.spec.ts +47 -0
  26. package/src/elements/adw-about-dialog.ts +4 -2
  27. package/src/elements/adw-action-row.ts +6 -0
  28. package/src/elements/adw-bottom-sheet.ts +6 -0
  29. package/src/elements/adw-navigation-view.ts +6 -0
  30. package/src/elements/adw-switch-row.ts +6 -0
  31. package/src/elements/gtk-image.ts +55 -24
  32. package/src/elements/gtk-scrolled-window.ts +37 -1
  33. package/src/elements/gtk-window-controls.ts +1 -1
  34. package/src/elements/gtk-window.ts +1 -1
  35. package/src/file-chooser-host.ts +2 -0
  36. package/src/file-chooser.spec.ts +3 -5
  37. package/src/gobject-door.spec.ts +44 -0
  38. package/src/gobject-elements.ts +22 -0
  39. package/src/gtk-font-dialog-button.spec.ts +3 -1
  40. package/src/gtk-scrolled-window.spec.ts +77 -0
  41. package/src/icon-registry.ts +13 -7
  42. package/src/icon-theme.spec.ts +182 -0
  43. package/src/icon-theme.ts +178 -0
  44. package/src/index.ts +3 -0
  45. package/src/namespace/gtk.ts +2 -0
  46. package/src/navigation-view.spec.ts +4 -4
  47. package/src/shared-tree-builder.ts +2 -1
  48. package/src/styles.generated.ts +1 -1
  49. package/src/test.browser.mts +5 -0
@@ -132,6 +132,28 @@ export function dispatchElement(el: HTMLElement, signal: string, args: readonly
132
132
  el.dispatchEvent(domEvent);
133
133
  }
134
134
 
135
+ /**
136
+ * `Gtk.Widget:sensitive` for an element that has no member of that name. The DOM spells it
137
+ * `disabled` on a form control and `aria-disabled` on anything else (`adw-action-row.ts` reads
138
+ * both), so the accessor writes the spelling the element understands. An element that already
139
+ * declares `sensitive` (`<adw-toggle-group>`) keeps its own.
140
+ */
141
+ export function installSensitive(el: HTMLElement): void {
142
+ if ('sensitive' in el) return;
143
+ const observed = (el.constructor as { observedAttributes?: string[] }).observedAttributes ?? [];
144
+ const form = 'disabled' in el || observed.includes('disabled');
145
+ Object.defineProperty(el, 'sensitive', {
146
+ configurable: true,
147
+ enumerable: false,
148
+ get: () => (form ? !el.hasAttribute('disabled') : el.getAttribute('aria-disabled') !== 'true'),
149
+ set: (value: boolean) => {
150
+ if (form) el.toggleAttribute('disabled', !value);
151
+ else if (value) el.removeAttribute('aria-disabled');
152
+ else el.setAttribute('aria-disabled', 'true');
153
+ },
154
+ });
155
+ }
156
+
135
157
  /** Gives `el` the instance API of a registered class, over the events it declares. */
136
158
  export function installElementApi(el: HTMLElement): void {
137
159
  // A registered class already has the core's four methods on its prototype.
@@ -124,7 +124,9 @@ export const GtkFontDialogButtonTest = async () => {
124
124
  expect(label.style.fontFamily).toBe('');
125
125
  el.useFont = true;
126
126
  expect(label.style.fontWeight).toBe('700');
127
- expect(label.style.fontFamily).toBe('"Cantarell"');
127
+ // Engines differ on serializing a quoted single-identifier family: Firefox keeps
128
+ // the quotes, Chromium drops them. The family name is what matters.
129
+ expect(label.style.fontFamily.replace(/"/g, '')).toBe('Cantarell');
128
130
  // `use-size` is false, so `PANGO_FONT_MASK_SIZE` was cleared and there is no size.
129
131
  expect(label.style.fontSize).toBe('');
130
132
  el.useSize = true;
@@ -91,6 +91,80 @@ export const GtkScrolledWindowTest = async () => {
91
91
  });
92
92
  });
93
93
 
94
+ await describe('<gtk-scrolled-window> its child, before and after connect', async () => {
95
+ const detached = (): { el: GtkScrolledWindow; host: HTMLElement } => {
96
+ const host = document.createElement('div');
97
+ host.style.height = '100px';
98
+ const el = document.createElement('gtk-scrolled-window') as GtkScrolledWindow;
99
+ host.appendChild(el);
100
+ return { el, host };
101
+ };
102
+ const label = (slot?: string): HTMLElement => {
103
+ const child = document.createElement('gtk-label');
104
+ if (slot !== undefined) child.setAttribute('slot', slot);
105
+ return child;
106
+ };
107
+
108
+ await it('adopts a slot="child" child appended BEFORE the element connects', async () => {
109
+ const host = document.createElement('div');
110
+ const el = document.createElement('gtk-scrolled-window') as GtkScrolledWindow;
111
+ const child = label('child');
112
+ el.appendChild(child);
113
+ host.appendChild(el);
114
+ document.body.appendChild(host);
115
+ await settle();
116
+ expect(el.viewport.contains(child)).toBe(true);
117
+ host.remove();
118
+ });
119
+
120
+ await it('adopts a slot="child" child appended AFTER the element connects', async () => {
121
+ const { el, host } = mount();
122
+ const child = label('child');
123
+ el.appendChild(child);
124
+ await settle();
125
+ expect(el.viewport.contains(child)).toBe(true);
126
+ host.remove();
127
+ });
128
+
129
+ await it('adopts a default-slot child appended before connect', async () => {
130
+ const host = document.createElement('div');
131
+ const el = document.createElement('gtk-scrolled-window') as GtkScrolledWindow;
132
+ const child = label();
133
+ el.appendChild(child);
134
+ host.appendChild(el);
135
+ document.body.appendChild(host);
136
+ await settle();
137
+ expect(el.viewport.contains(child)).toBe(true);
138
+ host.remove();
139
+ });
140
+
141
+ await it('set_child before connect is adopted on connect', async () => {
142
+ const { el, host } = detached();
143
+ const child = label();
144
+ el.set_child(child);
145
+ expect(el.get_child()).toBe(child);
146
+ document.body.appendChild(host);
147
+ await settle();
148
+ expect(el.viewport.contains(child)).toBe(true);
149
+ expect(el.child).toBe(child);
150
+ host.remove();
151
+ });
152
+
153
+ await it('set_child after connect replaces the child inside the viewport', async () => {
154
+ const { el, host } = mount();
155
+ const first = label();
156
+ const second = label();
157
+ el.set_child(first);
158
+ expect(el.viewport.contains(first)).toBe(true);
159
+ el.set_child(second);
160
+ expect(el.viewport.contains(first)).toBe(false);
161
+ expect(el.viewport.contains(second)).toBe(true);
162
+ el.set_child(null);
163
+ expect(el.child).toBe(null);
164
+ host.remove();
165
+ });
166
+ });
167
+
94
168
  await describe('<gtk-scrolled-window> the policies', async () => {
95
169
  await it('shows an automatic bar only while the content is longer than the window', async () => {
96
170
  const { el, host } = mount();
@@ -111,6 +185,9 @@ export const GtkScrolledWindowTest = async () => {
111
185
  // An unknown nick is the pspec's `automatic`, not a silent never.
112
186
  el.setAttribute('vscrollbar-policy', 'sideways');
113
187
  expect(el.vscrollbarPolicy).toBe('automatic');
188
+ // AUTOMATIC decides from the adjustment, and the viewport was just held at its
189
+ // minimum by NEVER: the re-measure that lifts it is a ResizeObserver delivery.
190
+ await settle();
114
191
  expect(bars(el).vertical.hidden).toBe(false);
115
192
  host.remove();
116
193
  });
@@ -31,6 +31,7 @@ import { normalizeIconName } from '@gjsify/adwaita-core';
31
31
  import { toDataUri } from '@gjsify/adwaita-icons/utils';
32
32
 
33
33
  import { MASK_CLASS_PREFIX } from './elements/gtk-image.js';
34
+ import { iconResolves, notifyIconThemeChanged } from './icon-theme.js';
34
35
 
35
36
  /** Marks the one `<style>` element this module owns, so a reload reuses it. */
36
37
  const REGISTRY_STYLE_ID = 'adwaita-web-icon-registry';
@@ -130,13 +131,20 @@ export function registerIcon(name: string, svg: string): void {
130
131
  // BEFORE the `ruled` early return: taking the sheet is what detects a lost <style>,
131
132
  // and a name already in `ruled` is exactly the one the early return would skip.
132
133
  const sheet = registrySheet();
133
- if (ruled.has(resolved)) return;
134
- insertMaskRule(sheet, resolved);
135
- ruled.add(resolved);
134
+ if (!ruled.has(resolved)) {
135
+ insertMaskRule(sheet, resolved);
136
+ ruled.add(resolved);
137
+ }
138
+ // The CSS above is live for every mask class, but a `name-symbolic` request that fell
139
+ // back to a registered COLOUR icon has no mask class to update — the symbolic glyph
140
+ // now wins that lookup, so the element has to run it again.
141
+ notifyIconThemeChanged();
136
142
  }
137
143
 
138
144
  /**
139
- * Whether `name` has a glyph — compiled into the stylesheet, or registered at runtime.
145
+ * Whether `name` draws something other than `image-missing` — a symbolic glyph compiled into
146
+ * the stylesheet or registered at runtime, or a colour icon from `registerColorIcon`. The
147
+ * name is looked up exactly as `<gtk-image icon-name>` looks it up (`icon-theme.ts`).
140
148
  *
141
149
  * Reads the live cascade rather than a generated list, so it answers for a glyph that
142
150
  * arrived by any route, including a consumer's own `--icon-<name>` declaration. It is
@@ -151,7 +159,5 @@ export function registerIcon(name: string, svg: string): void {
151
159
  * {@link registerIcon} are the two things that create the pair, and both create both.
152
160
  */
153
161
  export function isIconAvailable(name: string): boolean {
154
- const resolved = normalizeIconName(name);
155
- if (resolved === '') return false;
156
- return getComputedStyle(document.documentElement).getPropertyValue(`--icon-${resolved}`).trim() !== '';
162
+ return iconResolves(name);
157
163
  }
@@ -0,0 +1,182 @@
1
+ // The colour half of the icon theme, measured through `getComputedStyle` like
2
+ // `icon-registry.spec.ts` measures the symbolic half: a class on the node proves nothing
3
+ // about what it paints.
4
+ //
5
+ // The order under test is GLib's (`g_themed_icon_update_names`, which `GtkImage`'s lookup
6
+ // goes through): `X` tries [X, X-symbolic], `X-symbolic` tries [X-symbolic, X], and a name
7
+ // neither answers draws `image-missing`. The registry is module-level and outlives a test,
8
+ // so every case registers names of its own.
9
+ import { describe, expect, it } from '@gjsify/unit';
10
+
11
+ import { COLOR_ICON_CLASS, type GtkImage } from './elements/gtk-image.js';
12
+ import { fallbackMask, maskOf } from './icon-registry.spec.js';
13
+ import { isIconAvailable, registerIcon } from './icon-registry.js';
14
+ import { registerColorIcon } from './icon-theme.js';
15
+
16
+ /** A full-colour app icon: a red square on a blue disc, nothing `currentColor` about it. */
17
+ const COLOR_SVG = `<svg height="128" viewBox="0 0 128 128" width="128" xmlns="http://www.w3.org/2000/svg">
18
+ <circle cx="64" cy="64" r="60" fill="#3584e4"/>
19
+ <rect x="40" y="40" width="48" height="48" fill="#e01b24"/>
20
+ </svg>`;
21
+
22
+ /** A symbolic glyph, distinct from the colour one by its path data. */
23
+ const SYMBOLIC_SVG = `<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
24
+ <path d="m 3 3 h 10 v 10 h -10 z m 0 0" fill="currentColor"/>
25
+ </svg>`;
26
+
27
+ function mount(iconName: string): { el: GtkImage; host: HTMLElement } {
28
+ const host = document.createElement('div');
29
+ document.body.appendChild(host);
30
+ const el = document.createElement('gtk-image') as GtkImage;
31
+ el.iconName = iconName;
32
+ host.appendChild(el);
33
+ return { el, host };
34
+ }
35
+
36
+ /** Every `.adw-icon--…` mask class on the node. */
37
+ function maskClasses(el: HTMLElement): string[] {
38
+ return [...el.classList].filter((c) => c.startsWith('adw-icon--'));
39
+ }
40
+
41
+ /** True when `el` paints the registered colour image and nothing masked. */
42
+ function drawsInColor(el: HTMLElement): boolean {
43
+ const style = getComputedStyle(el);
44
+ return (
45
+ el.classList.contains(COLOR_ICON_CLASS) &&
46
+ maskClasses(el).length === 0 &&
47
+ style.maskImage === 'none' &&
48
+ style.backgroundColor === 'rgba(0, 0, 0, 0)' &&
49
+ // `#e01b24` survives `toDataUri` as `%23e01b24`.
50
+ style.backgroundImage.includes('%23e01b24')
51
+ );
52
+ }
53
+
54
+ export const AdwIconThemeTest = async () => {
55
+ await describe('registerColorIcon — a full-colour icon draws as-is', async () => {
56
+ await it('a reverse-DNS application id draws its image, unmasked and unfilled', () => {
57
+ const APP_ID = 'org.gjsify.ColorProbe';
58
+ registerColorIcon(APP_ID, COLOR_SVG);
59
+
60
+ const { el, host } = mount(APP_ID);
61
+ expect(drawsInColor(el)).toBe(true);
62
+ expect(isIconAvailable(APP_ID)).toBe(true);
63
+ host.remove();
64
+ });
65
+
66
+ await it('an already-mounted element redraws when the registration lands', () => {
67
+ const APP_ID = 'org.gjsify.LateColorProbe';
68
+ const { el, host } = mount(APP_ID);
69
+ // Before: what GTK draws for a name its theme does not have.
70
+ expect(maskOf(el)).toBe(fallbackMask());
71
+
72
+ registerColorIcon(APP_ID, COLOR_SVG);
73
+ expect(drawsInColor(el)).toBe(true);
74
+ host.remove();
75
+ });
76
+
77
+ await it("AdwAboutDialog's application-icon draws in colour", () => {
78
+ const APP_ID = 'org.gjsify.AboutColorProbe';
79
+ registerColorIcon(APP_ID, COLOR_SVG);
80
+
81
+ const host = document.createElement('div');
82
+ document.body.appendChild(host);
83
+ const dialog = document.createElement('adw-about-dialog');
84
+ dialog.setAttribute('application-icon', APP_ID);
85
+ host.appendChild(dialog);
86
+
87
+ const icon = dialog.querySelector('.adw-about-dialog-icon') as HTMLElement;
88
+ expect(drawsInColor(icon)).toBe(true);
89
+ // The 96px box, and NOT a solid square under the image: the about dialog used to
90
+ // force `background-color: currentColor` on it.
91
+ expect(getComputedStyle(icon).width).toBe('96px');
92
+ host.remove();
93
+ });
94
+
95
+ await it('a URL source resolves against the document', () => {
96
+ const APP_ID = 'org.gjsify.UrlColorProbe';
97
+ registerColorIcon(APP_ID, 'icons/org.gjsify.UrlColorProbe.svg');
98
+
99
+ const { el, host } = mount(APP_ID);
100
+ const expected = new URL('icons/org.gjsify.UrlColorProbe.svg', document.baseURI).href;
101
+ expect(el.classList.contains(COLOR_ICON_CLASS)).toBe(true);
102
+ expect(getComputedStyle(el).backgroundImage).toBe(`url("${expected}")`);
103
+ host.remove();
104
+ });
105
+ });
106
+
107
+ await describe('the lookup order is GThemedIcon’s', async () => {
108
+ await it('a symbolic icon still masks — a compiled one with no colour entry', () => {
109
+ const { el, host } = mount('go-next-symbolic');
110
+ expect(el.classList.contains(COLOR_ICON_CLASS)).toBe(false);
111
+ expect(maskClasses(el)).toStrictEqual(['adw-icon--go-next']);
112
+ expect(maskOf(el)).not.toBe(fallbackMask());
113
+ expect(getComputedStyle(el).backgroundImage).toBe('none');
114
+ host.remove();
115
+ });
116
+
117
+ await it('X prefers the colour icon, X-symbolic prefers the symbolic one', () => {
118
+ const NAME = 'gjsify-theme-both-probe';
119
+ registerColorIcon(NAME, COLOR_SVG);
120
+ registerIcon(`${NAME}-symbolic`, SYMBOLIC_SVG);
121
+
122
+ const plain = mount(NAME);
123
+ expect(drawsInColor(plain.el)).toBe(true);
124
+ plain.host.remove();
125
+
126
+ const symbolic = mount(`${NAME}-symbolic`);
127
+ expect(symbolic.el.classList.contains(COLOR_ICON_CLASS)).toBe(false);
128
+ expect(maskOf(symbolic.el).includes('m%203%203')).toBe(true);
129
+ expect(getComputedStyle(symbolic.el).backgroundImage).toBe('none');
130
+ symbolic.host.remove();
131
+ });
132
+
133
+ await it('X-symbolic falls back to the colour X, and back again once X-symbolic lands', () => {
134
+ const NAME = 'gjsify-theme-fallback-probe';
135
+ registerColorIcon(NAME, COLOR_SVG);
136
+
137
+ const { el, host } = mount(`${NAME}-symbolic`);
138
+ expect(drawsInColor(el)).toBe(true);
139
+ expect(isIconAvailable(`${NAME}-symbolic`)).toBe(true);
140
+
141
+ registerIcon(NAME, SYMBOLIC_SVG);
142
+ expect(el.classList.contains(COLOR_ICON_CLASS)).toBe(false);
143
+ expect(maskOf(el).includes('m%203%203')).toBe(true);
144
+ host.remove();
145
+ });
146
+
147
+ await it('an unknown name draws image-missing', () => {
148
+ for (const unknown of ['org.gjsify.NeverRegistered', 'gjsify-theme-never-registered']) {
149
+ const { el, host } = mount(unknown);
150
+ expect(el.classList.contains(COLOR_ICON_CLASS)).toBe(false);
151
+ expect(maskOf(el)).toBe(fallbackMask());
152
+ expect(isIconAvailable(unknown)).toBe(false);
153
+ host.remove();
154
+ }
155
+ });
156
+ });
157
+
158
+ await describe('registerColorIcon rejects what it cannot draw faithfully', async () => {
159
+ await it('throws on a name that is empty, spaced, or symbolic', () => {
160
+ expect(() => registerColorIcon('', COLOR_SVG)).toThrow();
161
+ expect(() => registerColorIcon('a b', COLOR_SVG)).toThrow();
162
+ // The symbolic half has its own door; GTK's theme treats all three suffixes as
163
+ // symbolic files.
164
+ expect(() => registerColorIcon('gjsify-probe-symbolic', COLOR_SVG)).toThrow();
165
+ expect(() => registerColorIcon('gjsify-probe-symbolic-ltr', COLOR_SVG)).toThrow();
166
+ expect(() => registerColorIcon('gjsify-probe-symbolic-rtl', COLOR_SVG)).toThrow();
167
+ });
168
+
169
+ await it('throws on a source that is not SVG or an image URL, and registers nothing', () => {
170
+ const REJECTED = 'org.gjsify.RejectedColorProbe';
171
+ expect(() => registerColorIcon(REJECTED, '')).toThrow();
172
+ expect(() => registerColorIcon(REJECTED, '<p>not svg</p>')).toThrow();
173
+ expect(() => registerColorIcon(REJECTED, 'javascript:alert(1)')).toThrow();
174
+ expect(() => registerColorIcon(REJECTED, 'data:text/html,<svg></svg>')).toThrow();
175
+ expect(isIconAvailable(REJECTED)).toBe(false);
176
+
177
+ const { el, host } = mount(REJECTED);
178
+ expect(maskOf(el)).toBe(fallbackMask());
179
+ host.remove();
180
+ });
181
+ });
182
+ };
@@ -0,0 +1,178 @@
1
+ // The one name → glyph lookup every icon in this package goes through, symbolic AND colour.
2
+ //
3
+ // GTK draws two kinds of icon from the same `icon-name`: a symbolic one, recoloured with the
4
+ // foreground colour, and a full-colour one (an application icon, a document thumbnail) that
5
+ // is drawn as it is. Which one a name reaches is not the widget's choice. `GtkImage` builds a
6
+ // `GThemedIcon` for the name (gtkimage.c → `gtk_icon_theme_lookup_by_gicon`) without
7
+ // `use-default-fallbacks`, and `g_themed_icon_update_names` (GLib gio/gthemedicon.c, not in refs/)
8
+ // turns it into an ordered candidate list:
9
+ //
10
+ // `X` → [X, X-symbolic]
11
+ // `X-symbolic` → [X-symbolic, X]
12
+ //
13
+ // and `gtk_icon_theme_lookup_icon` returns the first candidate the theme has, else
14
+ // `image-missing` — it never returns NULL (refs/gtk/gtk/gtkicontheme.c, `choose_icon`).
15
+ //
16
+ // This package compiles and registers symbolic glyphs only (`icon-registry.ts`, the
17
+ // stylesheet's `.adw-icon--<name>` masks). The colour half lives here: a map from an EXACT
18
+ // name to an image URL, filled by {@link registerColorIcon}. With the map empty every lookup
19
+ // ends where it ended before this module existed, so nothing that only uses symbolic icons
20
+ // changes.
21
+ //
22
+ // The colour map is keyed by the raw name, not by `normalizeIconName`: an application icon
23
+ // is named after its application ID (`org.gnome.Builder`, adw-about-dialog.c:1215), which is
24
+ // not a CSS token and never could be a mask class, but is a perfectly good Map key.
25
+ //
26
+ // Original implementation.
27
+
28
+ import { normalizeIconName } from '@gjsify/adwaita-core';
29
+ import { toDataUri } from '@gjsify/adwaita-icons/utils';
30
+
31
+ /** What one icon name resolves to, in the order GTK's theme would try it. */
32
+ export type IconLookup =
33
+ /** A registered full-colour image, drawn as-is. `url` is ready for `url("…")`. */
34
+ | { kind: 'color'; name: string; url: string }
35
+ /** A symbolic glyph, drawn through the `.adw-icon--<name>` mask. `name` is normalized. */
36
+ | { kind: 'symbolic'; name: string }
37
+ /** Nothing in the theme answers this name — draw `image-missing`. */
38
+ | { kind: 'missing' };
39
+
40
+ /** The suffix `g_themed_icon_update_names` swaps to build the other variant. */
41
+ const SYMBOLIC_SUFFIX = /-symbolic$/;
42
+
43
+ /**
44
+ * Every name GTK's theme treats as a symbolic FILE (gtkicontheme.c `icon_name_is_symbolic`):
45
+ * the directional variants are symbolic too, so none of them is a colour icon.
46
+ */
47
+ const SYMBOLIC_FILE_NAME = /-symbolic(?:-ltr|-rtl)?$/;
48
+
49
+ /** Schemes a colour icon may be fetched from. Anything else can run script or is unknown. */
50
+ const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'blob:', 'file:']);
51
+
52
+ const colorIcons = new Map<string, string>();
53
+
54
+ /**
55
+ * Elements that drew a lookup result and have to redraw when the theme changes. A symbolic
56
+ * registration is live through CSS and needs no redraw, but a colour one is an inline
57
+ * `background-image`, and GTK's own images re-run their lookup on the theme's `changed`
58
+ * signal for the same reason.
59
+ */
60
+ const listeners = new Set<() => void>();
61
+
62
+ /** Subscribe to theme changes. Returns the unsubscribe function. */
63
+ export function onIconThemeChanged(listener: () => void): () => void {
64
+ listeners.add(listener);
65
+ return () => {
66
+ listeners.delete(listener);
67
+ };
68
+ }
69
+
70
+ /** Run every subscriber — after any registration that can change a lookup's answer. */
71
+ export function notifyIconThemeChanged(): void {
72
+ for (const listener of listeners) listener();
73
+ }
74
+
75
+ /** Whether a symbolic glyph for an already-normalized name is in the cascade. */
76
+ function hasSymbolic(normalized: string): boolean {
77
+ if (normalized === '') return false;
78
+ return getComputedStyle(document.documentElement).getPropertyValue(`--icon-${normalized}`).trim() !== '';
79
+ }
80
+
81
+ /**
82
+ * Resolve `iconName` as `gtk_icon_theme_lookup_by_gicon` would for a `GtkImage`. `null` when
83
+ * no name was given — the state where GTK draws nothing, not `image-missing`.
84
+ *
85
+ * When no colour icon is registered for the name, a symbolic request is returned WITHOUT
86
+ * checking the cascade: the mask class falls back to `image-missing` through CSS by itself
87
+ * and corrects itself when a later `registerIcon` lands, which is how `<gtk-image>` behaved
88
+ * before this module. The cascade is read only to choose between a colour entry and a
89
+ * symbolic glyph.
90
+ */
91
+ export function lookupIcon(iconName: string | null | undefined): IconLookup | null {
92
+ if (iconName === null || iconName === undefined || iconName === '') return null;
93
+ const symbolicRequest = SYMBOLIC_SUFFIX.test(iconName);
94
+ const colorName = symbolicRequest ? iconName.replace(SYMBOLIC_SUFFIX, '') : iconName;
95
+ const color = colorIcons.get(colorName);
96
+ const symbolic = normalizeIconName(iconName);
97
+
98
+ if (color === undefined) {
99
+ return symbolic === '' ? { kind: 'missing' } : { kind: 'symbolic', name: symbolic };
100
+ }
101
+ if (symbolicRequest && hasSymbolic(symbolic)) return { kind: 'symbolic', name: symbolic };
102
+ return { kind: 'color', name: colorName, url: color };
103
+ }
104
+
105
+ /**
106
+ * Whether `iconName` resolves to something other than `image-missing` — a registered colour
107
+ * icon, or a symbolic glyph compiled into the stylesheet or registered at runtime.
108
+ */
109
+ export function iconResolves(iconName: string): boolean {
110
+ const found = lookupIcon(iconName);
111
+ if (found === null || found.kind === 'missing') return false;
112
+ return found.kind === 'color' || hasSymbolic(found.name);
113
+ }
114
+
115
+ /** `source` as a URL `background-image` can load, or a thrown reason why not. */
116
+ function colorIconUrl(name: string, source: string): string {
117
+ if (source.trimStart().startsWith('<')) {
118
+ const root = new DOMParser().parseFromString(source, 'image/svg+xml').documentElement;
119
+ if (root.tagName !== 'svg') {
120
+ throw new TypeError(
121
+ `registerColorIcon(${JSON.stringify(name)}): the source starts with '<' but parsed ` +
122
+ `to <${root.tagName}>, not an <svg> root.`,
123
+ );
124
+ }
125
+ return toDataUri(source);
126
+ }
127
+
128
+ let url: URL;
129
+ try {
130
+ url = new URL(source, document.baseURI);
131
+ } catch {
132
+ throw new TypeError(`registerColorIcon(${JSON.stringify(name)}): the source is neither SVG source nor a URL.`);
133
+ }
134
+ const isImageData = url.protocol === 'data:' && /^data:image\//i.test(url.href);
135
+ if (!isImageData && !ALLOWED_PROTOCOLS.has(url.protocol)) {
136
+ throw new TypeError(
137
+ `registerColorIcon(${JSON.stringify(name)}): ${JSON.stringify(url.protocol)} URLs are not ` +
138
+ 'accepted — use http(s):, blob:, file: or a data:image/ URI.',
139
+ );
140
+ }
141
+ return url.href;
142
+ }
143
+
144
+ /**
145
+ * Register a FULL-COLOUR icon under `name` — what an application ships in
146
+ * `hicolor/scalable/apps/<app-id>.svg` and GTK finds through its icon theme. Every
147
+ * `<gtk-image>` asking for the name draws the image unrecoloured, the way `AdwAboutDialog`'s
148
+ * `application-icon` does on GNOME.
149
+ *
150
+ * `name` is the EXACT icon name, reverse-DNS application IDs included. A name with a
151
+ * `-symbolic` suffix THROWS: that is the other half of the theme, and {@link registerIcon}
152
+ * is its door. `source` is SVG source, or a URL (resolved against the document) to any
153
+ * image the browser can draw.
154
+ *
155
+ * Re-registering a name replaces the image, and every mounted `<gtk-image>` redraws.
156
+ *
157
+ * CLIENT-SIDE ONLY, like `registerIcon`: it reads `document`.
158
+ */
159
+ export function registerColorIcon(name: string, source: string): void {
160
+ if (typeof name !== 'string' || name === '' || /\s/.test(name)) {
161
+ throw new TypeError(
162
+ `registerColorIcon: ${JSON.stringify(name)} is not an icon name — it must be non-empty ` +
163
+ 'and contain no whitespace.',
164
+ );
165
+ }
166
+ if (SYMBOLIC_FILE_NAME.test(name)) {
167
+ throw new TypeError(
168
+ `registerColorIcon: ${JSON.stringify(name)} names a SYMBOLIC icon, which is recoloured ` +
169
+ 'rather than drawn as-is — register it with registerIcon() instead.',
170
+ );
171
+ }
172
+ if (typeof source !== 'string' || source.trim() === '') {
173
+ throw new TypeError(`registerColorIcon(${JSON.stringify(name)}): the source is empty.`);
174
+ }
175
+
176
+ colorIcons.set(name, colorIconUrl(name, source));
177
+ notifyIconThemeChanged();
178
+ }
package/src/index.ts CHANGED
@@ -89,6 +89,9 @@ export { registerTemplateClass } from './template-classes.js';
89
89
  // the way in for an app that needs a glyph this package does not ship — see
90
90
  // `icon-registry.ts` for the full recipe.
91
91
  export { isIconAvailable, registerIcon } from './icon-registry.js';
92
+ // The full-colour half of the icon theme — an application icon drawn as-is, the web stand-in
93
+ // for an app's `hicolor/…/apps/<app-id>.svg`. See `icon-theme.ts` for the lookup order.
94
+ export { registerColorIcon } from './icon-theme.js';
92
95
 
93
96
  // THE WIDGET CLASSES ARE REACHABLE ONLY AS `Adw.<Name>` / `Gtk.<Name>` (ADR 0034
94
97
  // clause 2 + § Amendment 6). The namespace used to be the LAST line in the file, under a
@@ -96,6 +96,8 @@ export { GtkToggleButton as ToggleButton } from '../elements/gtk-toggle-button.j
96
96
  export { GtkTreeExpander as TreeExpander } from '../elements/gtk-tree-expander.js';
97
97
  export { GtkVideo as Video } from '../elements/gtk-video.js';
98
98
  export { GtkWindow as Window } from '../elements/gtk-window.js';
99
+ // ADR 0098 Amendment 4: the `Gtk.Window` subset both ports and GJS share, for code that runs on all three.
100
+ export type { WindowCommon } from '@gjsify/adwaita-core';
99
101
  export { GtkWindowControls as WindowControls } from '../elements/gtk-window-controls.js';
100
102
  export { GtkViewport as Viewport } from '../elements/gtk-viewport.js';
101
103
  export { GtkWindowHandle as WindowHandle } from '../elements/gtk-window-handle.js';
@@ -433,8 +433,8 @@ export const AdwNavigationViewTest = async () => {
433
433
  await describe('<adw-navigation-view> GJS-spelled API and declared signals', async () => {
434
434
  await it('push_by_tag and get_visible_page_tag follow the camelCase members', () => {
435
435
  const { adapter, view, host, page } = mountView();
436
- adapter.add(page('root'));
437
- adapter.add(page('detail'));
436
+ adapter.add(page('root'), { tag: 'root' });
437
+ adapter.add(page('detail'), { tag: 'detail' });
438
438
  expect(view.push_by_tag('detail')).toBe(true);
439
439
  expect(view.get_visible_page_tag()).toBe('detail');
440
440
  host.remove();
@@ -442,8 +442,8 @@ export const AdwNavigationViewTest = async () => {
442
442
 
443
443
  await it('connects `pushed` and `notify::visible-page`', () => {
444
444
  const { adapter, view, host, page } = mountView();
445
- adapter.add(page('root'));
446
- adapter.add(page('detail'));
445
+ adapter.add(page('root'), { tag: 'root' });
446
+ adapter.add(page('detail'), { tag: 'detail' });
447
447
  const api = view as unknown as { connect(s: string, h: () => void): number };
448
448
  let pushed = 0;
449
449
  let visible = 0;
@@ -55,7 +55,7 @@ import {
55
55
 
56
56
  import { observeAdaptiveSize } from './breakpoints.js';
57
57
  import { capabilities } from './capabilities.mjs';
58
- import { endpointOf, hasElementApi, installElementApi } from './gobject-elements.js';
58
+ import { endpointOf, hasElementApi, installElementApi, installSensitive } from './gobject-elements.js';
59
59
  import { dispatchedSignalsOf } from './signals.js';
60
60
  import { slottedChildrenOf } from './slotted-children.js';
61
61
  import { isTemplateTag, templateTagFor } from './template-classes.js';
@@ -389,6 +389,7 @@ function buildNode(node: SharedTreeNode, record: BuildRecord): HTMLElement {
389
389
  /** What a node asks to be wired once every id exists: its binds and its breakpoints. */
390
390
  function collectNode(el: HTMLElement, node: SharedTreeNode, record: BuildRecord): void {
391
391
  for (const [property, binding] of Object.entries(node.bindings ?? {})) {
392
+ if (property === 'sensitive') installSensitive(el);
392
393
  record.binds.push({
393
394
  el,
394
395
  property,