@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.
- package/README.md +42 -4
- package/dist/adwaita-web.css +9 -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 +1 -0
- package/lib/types/elements/adw-bottom-sheet.d.ts +1 -0
- package/lib/types/elements/adw-navigation-view.d.ts +1 -0
- package/lib/types/elements/adw-switch-row.d.ts +1 -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/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/namespace/gtk.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/src/adw-window.spec.ts +7 -0
- package/src/application.spec.ts +3 -3
- 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 +6 -0
- package/src/elements/adw-bottom-sheet.ts +6 -0
- package/src/elements/adw-navigation-view.ts +6 -0
- package/src/elements/adw-switch-row.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-controls.ts +1 -1
- package/src/elements/gtk-window.ts +1 -1
- package/src/file-chooser-host.ts +2 -0
- package/src/file-chooser.spec.ts +3 -5
- package/src/gobject-door.spec.ts +44 -0
- package/src/gobject-elements.ts +22 -0
- package/src/gtk-font-dialog-button.spec.ts +3 -1
- package/src/gtk-scrolled-window.spec.ts +77 -0
- 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/namespace/gtk.ts +2 -0
- package/src/navigation-view.spec.ts +4 -4
- package/src/shared-tree-builder.ts +2 -1
- package/src/styles.generated.ts +1 -1
- package/src/test.browser.mts +5 -0
package/src/gobject-elements.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
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
|
package/src/namespace/gtk.ts
CHANGED
|
@@ -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,
|