@gjsify/adwaita-web 0.64.1-next.245 → 0.64.1

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
@@ -319,22 +319,43 @@ registerColorIcon('org.example.Other', '/icons/other.png'); // …or an image UR
319
319
  Every `<gtk-image>` asking for a registered name draws the image unmasked and unrecoloured,
320
320
  which covers every widget that draws its icons through it, `<adw-about-dialog>` and
321
321
  `<gtk-about-dialog logo-icon-name>` among them. A name is looked up in the order GTK's own
322
- theme tries it (`g_themed_icon_update_names`):
322
+ theme tries it (`g_themed_icon_update_names`, then gtkicontheme.c's theme order). A colour
323
+ registration is hicolor's, so the current theme's symbolic glyph comes first either way:
323
324
 
324
325
  | Asked for | Tried, in order | Neither found |
325
326
  | --- | --- | --- |
326
- | `X` | colour `X`, then symbolic `X-symbolic` | `image-missing` |
327
+ | `X` | symbolic `X-symbolic`, then colour `X` | `image-missing` |
327
328
  | `X-symbolic` | symbolic `X-symbolic`, then colour `X` | `image-missing` |
328
329
 
329
- So nothing changes for a name with no colour registration. `registerColorIcon` keys on the
330
+ So nothing changes for a name with no colour registration, and a colour `X` shows only where the
331
+ theme has no `X-symbolic` — the same as on GNOME. `registerColorIcon` keys on the
330
332
  **exact** name, reverse-DNS IDs included, and *throws* on an empty or spaced name, on a
331
333
  `-symbolic` (`-ltr`/`-rtl`) name — that is `registerIcon`'s half — and on a source that is
332
334
  neither SVG source nor an `http(s):`, `blob:`, `file:` or `data:image/` URL. A registration
333
335
  redraws every mounted `<gtk-image>`, and `isIconAvailable` answers `true` for the name.
334
336
 
335
- `Gtk.IconTheme.add_resource_path` and `add_search_path` stay records only on the web: there
336
- is no GResource and no file system to search, so a registration is the one way a colour
337
- icon gets in.
337
+ ### Icons from the app's GResource (ADR 0116)
338
+
339
+ The way GNOME takes: the app's compiled `.gresource`, registered with the GIO calls it already
340
+ makes on GJS. `Gtk.Application` adds `<resource-base-path>/icons/` to the icon theme at startup
341
+ (`resource-base-path` defaults to `/` + the application ID with `.` as `/`), so an app that
342
+ registers before `run()` needs nothing else:
343
+
344
+ ```typescript
345
+ import { Gio } from '@gjsify/adwaita-web';
346
+
347
+ Gio.resources_register(Gio.Resource.new_from_data(bytes)); // bytes: the embedded .gresource
348
+ ```
349
+
350
+ `Gtk.IconTheme.get_for_display(null).add_resource_path(path)` adds a path by hand. Under a path
351
+ the lookup follows `gtkicontheme.c`: hicolor subdirectories (`16x16/apps/`, `scalable/apps/`,
352
+ `symbolic/apps/`, …, without a `hicolor/` component) by size and scale, then files directly
353
+ under the path. A compiled symbolic glyph still answers a `-symbolic` name first. A `-symbolic`
354
+ file draws as a mask in `currentColor`, any other SVG or PNG as it is; an XPM or a compressed
355
+ entry draws `image-missing` with a warning. After a colour registration, a resource is the next
356
+ answer, so `registerColorIcon` still wins for its name. If the resource holds `gtk/menus.ui`
357
+ or `gtk/help-overlay.ui` under the base path, startup throws: no builder here reads them yet.
358
+ `add_search_path` stays a record, since there is no file system to search.
338
359
 
339
360
  ### Why this is a web-only concern
340
361
 
@@ -345,7 +366,7 @@ knowing which one you are on:
345
366
  | --- | --- | --- |
346
367
  | GTK (`@gjsify/adwaita-app`) | `GtkIconTheme` looks it up in the **system** icon theme at runtime | draws the theme's `image-missing` |
347
368
  | NativeScript (`@gjsify/adwaita-nativescript`) | `GtkImage` is handed the **SVG source**, not a name | cannot happen — a missing icon is a missing import |
348
- | Web (this package) | a **compile-time** subset, plus `registerIcon` / `registerColorIcon` | draws `image-missing` |
369
+ | Web (this package) | a **compile-time** subset, the `Gtk.IconTheme` resource paths, plus `registerIcon` / `registerColorIcon` | draws `image-missing` |
349
370
 
350
371
  All three therefore end on ONE glyph for a name they cannot draw. The web reaches it by two
351
372
  routes — no mask class for a known-shaped name, or no usable class for a name like
@@ -39,6 +39,13 @@ export declare class GtkImage extends HTMLElement {
39
39
  connectedCallback(): void;
40
40
  disconnectedCallback(): void;
41
41
  attributeChangedCallback(): void;
42
+ /**
43
+ * What `GtkImage` hands `gtk_icon_theme_lookup_by_gicon` besides the name: the size (the
44
+ * attribute, else the box the stylesheet gave it), the integer scale and the direction.
45
+ * `size` is a getter: measuring flushes style, which would start the CSS transitions of
46
+ * every control around an icon, so only a lookup a resource answers pays for it.
47
+ */
48
+ private _request;
42
49
  private _render;
43
50
  }
44
51
  /**
@@ -0,0 +1 @@
1
+ export declare const GioResourceTest: () => Promise<void>;
@@ -1,3 +1,4 @@
1
+ import { type IconDirection } from '@gjsify/adwaita-core';
1
2
  /** What one icon name resolves to, in the order GTK's theme would try it. */
2
3
  export type IconLookup =
3
4
  /** A registered full-colour image, drawn as-is. `url` is ready for `url("…")`. */
@@ -6,6 +7,17 @@ export type IconLookup =
6
7
  name: string;
7
8
  url: string;
8
9
  }
10
+ /**
11
+ * A file under a `Gtk.IconTheme` resource path; `symbolic` files are masks in `currentColor`.
12
+ * `url` is `null` when the browser cannot draw the file (XPM, compressed): draw `image-missing`.
13
+ */
14
+ | {
15
+ kind: 'resource';
16
+ name: string;
17
+ path: string;
18
+ url: string | null;
19
+ symbolic: boolean;
20
+ }
9
21
  /** A symbolic glyph, drawn through the `.adw-icon--<name>` mask. `name` is normalized. */
10
22
  | {
11
23
  kind: 'symbolic';
@@ -23,16 +35,30 @@ export declare function notifyIconThemeChanged(): void;
23
35
  * Resolve `iconName` as `gtk_icon_theme_lookup_by_gicon` would for a `GtkImage`. `null` when
24
36
  * no name was given — the state where GTK draws nothing, not `image-missing`.
25
37
  *
26
- * When no colour icon is registered for the name, a symbolic request is returned WITHOUT
27
- * checking the cascade: the mask class falls back to `image-missing` through CSS by itself
28
- * and corrects itself when a later `registerIcon` lands, which is how `<gtk-image>` behaved
29
- * before this module. The cascade is read only to choose between a colour entry and a
30
- * symbolic glyph.
38
+ * When neither a colour registration nor a resource holds a name, the request is returned as a
39
+ * symbolic one WITHOUT checking the cascade: the mask class falls back to `image-missing`
40
+ * through CSS by itself and corrects itself when a later `registerIcon` lands, which is how
41
+ * `<gtk-image>` behaved before this module. The cascade is read only to choose between a
42
+ * symbolic glyph and a colour entry or file.
43
+ */
44
+ export declare function lookupIcon(iconName: string | null | undefined, request?: IconRequest): IconLookup | null;
45
+ /** What a `GtkImage` hands its theme besides the name: its size, scale and text direction. */
46
+ export interface IconRequest {
47
+ /** In CSS pixels; 16 when absent. */
48
+ readonly size?: number;
49
+ /** The device scale; 1 when absent. */
50
+ readonly scale?: number;
51
+ readonly direction?: IconDirection;
52
+ }
53
+ /**
54
+ * The lookup over the colour registrations and the theme's resource paths; `null` when neither
55
+ * answers any name, a `symbolic` entry when the current theme's glyph comes first.
31
56
  */
32
- export declare function lookupIcon(iconName: string | null | undefined): IconLookup | null;
57
+ export declare function resourceIcon(iconName: string, request: IconRequest): IconLookup | null;
33
58
  /**
34
59
  * Whether `iconName` resolves to something other than `image-missing` — a registered colour
35
- * icon, or a symbolic glyph compiled into the stylesheet or registered at runtime.
60
+ * icon, a file under a resource path, or a symbolic glyph compiled into the stylesheet or
61
+ * registered at runtime.
36
62
  */
37
63
  export declare function iconResolves(iconName: string): boolean;
38
64
  /**
@@ -2,5 +2,6 @@ import '../file-chooser-host.js';
2
2
  export { GioMenu as Menu, GioMenuItem as MenuItem } from '@gjsify/adwaita-core';
3
3
  export { GioFile as File, GioListStore as ListStore, GioOutputStream as OutputStream, GioFileOutputStream as FileOutputStream, FileCreateFlags, IOErrorEnum, promisify as _promisify, } from '@gjsify/adwaita-core';
4
4
  export { SimpleAction, SimpleActionGroup, ApplicationFlags } from '@gjsify/adwaita-core';
5
+ export { GioResource as Resource, ResourceError, ResourceLookupFlags, resources_enumerate_children, resources_get_info, resources_lookup_data, resources_register, resources_unregister, } from '@gjsify/adwaita-core';
5
6
  export { GioApplication as Application } from '../application.js';
6
7
  export { Settings } from '@gjsify/app-settings/browser';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/adwaita-web",
3
- "version": "0.64.1-next.245",
3
+ "version": "0.64.1",
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.64.1-next.245",
62
- "@gjsify/adwaita-fonts": "0.64.1-next.245",
63
- "@gjsify/adwaita-icons": "0.64.1-next.245",
64
- "@gjsify/gtksource-core": "0.64.1-next.245",
61
+ "@gjsify/adwaita-core": "^0.64.1",
62
+ "@gjsify/adwaita-fonts": "^0.64.1",
63
+ "@gjsify/adwaita-icons": "^0.64.1",
64
+ "@gjsify/gtksource-core": "^0.64.1",
65
65
  "@lezer/highlight": "^1.2.3"
66
66
  },
67
67
  "peerDependencies": {
68
- "@gjsify/app-settings": "0.64.1-next.245"
68
+ "@gjsify/app-settings": "^0.64.1"
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.64.1-next.245",
77
- "@gjsify/blueprint": "0.64.1-next.245",
78
- "@gjsify/cli": "0.64.1-next.245",
79
- "@gjsify/unit": "0.64.1-next.245",
80
- "@gjsify/vite-plugin-blueprint": "0.64.1-next.245",
76
+ "@gjsify/app-settings": "^0.64.1",
77
+ "@gjsify/blueprint": "^0.64.1",
78
+ "@gjsify/cli": "^0.64.1",
79
+ "@gjsify/unit": "^0.64.1",
80
+ "@gjsify/vite-plugin-blueprint": "^0.64.1",
81
81
  "sass": "^1.101.0",
82
82
  "typescript": "^6.0.3"
83
83
  },
@@ -114,6 +114,5 @@
114
114
  "bugs": {
115
115
  "url": "https://github.com/gjsify/gjsify/issues"
116
116
  },
117
- "homepage": "https://github.com/gjsify/gjsify/tree/main/packages/web/adwaita-web#readme",
118
- "gitHead": "9a6ca179c1a74477f23791c5b8f6d5a7bffaee61"
117
+ "homepage": "https://github.com/gjsify/gjsify/tree/main/packages/web/adwaita-web#readme"
119
118
  }
@@ -49,6 +49,7 @@ const subject: ApplicationSubject = {
49
49
  SimpleAction: Gio.SimpleAction as unknown as ApplicationSubject['SimpleAction'],
50
50
  handlesOpen: Gio.ApplicationFlags.HANDLES_OPEN,
51
51
  scene,
52
+ iconTheme: () => Gtk.IconTheme.get_for_display(null),
52
53
  };
53
54
 
54
55
  export const ApplicationTest = async () => {
@@ -19,6 +19,7 @@ import {
19
19
  type AdwMenuModel,
20
20
  type ApplicationHost,
21
21
  } from '@gjsify/adwaita-core';
22
+ import { IconTheme } from './namespace/gtk-platform.js';
22
23
  import { webPlatformHost } from './platform-host.js';
23
24
 
24
25
  export const webHost: ApplicationHost = {
@@ -38,6 +39,7 @@ export const webHost: ApplicationHost = {
38
39
  (window as Element).remove();
39
40
  },
40
41
  exit() {},
42
+ iconTheme: () => IconTheme.get_for_display(null),
41
43
  };
42
44
 
43
45
  /** An element's parent for action resolution; a popover is rooted at the widget that owns it. */
@@ -35,7 +35,12 @@
35
35
 
36
36
  import { normalizeIconName } from '@gjsify/adwaita-core';
37
37
 
38
- import { lookupIcon, onIconThemeChanged } from '../icon-theme.js';
38
+ import { lookupIcon, onIconThemeChanged, type IconRequest } from '../icon-theme.js';
39
+
40
+ /** `url` as one CSS string token, whatever quotes or backslashes it holds. */
41
+ function cssUrl(url: string): string {
42
+ return `url("${url.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}")`;
43
+ }
39
44
 
40
45
  /**
41
46
  * The generated mask-class prefix — the one place the `--` spelling appears. `icon-registry`
@@ -114,6 +119,27 @@ export class GtkImage extends HTMLElement {
114
119
  this._render();
115
120
  }
116
121
 
122
+ /**
123
+ * What `GtkImage` hands `gtk_icon_theme_lookup_by_gicon` besides the name: the size (the
124
+ * attribute, else the box the stylesheet gave it), the integer scale and the direction.
125
+ * `size` is a getter: measuring flushes style, which would start the CSS transitions of
126
+ * every control around an icon, so only a lookup a resource answers pays for it.
127
+ */
128
+ private _request(): IconRequest {
129
+ const measure = (): number => {
130
+ if (this.size !== null) return this.size;
131
+ const measured = this.isConnected ? Number.parseFloat(getComputedStyle(this).width) : Number.NaN;
132
+ return Number.isFinite(measured) && measured > 0 ? Math.round(measured) : 16;
133
+ };
134
+ return {
135
+ get size() {
136
+ return measure();
137
+ },
138
+ scale: Math.max(1, Math.round(globalThis.devicePixelRatio ?? 1)),
139
+ direction: this.matches(':dir(rtl)') ? 'rtl' : 'ltr',
140
+ };
141
+ }
142
+
117
143
  private _render(): void {
118
144
  this.classList.add('adw-icon');
119
145
 
@@ -123,16 +149,31 @@ export class GtkImage extends HTMLElement {
123
149
  // would skip the entry after each hit.
124
150
  const stale = [...this.classList].filter((existing) => existing.startsWith(MASK_CLASS_PREFIX));
125
151
  this.classList.remove(...stale);
126
- const found = lookupIcon(this.getAttribute('icon-name'));
127
- this.classList.toggle(COLOR_ICON_CLASS, found?.kind === 'color');
128
- if (found?.kind === 'color') {
152
+ const looked = lookupIcon(this.getAttribute('icon-name'), this._request());
153
+ // A resource file the browser cannot draw (XPM, compressed) is GTK's `image-missing`.
154
+ const found = looked?.kind === 'resource' && looked.url === null ? ({ kind: 'missing' } as const) : looked;
155
+ const asIs = found?.kind === 'color' || (found?.kind === 'resource' && !found.symbolic) ? found.url : null;
156
+ const mask = found?.kind === 'resource' && found.symbolic ? found.url : null;
157
+ this.classList.toggle(COLOR_ICON_CLASS, asIs !== null);
158
+ if (asIs !== null) {
129
159
  // Drawn as-is: `_icon.scss` drops the mask and the `currentColor` fill for this
130
- // modifier, so only the image paints. `url` is a data-URI or a resolved href;
160
+ // modifier, so only the image paints. `url` is a data-URI, a blob or a resolved href;
131
161
  // the quote and backslash escapes keep it one CSS string either way.
132
- this.style.backgroundImage = `url("${found.url.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}")`;
162
+ this.style.backgroundImage = cssUrl(asIs);
133
163
  } else {
134
164
  this.style.removeProperty('background-image');
135
165
  }
166
+ if (mask !== null) {
167
+ // A symbolic file from a resource path: the mask is the file, the fill `currentColor`
168
+ // (inline, because the classless-box rule in `_icon.scss` makes it transparent).
169
+ this.style.setProperty('mask-image', cssUrl(mask));
170
+ this.style.setProperty('-webkit-mask-image', cssUrl(mask));
171
+ this.style.backgroundColor = 'currentColor';
172
+ } else {
173
+ this.style.removeProperty('mask-image');
174
+ this.style.removeProperty('-webkit-mask-image');
175
+ this.style.removeProperty('background-color');
176
+ }
136
177
  if (found?.kind === 'symbolic') {
137
178
  this.classList.add(`${MASK_CLASS_PREFIX}${found.name}`);
138
179
  } else if (found?.kind === 'missing') {
@@ -0,0 +1,137 @@
1
+ // ADR 0116 §§ 3–4 on the web: the `Gio` resource subset through the `Gio` door, the shared
2
+ // icon theme's resource paths through the lookup `<gtk-image>` uses, and what it then paints.
3
+ // Real GJS is the oracle for both vector sets, in adwaita-core's `*.gjs.spec.ts`.
4
+ import {
5
+ GIO_RESOURCE_VECTORS,
6
+ ICON_FIXTURE_PATH,
7
+ ICON_THEME_RESOURCE_VECTORS,
8
+ driveGioResourceVectors,
9
+ driveIconThemeResourceVectors,
10
+ gioResourceFixtures,
11
+ type IconThemeResourceSubject,
12
+ } from '@gjsify/adwaita-core/conformance';
13
+ import { describe, expect, it } from '@gjsify/unit';
14
+
15
+ import { COLOR_ICON_CLASS, type GtkImage } from './elements/gtk-image.js';
16
+ import { resourceIcon } from './icon-theme.js';
17
+ import { GLib, Gio, Gtk } from './index.js';
18
+
19
+ const theme = Gtk.IconTheme.get_for_display(null);
20
+
21
+ const iconSubject: IconThemeResourceSubject = {
22
+ name: 'adwaita-web',
23
+ isOracle: false,
24
+ register(data) {
25
+ const resource = Gio.Resource.new_from_data(data);
26
+ Gio.resources_register(resource);
27
+ return () => Gio.resources_unregister(resource);
28
+ },
29
+ theme() {
30
+ theme.set_resource_path([]);
31
+ return theme;
32
+ },
33
+ lookup(_theme, name, size, scale, direction) {
34
+ const found = resourceIcon(name, { size, scale, direction });
35
+ if (found === null) return null;
36
+ if (found.kind === 'resource') return { uri: `resource://${found.path}`, symbolic: found.symbolic };
37
+ return found.kind === 'symbolic' ? 'theme' : null;
38
+ },
39
+ };
40
+
41
+ /** Mounts `<gtk-image>` for each name with the fixture's icons on the theme's resource path. */
42
+ async function withIcons(run: (image: (name: string, size?: number) => GtkImage) => Promise<void> | void) {
43
+ const resource = Gio.Resource.new_from_data(gioResourceFixtures().icons);
44
+ Gio.resources_register(resource);
45
+ const before = theme.get_resource_path();
46
+ theme.add_resource_path(ICON_FIXTURE_PATH);
47
+ // `changed` is deferred: let it drop the previous scan's URLs (and their warnings) first.
48
+ await new Promise((resolve) => setTimeout(resolve, 0));
49
+ const host = document.createElement('div');
50
+ document.body.append(host);
51
+ try {
52
+ await run((name, size) => {
53
+ const el = document.createElement('gtk-image') as GtkImage;
54
+ if (size !== undefined) el.setAttribute('size', String(size));
55
+ el.setAttribute('icon-name', name);
56
+ host.append(el);
57
+ return el;
58
+ });
59
+ } finally {
60
+ host.remove();
61
+ theme.set_resource_path(before);
62
+ Gio.resources_unregister(resource);
63
+ }
64
+ }
65
+
66
+ export const GioResourceTest = async () => {
67
+ await driveGioResourceVectors(
68
+ {
69
+ name: 'adwaita-web',
70
+ isOracle: false,
71
+ Gio: Gio as never,
72
+ bytes: (data) => new GLib.Bytes(data),
73
+ },
74
+ { describe, it, expect },
75
+ GIO_RESOURCE_VECTORS,
76
+ );
77
+
78
+ const before = theme.get_resource_path();
79
+ try {
80
+ await driveIconThemeResourceVectors(iconSubject, { describe, it, expect }, ICON_THEME_RESOURCE_VECTORS);
81
+ } finally {
82
+ theme.set_resource_path(before);
83
+ }
84
+
85
+ await describe('adwaita-web: <gtk-image> draws resource icons (ADR 0116 § 4)', async () => {
86
+ await it('draws a symbolic file as an inline mask in currentColor', async () => {
87
+ await withIcons((image) => {
88
+ const el = image('sym-symbolic', 16);
89
+ expect(el.style.getPropertyValue('mask-image').startsWith('url("blob:')).toBe(true);
90
+ expect(el.style.backgroundColor).toBe('currentcolor');
91
+ expect(el.classList.contains(COLOR_ICON_CLASS)).toBe(false);
92
+ });
93
+ });
94
+
95
+ await it('draws a colour file as-is, without a mask', async () => {
96
+ await withIcons((image) => {
97
+ const el = image('flat');
98
+ expect(el.classList.contains(COLOR_ICON_CLASS)).toBe(true);
99
+ expect(el.style.backgroundImage.startsWith('url("blob:')).toBe(true);
100
+ expect(el.style.getPropertyValue('mask-image')).toBe('');
101
+ });
102
+ });
103
+
104
+ await it('draws image-missing for a file a browser cannot draw', async () => {
105
+ await withIcons((image) => {
106
+ const warn = console.warn;
107
+ const warned: unknown[] = [];
108
+ console.warn = (...args: unknown[]) => warned.push(args);
109
+ try {
110
+ const el = image('old');
111
+ expect(el.classList.contains('adw-icon--image-missing')).toBe(true);
112
+ expect(el.style.backgroundImage).toBe('');
113
+ expect(warned.length > 0).toBe(true);
114
+ } finally {
115
+ console.warn = warn;
116
+ }
117
+ });
118
+ });
119
+
120
+ await it("prefers the current theme's glyph over a resource of the same name", async () => {
121
+ await withIcons((image) => {
122
+ const el = image('go-next-symbolic', 16);
123
+ expect(el.classList.contains('adw-icon--go-next')).toBe(true);
124
+ expect(el.style.getPropertyValue('mask-image')).toBe('');
125
+ });
126
+ });
127
+
128
+ await it('clears the inline drawing when the name changes to a theme glyph', async () => {
129
+ await withIcons((image) => {
130
+ const el = image('sym-symbolic', 16);
131
+ el.setAttribute('icon-name', 'go-next-symbolic');
132
+ expect(el.style.getPropertyValue('mask-image')).toBe('');
133
+ expect(el.style.backgroundColor).toBe('');
134
+ });
135
+ });
136
+ });
137
+ };
@@ -4,7 +4,9 @@
4
4
  //
5
5
  // The order under test is GLib's (`g_themed_icon_update_names`, which `GtkImage`'s lookup
6
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,
7
+ // neither answers draws `image-missing`. Over the names the order is gtkicontheme.c's: the
8
+ // current theme (the stylesheet's glyphs) before hicolor, where a colour registration sits.
9
+ // The registry is module-level and outlives a test,
8
10
  // so every case registers names of its own.
9
11
  import { describe, expect, it } from '@gjsify/unit';
10
12
 
@@ -114,13 +116,16 @@ export const AdwIconThemeTest = async () => {
114
116
  host.remove();
115
117
  });
116
118
 
117
- await it('X prefers the colour icon, X-symbolic prefers the symbolic one', () => {
119
+ await it("the theme's X-symbolic answers X and X-symbolic before the colour X, as on GTK", () => {
118
120
  const NAME = 'gjsify-theme-both-probe';
119
121
  registerColorIcon(NAME, COLOR_SVG);
120
122
  registerIcon(`${NAME}-symbolic`, SYMBOLIC_SVG);
121
123
 
124
+ // gtkicontheme.c `real_choose_icon`: theme outer, name inner, and the colour icon is
125
+ // hicolor's, so the current theme's `X-symbolic` comes first (ADR 0115 Amendment 2).
122
126
  const plain = mount(NAME);
123
- expect(drawsInColor(plain.el)).toBe(true);
127
+ expect(plain.el.classList.contains(COLOR_ICON_CLASS)).toBe(false);
128
+ expect(maskOf(plain.el).includes('m%203%203')).toBe(true);
124
129
  plain.host.remove();
125
130
 
126
131
  const symbolic = mount(`${NAME}-symbolic`);
package/src/icon-theme.ts CHANGED
@@ -23,23 +23,43 @@
23
23
  // is named after its application ID (`org.gnome.Builder`, adw-about-dialog.c:1215), which is
24
24
  // not a CSS token and never could be a mask class, but is a perfectly good Map key.
25
25
  //
26
+ // RESOURCE PATHS (ADR 0116 § 4). The names go through the `Gtk.IconTheme` resource paths an
27
+ // application added (`GtkApplication` adds `<resource-base-path>/icons/` at startup);
28
+ // `@gjsify/adwaita-core`'s `lookupResourceIcon` decides which file answers, as gtkicontheme.c
29
+ // does, and asks this module only whether the stylesheet holds a symbolic glyph. A colour
30
+ // registration stands in hicolor there, as the file an app ships in `hicolor/scalable/apps/`
31
+ // would: the stylesheet's `X-symbolic` answers `X` before it, as Adwaita's does on GNOME. A file
32
+ // answers as a blob URL of its bytes: a symbolic one is drawn as a mask in `currentColor`, the
33
+ // rest as-is. A browser draws SVG and PNG; an XPM or a compressed entry draws `image-missing`
34
+ // with a warning.
35
+ //
26
36
  // Original implementation.
27
37
 
28
- import { normalizeIconName } from '@gjsify/adwaita-core';
38
+ import {
39
+ iconLookupNames,
40
+ lookupResourceIcon,
41
+ normalizeIconName,
42
+ resources_lookup_data,
43
+ type IconDirection,
44
+ } from '@gjsify/adwaita-core';
29
45
  import { toDataUri } from '@gjsify/adwaita-icons/utils';
30
46
 
47
+ import { IconTheme } from './namespace/gtk-platform.js';
48
+
31
49
  /** What one icon name resolves to, in the order GTK's theme would try it. */
32
50
  export type IconLookup =
33
51
  /** A registered full-colour image, drawn as-is. `url` is ready for `url("…")`. */
34
52
  | { kind: 'color'; name: string; url: string }
53
+ /**
54
+ * A file under a `Gtk.IconTheme` resource path; `symbolic` files are masks in `currentColor`.
55
+ * `url` is `null` when the browser cannot draw the file (XPM, compressed): draw `image-missing`.
56
+ */
57
+ | { kind: 'resource'; name: string; path: string; url: string | null; symbolic: boolean }
35
58
  /** A symbolic glyph, drawn through the `.adw-icon--<name>` mask. `name` is normalized. */
36
59
  | { kind: 'symbolic'; name: string }
37
60
  /** Nothing in the theme answers this name — draw `image-missing`. */
38
61
  | { kind: 'missing' };
39
62
 
40
- /** The suffix `g_themed_icon_update_names` swaps to build the other variant. */
41
- const SYMBOLIC_SUFFIX = /-symbolic$/;
42
-
43
63
  /**
44
64
  * Every name GTK's theme treats as a symbolic FILE (gtkicontheme.c `icon_name_is_symbolic`):
45
65
  * the directional variants are symbolic too, so none of them is a colour icon.
@@ -82,33 +102,97 @@ function hasSymbolic(normalized: string): boolean {
82
102
  * Resolve `iconName` as `gtk_icon_theme_lookup_by_gicon` would for a `GtkImage`. `null` when
83
103
  * no name was given — the state where GTK draws nothing, not `image-missing`.
84
104
  *
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.
105
+ * When neither a colour registration nor a resource holds a name, the request is returned as a
106
+ * symbolic one WITHOUT checking the cascade: the mask class falls back to `image-missing`
107
+ * through CSS by itself and corrects itself when a later `registerIcon` lands, which is how
108
+ * `<gtk-image>` behaved before this module. The cascade is read only to choose between a
109
+ * symbolic glyph and a colour entry or file.
90
110
  */
91
- export function lookupIcon(iconName: string | null | undefined): IconLookup | null {
111
+ export function lookupIcon(iconName: string | null | undefined, request: IconRequest = {}): IconLookup | null {
92
112
  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);
113
+ const found = resourceIcon(iconName, request);
114
+ if (found !== null) return found;
96
115
  const symbolic = normalizeIconName(iconName);
116
+ return symbolic === '' ? { kind: 'missing' } : { kind: 'symbolic', name: symbolic };
117
+ }
97
118
 
98
- if (color === undefined) {
99
- return symbolic === '' ? { kind: 'missing' } : { kind: 'symbolic', name: symbolic };
119
+ /** What a `GtkImage` hands its theme besides the name: its size, scale and text direction. */
120
+ export interface IconRequest {
121
+ /** In CSS pixels; 16 when absent. */
122
+ readonly size?: number;
123
+ /** The device scale; 1 when absent. */
124
+ readonly scale?: number;
125
+ readonly direction?: IconDirection;
126
+ }
127
+
128
+ const theme = IconTheme.get_for_display(null);
129
+
130
+ /** Blob URLs of resource files, by resource path, for the current scan. */
131
+ const resourceUrls = new Map<string, string | null>();
132
+
133
+ theme.connect('changed', () => {
134
+ for (const url of resourceUrls.values()) if (url !== null) URL.revokeObjectURL(url);
135
+ resourceUrls.clear();
136
+ notifyIconThemeChanged();
137
+ });
138
+
139
+ const RESOURCE_TYPES: Record<string, string> = { '.svg': 'image/svg+xml', '.png': 'image/png' };
140
+
141
+ /** The file at `path` as a URL a browser draws, or `null` (warned once) when it cannot. */
142
+ function resourceUrl(path: string): string | null {
143
+ const cached = resourceUrls.get(path);
144
+ if (cached !== undefined) return cached;
145
+ let url: string | null = null;
146
+ const type = RESOURCE_TYPES[path.slice(path.lastIndexOf('.'))];
147
+ if (type === undefined) {
148
+ console.warn(`Gtk.IconTheme: ${path} is not an SVG or a PNG, which is all a browser draws (ADR 0116 § 4).`);
149
+ } else {
150
+ try {
151
+ const data = resources_lookup_data(path, 0).get_data();
152
+ url = URL.createObjectURL(new Blob([data as Uint8Array<ArrayBuffer>], { type }));
153
+ } catch (error) {
154
+ console.warn(`Gtk.IconTheme: ${path} cannot be drawn: ${(error as Error).message}`);
155
+ }
100
156
  }
101
- if (symbolicRequest && hasSymbolic(symbolic)) return { kind: 'symbolic', name: symbolic };
102
- return { kind: 'color', name: colorName, url: color };
157
+ resourceUrls.set(path, url);
158
+ return url;
159
+ }
160
+
161
+ /**
162
+ * The lookup over the colour registrations and the theme's resource paths; `null` when neither
163
+ * answers any name, a `symbolic` entry when the current theme's glyph comes first.
164
+ */
165
+ export function resourceIcon(iconName: string, request: IconRequest): IconLookup | null {
166
+ const match = lookupResourceIcon(theme, {
167
+ names: iconLookupNames(iconName, request.direction ?? null),
168
+ get size() {
169
+ return request.size ?? 16;
170
+ },
171
+ scale: request.scale ?? 1,
172
+ themeHas: (name) => hasSymbolic(normalizeIconName(name)),
173
+ hicolorHas: (name) => colorIcons.has(name),
174
+ });
175
+ if (match === null) return null;
176
+ if (match.kind === 'theme') return { kind: 'symbolic', name: normalizeIconName(match.name) };
177
+ if (match.kind === 'hicolor') return { kind: 'color', name: match.name, url: colorIcons.get(match.name)! };
178
+ return {
179
+ kind: 'resource',
180
+ name: match.name,
181
+ path: match.path,
182
+ url: resourceUrl(match.path),
183
+ symbolic: match.symbolic,
184
+ };
103
185
  }
104
186
 
105
187
  /**
106
188
  * 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.
189
+ * icon, a file under a resource path, or a symbolic glyph compiled into the stylesheet or
190
+ * registered at runtime.
108
191
  */
109
192
  export function iconResolves(iconName: string): boolean {
110
193
  const found = lookupIcon(iconName);
111
194
  if (found === null || found.kind === 'missing') return false;
195
+ if (found.kind === 'resource') return found.url !== null;
112
196
  return found.kind === 'color' || hasSymbolic(found.name);
113
197
  }
114
198
 
@@ -35,5 +35,15 @@ export {
35
35
  promisify as _promisify,
36
36
  } from '@gjsify/adwaita-core';
37
37
  export { SimpleAction, SimpleActionGroup, ApplicationFlags } from '@gjsify/adwaita-core';
38
+ export {
39
+ GioResource as Resource,
40
+ ResourceError,
41
+ ResourceLookupFlags,
42
+ resources_enumerate_children,
43
+ resources_get_info,
44
+ resources_lookup_data,
45
+ resources_register,
46
+ resources_unregister,
47
+ } from '@gjsify/adwaita-core';
38
48
  export { GioApplication as Application } from '../application.js';
39
49
  export { Settings } from '@gjsify/app-settings/browser';
@@ -67,6 +67,7 @@ import { AdwButtonContentTest } from './adw-button-content.spec.js';
67
67
  import { GtkImageTest } from './gtk-image.spec.js';
68
68
  import { AdwIconRegistryTest } from './icon-registry.spec.js';
69
69
  import { AdwIconThemeTest } from './icon-theme.spec.js';
70
+ import { GioResourceTest } from './gio-resource.spec.js';
70
71
  import { GtkSwitchTest } from './gtk-switch.spec.js';
71
72
  import { AdwChecksTest } from './checks.spec.js';
72
73
  import { GtkProgressBarTest } from './gtk-progress-bar.spec.js';
@@ -202,6 +203,7 @@ run({
202
203
  GtkImageTest,
203
204
  AdwIconRegistryTest,
204
205
  AdwIconThemeTest,
206
+ GioResourceTest,
205
207
  GtkSwitchTest,
206
208
  AdwChecksTest,
207
209
  GtkProgressBarTest,