@gjsify/adwaita-web 0.64.1-next.240 → 0.64.1-next.243
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 +42 -4
- package/dist/adwaita-web.css +12 -1
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/canvas2d/canvas2d-bridge.d.ts +18 -0
- package/lib/types/canvas2d-bridge.spec.d.ts +1 -0
- package/lib/types/elements/adw-action-row.d.ts +3 -0
- package/lib/types/elements/adw-bottom-sheet.d.ts +3 -0
- package/lib/types/elements/adw-navigation-view.d.ts +7 -0
- package/lib/types/elements/adw-switch-row.d.ts +3 -0
- package/lib/types/elements/adw-window-features.d.ts +6 -1
- package/lib/types/elements/gtk-application-window.d.ts +2 -0
- package/lib/types/elements/gtk-image.d.ts +12 -3
- package/lib/types/elements/gtk-scrolled-window.d.ts +10 -0
- package/lib/types/elements/gtk-window.d.ts +29 -3
- package/lib/types/gobject-elements.d.ts +7 -0
- package/lib/types/icon-registry.d.ts +3 -1
- package/lib/types/icon-theme.d.ts +53 -0
- package/lib/types/icon-theme.spec.d.ts +1 -0
- package/lib/types/index.d.ts +1 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +12 -12
- package/scss/_about_dialog.scss +2 -2
- package/scss/_icon.scss +18 -0
- package/scss/_window.scss +7 -0
- package/src/adw-action-rows.spec.ts +20 -0
- package/src/adw-alert-dialog.spec.ts +15 -0
- package/src/adw-application-window.spec.ts +2 -1
- package/src/adw-window.spec.ts +22 -3
- package/src/bottom-sheet.spec.ts +11 -0
- package/src/canvas2d/canvas2d-bridge.ts +51 -1
- package/src/canvas2d-bridge.spec.ts +47 -0
- package/src/elements/adw-about-dialog.ts +4 -2
- package/src/elements/adw-action-row.ts +4 -0
- package/src/elements/adw-alert-dialog.ts +6 -0
- package/src/elements/adw-application-window.ts +9 -2
- package/src/elements/adw-bottom-sheet.ts +4 -0
- package/src/elements/adw-navigation-view.ts +19 -0
- package/src/elements/adw-switch-row.ts +4 -0
- package/src/elements/adw-window-features.ts +19 -4
- package/src/elements/adw-window.ts +2 -2
- package/src/elements/gtk-application-window.ts +6 -0
- package/src/elements/gtk-image.ts +55 -24
- package/src/elements/gtk-scrolled-window.ts +37 -1
- package/src/elements/gtk-window.ts +82 -3
- package/src/gobject-door.spec.ts +44 -0
- package/src/gobject-elements.ts +43 -2
- package/src/gtk-scrolled-window.spec.ts +74 -0
- package/src/gtk-window.spec.ts +97 -1
- package/src/icon-registry.ts +13 -7
- package/src/icon-theme.spec.ts +182 -0
- package/src/icon-theme.ts +178 -0
- package/src/index.ts +3 -0
- package/src/navigation-view.spec.ts +26 -0
- package/src/shared-tree-builder.ts +2 -1
- package/src/styles.generated.ts +1 -1
- package/src/test.browser.mts +5 -0
package/src/icon-registry.ts
CHANGED
|
@@ -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))
|
|
134
|
-
|
|
135
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
|
@@ -429,4 +429,30 @@ export const AdwNavigationViewTest = async () => {
|
|
|
429
429
|
host.remove();
|
|
430
430
|
});
|
|
431
431
|
});
|
|
432
|
+
|
|
433
|
+
await describe('<adw-navigation-view> GJS-spelled API and declared signals', async () => {
|
|
434
|
+
await it('push_by_tag and get_visible_page_tag follow the camelCase members', () => {
|
|
435
|
+
const { adapter, view, host, page } = mountView();
|
|
436
|
+
adapter.add(page('root'));
|
|
437
|
+
adapter.add(page('detail'));
|
|
438
|
+
expect(view.push_by_tag('detail')).toBe(true);
|
|
439
|
+
expect(view.get_visible_page_tag()).toBe('detail');
|
|
440
|
+
host.remove();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
await it('connects `pushed` and `notify::visible-page`', () => {
|
|
444
|
+
const { adapter, view, host, page } = mountView();
|
|
445
|
+
adapter.add(page('root'));
|
|
446
|
+
adapter.add(page('detail'));
|
|
447
|
+
const api = view as unknown as { connect(s: string, h: () => void): number };
|
|
448
|
+
let pushed = 0;
|
|
449
|
+
let visible = 0;
|
|
450
|
+
api.connect('pushed', () => pushed++);
|
|
451
|
+
api.connect('notify::visible-page', () => visible++);
|
|
452
|
+
view.push_by_tag('detail');
|
|
453
|
+
expect(pushed).toBe(1);
|
|
454
|
+
expect(visible).toBeGreaterThan(0);
|
|
455
|
+
host.remove();
|
|
456
|
+
});
|
|
457
|
+
});
|
|
432
458
|
};
|
|
@@ -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,
|