@gjsify/adwaita-web 0.64.12 → 0.64.13

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.
@@ -0,0 +1,193 @@
1
+ // A `Gtk.Label`'s markup DRAWN — the flat styled runs of `@gjsify/adwaita-core`'s
2
+ // `labelMarkupRender` as DOM nodes, and the `<a href>` elements beside them.
3
+ //
4
+ // WHY THIS IS NOT `pango-markup.ts`. That file walks the parse TREE and builds one element
5
+ // per element, which is the obvious mapping and the right one for a surface that only
6
+ // shows markup (`<adw-banner>`, `<adw-alert-dialog>`). A label additionally has LINKS,
7
+ // whose ranges are recorded against the display TEXT and may cross the tree's shape, and
8
+ // it shares its derivation with the NativeScript port, whose `FormattedString` has no tree
9
+ // to walk. So the label draws the RUNS; core's `label-markup.ts` header carries why they
10
+ // are flat. The two files share the parts that must not drift: {@link TAG_STYLES} (which
11
+ // element draws which pango tag) and {@link applySpanAttributes} (the `<span>` attribute
12
+ // whitelist), both imported from `pango-markup.ts` rather than restated.
13
+ //
14
+ // NO `innerHTML`, SAME AS EVER (`check-adwaita-no-markup-sinks.mjs`): every node here is
15
+ // `createElement` and every character is a text node, so a string the label carries can
16
+ // never become an element the browser parses.
17
+ //
18
+ // THE `<a>` IS A REAL LINK AND STILL GOES THROUGH THE SIGNAL. `href` is set, because an
19
+ // anchor without one is not a link to the keyboard, to the accessibility tree or to the
20
+ // browser's own "copy link address" — all of which GTK's link node has. The click is then
21
+ // `preventDefault`ed and `activate-link` is dispatched instead, because GTK does NOT
22
+ // navigate the window the label sits in: `gtk_label_activate_link` hands the URI to the
23
+ // desktop, which opens it elsewhere and leaves the window standing. `<gtk-link-button>`
24
+ // answers the same question the same way (`window.open(uri, '_blank', 'noopener')`), and
25
+ // the two must not disagree about what following a link does.
26
+ //
27
+ // WHERE A `<span>` ATTRIBUTE LOSES TO A TAG — a DECLARED divergence. Pango resolves a
28
+ // collision between `<span weight="light">` and `<b>` by NESTING: the inner one wins. The
29
+ // runs cannot say which was inner, because {@link LabelMarkupStyle} records the tags as
30
+ // booleans and the `<span>`s as a separate stack, so this file picks one order and states
31
+ // it: the `<span>`s are the OUTER elements and the tags the inner ones, i.e. a tag always
32
+ // wins. It is also the better of the two for `<big>`/`<small>`, whose `font-size: larger`
33
+ // is RELATIVE: nested inside a `<span size="12pt">` it multiplies that size, which is what
34
+ // pango does with a scale attribute beside an absolute one. The corpus this was measured
35
+ // against (155 labels, `adwaita-core`'s `label.ts` header) has no `<span>` at all.
36
+ //
37
+ // Reference: packages/web/adwaita-core/src/label-markup.ts (the runs, the links, the
38
+ // `::activate-link` rule)
39
+ // Reference: refs/gtk/gtk/gtklabel.c (the `link` CSS class, the per-link tooltip,
40
+ // gtk_label_activate_link)
41
+ // Copyright (c) The GTK Team. LGPLv2.1+.
42
+
43
+ import {
44
+ stripMnemonic,
45
+ type LabelMarkupRender,
46
+ type LabelMarkupRun,
47
+ type LabelMarkupStyle,
48
+ } from '@gjsify/adwaita-core';
49
+
50
+ import { applySpanAttributes, TAG_STYLES } from './pango-markup.js';
51
+
52
+ /**
53
+ * Which link an `<a>` draws, as its index into {@link LabelMarkupRender.links}.
54
+ *
55
+ * On the attribute and not in a JS-side map, because the element re-reads it to paint
56
+ * `visited` and to answer a click: one link may be drawn as SEVERAL anchors (a link whose
57
+ * text changes style mid-way is several runs), and the index is what ties them back
58
+ * together.
59
+ */
60
+ export const LABEL_LINK_ATTRIBUTE = 'data-gtk-link';
61
+
62
+ /** The pango tags {@link buildRun} nests inside a run's `<span>`s, innermost last. */
63
+ const STYLE_TAGS: readonly (readonly [keyof LabelMarkupStyle, string])[] = [
64
+ ['monospace', 'tt'],
65
+ ['underline', 'u'],
66
+ ['strikethrough', 's'],
67
+ ['italic', 'i'],
68
+ ['bold', 'b'],
69
+ ];
70
+
71
+ /** One element of {@link TAG_STYLES}, with the CSS that table gives it. */
72
+ function tagElement(tag: string, doc: Document): HTMLElement {
73
+ const spec = TAG_STYLES[tag];
74
+ // Every tag this file names is in the table — it is the same closed set pango's parser
75
+ // accepts — so a miss is a programming error here, not an input the label can carry.
76
+ if (spec === undefined) throw new Error(`label markup: no element draws <${tag}>`);
77
+ const el = doc.createElement(spec.element);
78
+ if (spec.style !== undefined)
79
+ for (const [property, value] of Object.entries(spec.style)) el.style.setProperty(property, value);
80
+ return el;
81
+ }
82
+
83
+ /**
84
+ * One run as a chain of elements around its text, outermost returned.
85
+ *
86
+ * The chain is `<span>`s (outermost first, so the innermost attribute set wins per CSS
87
+ * property) and then the pango tags — see the header for why that order and not the
88
+ * reverse. A run with no style at all is a bare text node: a plain label keeps the "one
89
+ * text node, no elements" DOM shape `gtk-label.spec.ts` asserts for the XSS answer.
90
+ *
91
+ * `mnemonics` is `use-underline`, and it is applied to the run's TEXT rather than to the
92
+ * markup string: `gtk_label_set_markup_with_mnemonic` parses first and strips the marker
93
+ * out of the result, so an `_` inside a tag's attributes is not a mnemonic — the same
94
+ * order `labelDisplayText` follows for the text reading.
95
+ */
96
+ function buildRun(run: LabelMarkupRun, doc: Document, mnemonics: boolean): Node {
97
+ const chain: HTMLElement[] = [];
98
+
99
+ for (const attributes of run.style.spans) {
100
+ const el = doc.createElement('span');
101
+ applySpanAttributes(el, attributes);
102
+ chain.push(el);
103
+ }
104
+
105
+ // `<big>`/`<small>` are a COUNT, so they are that many nested elements: `font-size:
106
+ // larger` is relative, and two of them is pango's two scale factors multiplied.
107
+ const steps = run.style.sizeSteps;
108
+ for (let index = 0; index < Math.abs(steps); index += 1) chain.push(tagElement(steps > 0 ? 'big' : 'small', doc));
109
+
110
+ for (const [flag, tag] of STYLE_TAGS) if (run.style[flag]) chain.push(tagElement(tag, doc));
111
+ if (run.style.script !== null) chain.push(tagElement(run.style.script, doc));
112
+
113
+ const text = doc.createTextNode(mnemonics ? stripMnemonic(run.text) : run.text);
114
+ if (chain.length === 0) return text;
115
+
116
+ for (let index = 0; index < chain.length - 1; index += 1) chain[index]!.append(chain[index + 1]!);
117
+ chain[chain.length - 1]!.append(text);
118
+ return chain[0]!;
119
+ }
120
+
121
+ /**
122
+ * The `<a>` for the link at `index`, empty.
123
+ *
124
+ * `link` is GTK's own CSS class on the link node (gtklabel.c, `_link.scss` here);
125
+ * `<a class>`'s value rides beside it, which is what that attribute is FOR — GTK puts it
126
+ * on the same node. `title` is the per-link tooltip, and only when the markup gave one:
127
+ * an empty `title` attribute is a tooltip in some browsers.
128
+ */
129
+ function buildAnchor(render: LabelMarkupRender, index: number, doc: Document): HTMLAnchorElement {
130
+ const link = render.links[index]!;
131
+ const anchor = doc.createElement('a');
132
+ anchor.setAttribute(LABEL_LINK_ATTRIBUTE, String(index));
133
+ anchor.href = link.href;
134
+ anchor.classList.add('link');
135
+ if (link.cssClass !== null && link.cssClass !== '') anchor.classList.add(link.cssClass);
136
+ anchor.classList.toggle('visited', link.visited);
137
+ if (link.title !== null) anchor.title = link.title;
138
+ return anchor;
139
+ }
140
+
141
+ /**
142
+ * `render` as nodes, ready to be put into a label.
143
+ *
144
+ * CONSECUTIVE RUNS OF ONE LINK SHARE ONE `<a>`, so a link is one navigable element to the
145
+ * keyboard and one entry in the accessibility tree even when its text changes style
146
+ * part-way through — which is what GTK's single link node is. Runs of DIFFERENT links, or
147
+ * a run with none between them, close the anchor.
148
+ */
149
+ export function labelMarkupNodes(render: LabelMarkupRender, doc: Document, mnemonics = false): Node[] {
150
+ const nodes: Node[] = [];
151
+ let anchor: HTMLAnchorElement | null = null;
152
+ let anchored: number | null = null;
153
+
154
+ for (const run of render.runs) {
155
+ if (run.link !== anchored) {
156
+ anchor = run.link === null ? null : buildAnchor(render, run.link, doc);
157
+ anchored = run.link;
158
+ if (anchor !== null) nodes.push(anchor);
159
+ }
160
+ (anchor ?? { append: (node: Node) => nodes.push(node) }).append(buildRun(run, doc, mnemonics));
161
+ }
162
+
163
+ // A LINK WITH NO TEXT still has a node: `<a href="…"></a>` is a link GTK records and
164
+ // gives a range of zero length, and dropping it here would lose the only trace of it.
165
+ for (let index = 0; index < render.links.length; index += 1)
166
+ if (!render.runs.some((run) => run.link === index)) nodes.push(buildAnchor(render, index, doc));
167
+
168
+ return nodes;
169
+ }
170
+
171
+ /** Write `render` into `target`, replacing what was there. */
172
+ export function renderLabelMarkup(target: HTMLElement, render: LabelMarkupRender, mnemonics = false): void {
173
+ const doc = target.ownerDocument ?? document;
174
+ target.replaceChildren(...labelMarkupNodes(render, doc, mnemonics));
175
+ }
176
+
177
+ /** The `<a>` elements {@link labelMarkupNodes} put under `target`, in reading order. */
178
+ export function labelMarkupAnchors(target: HTMLElement): HTMLAnchorElement[] {
179
+ return [...target.querySelectorAll<HTMLAnchorElement>(`a[${LABEL_LINK_ATTRIBUTE}]`)];
180
+ }
181
+
182
+ /**
183
+ * The link index `anchor` draws, or `null` when it is not one of ours.
184
+ *
185
+ * Read back from the attribute rather than closed over, because a click arrives at an
186
+ * element and the handler has only the DOM to go on.
187
+ */
188
+ export function labelMarkupAnchorIndex(anchor: Element): number | null {
189
+ const raw = anchor.getAttribute(LABEL_LINK_ATTRIBUTE);
190
+ if (raw === null) return null;
191
+ const index = Number(raw);
192
+ return Number.isInteger(index) && index >= 0 ? index : null;
193
+ }
@@ -73,6 +73,17 @@ export const PangoMarkupTest = async () => {
73
73
  expect(target.querySelector('i > u')?.textContent).toBe('b');
74
74
  });
75
75
 
76
+ await it("sub and sup carry pango's 1 / 1.2 font_scale fallback", () => {
77
+ const { target } = render('<sub>d</sub><sup>e</sup>');
78
+ // Compared as a computed ratio: browsers serialise the `calc()` differently
79
+ // (Firefox gives `calc(0.833333em)`).
80
+ const base = Number.parseFloat(getComputedStyle(target).fontSize);
81
+ for (const el of target.children) {
82
+ const ratio = Number.parseFloat(getComputedStyle(el).fontSize) / base;
83
+ expect(Math.abs(ratio - 1 / 1.2)).toBeLessThan(0.01);
84
+ }
85
+ });
86
+
76
87
  await it("<markup> draws its children — it is pango's implicit root, not a box", async () => {
77
88
  const { target } = render('<markup><b>x</b></markup>');
78
89
  expect(target.querySelector('b')?.textContent).toBe('x');
@@ -51,7 +51,7 @@ import { parsePangoMarkup, type PangoMarkupNode } from '@gjsify/adwaita-core';
51
51
  * presentational UA rule covers it. Every entry is a CLOSED set: a tag missing here is a
52
52
  * parse error, so there is no default branch to fall through.
53
53
  */
54
- const TAG_STYLES: Readonly<Record<string, { element: string; style?: Readonly<Record<string, string>> }>> = {
54
+ export const TAG_STYLES: Readonly<Record<string, { element: string; style?: Readonly<Record<string, string>> }>> = {
55
55
  b: { element: 'b' },
56
56
  i: { element: 'i' },
57
57
  u: { element: 'u' },
@@ -59,8 +59,11 @@ const TAG_STYLES: Readonly<Record<string, { element: string; style?: Readonly<Re
59
59
  tt: { element: 'span', style: { 'font-family': 'monospace' } },
60
60
  big: { element: 'span', style: { 'font-size': 'larger' } },
61
61
  small: { element: 'span', style: { 'font-size': 'smaller' } },
62
- sub: { element: 'sub' },
63
- sup: { element: 'sup' },
62
+ // pango's `font_scale` for `<sub>`/`<sup>` falls back to `1 / 1.2` when the font's OpenType
63
+ // metrics carry no script size (pango/itemize.c, `PANGO_FONT_SCALE_SUBSCRIPT`/`SUPERSCRIPT`);
64
+ // spelled out so it does not depend on each UA's `font-size: smaller` ratio.
65
+ sub: { element: 'sub', style: { 'font-size': 'calc(1em / 1.2)' } },
66
+ sup: { element: 'sup', style: { 'font-size': 'calc(1em / 1.2)' } },
64
67
  markup: { element: 'span', style: { display: 'contents' } },
65
68
  span: { element: 'span' },
66
69
  };
@@ -140,8 +143,14 @@ function buildNode(node: PangoMarkupNode, doc: Document): Node | null {
140
143
  return el;
141
144
  }
142
145
 
143
- /** Write the recognised `<span>` attributes onto `el` as style declarations. */
144
- function applySpanAttributes(el: HTMLElement, attributes: Readonly<Record<string, string>>): void {
146
+ /**
147
+ * Write the recognised `<span>` attributes onto `el` as style declarations.
148
+ *
149
+ * Exported for `label-markup.ts`, which draws a `Gtk.Label`'s markup from flat RUNS rather
150
+ * than from this file's tree: the whitelist and its value tables are the part the two must
151
+ * share, so an attribute is added once and both renderers gain it.
152
+ */
153
+ export function applySpanAttributes(el: HTMLElement, attributes: Readonly<Record<string, string>>): void {
145
154
  // Additive, so `<span underline="single" strikethrough="true">` draws BOTH —
146
155
  // `text-decoration` is a shorthand that would drop the first one.
147
156
  const decorations: string[] = [];
@@ -98,13 +98,16 @@ export const AdwSplitButtonTest = async () => {
98
98
  }
99
99
 
100
100
  // The element re-emits libadwaita's notify sequence verbatim, so the
101
- // ORDER — cleared slots first, then the set one — is DOM-observable.
101
+ // ORDER — the property SET first, then the slots that were filled
102
+ // before it — is DOM-observable.
102
103
  expect(seen).toStrictEqual([...step.notified]);
103
104
  expect(el.label).toBe(step.label);
104
105
  expect(el.iconName).toBe(step.iconName);
105
- // An empty label is SET but paints nothing — the `label[0]`
106
- // distinction, on screen.
107
- const painted = step.mode === 'icon' ? 'icon' : (step.label ?? '').length > 0 ? 'label' : 'empty';
106
+ // What is PAINTED follows the two readbacks, not the content mode: an
107
+ // empty label is SET but paints nothing (the `label[0]` distinction),
108
+ // and an icon name set to `''` leaves the internal GtkImage EMPTY, so
109
+ // an icon-mode button with a NULL readback paints nothing either.
110
+ const painted = step.iconName !== null ? 'icon' : (step.label ?? '').length > 0 ? 'label' : 'empty';
108
111
  expect(renderedMode(el)).toBe(painted);
109
112
  expect(rootStyleClasses(el)).toStrictEqual([...step.styleClasses]);
110
113
  }