@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.
@@ -8,11 +8,21 @@
8
8
  // mounted through `mountSharedTree` arrives as a TAG with a `label` attribute, and without
9
9
  // an element that reads it the text never reached the screen.
10
10
  //
11
- // THE TEXT IS WRITTEN AS TEXT. `use-markup` is honoured by REDUCING the markup to its
12
- // plain text (`labelDisplayText` in `@gjsify/adwaita-core`, the reduction the NativeScript
13
- // label shares), never by handing it to `innerHTML`, which would run whatever the string
14
- // carries. GTK draws the bold; this port shows the word. `use-underline` strips the
15
- // mnemonic marker and binds no key.
11
+ // THE TEXT IS WRITTEN AS NODES, NEVER AS MARKUP. `use-markup` is DRAWN: the string is
12
+ // parsed headlessly (`labelMarkupRender` in `@gjsify/adwaita-core`, the derivation the
13
+ // NativeScript label shares), and its styled runs become elements built with
14
+ // `createElement` — `../label-markup.js`. Nothing is ever handed to `innerHTML`, which
15
+ // would run whatever the string carries; markup that does not PARSE falls back to the raw
16
+ // string as text, which is the C's own fallback. `use-underline` strips the mnemonic
17
+ // marker and binds no key.
18
+ //
19
+ // `<a href>` IS A LINK HERE, with `::activate-link` behind it. The anchors carry GTK's
20
+ // `link` CSS class and their markup's `title` as a tooltip, the widget gains the `link`
21
+ // class and `has-tooltip` the way `gtk_label_set_markup` and
22
+ // `gtk_label_ensure_has_tooltip` set them, and a click emits the cancelable
23
+ // `activate-link` before anything is opened — `<gtk-link-button>`'s pattern, because the
24
+ // two must not disagree about what following a link does. `visited` is carried across a
25
+ // re-render by URI, as the C does.
16
26
  //
17
27
  // `xalign` IS WHERE THE TEXT SITS IN THE LABEL'S BOX, `justify` how its LINES align with
18
28
  // each other — two properties, as in GTK. `xalign` is a continuum and CSS alignment has
@@ -59,9 +69,14 @@
59
69
  // Copyright (c) The GTK Team, GNOME contributors. LGPLv2.1+.
60
70
 
61
71
  import {
72
+ labelActivateLink,
62
73
  labelDisplayText,
63
74
  labelEffectiveLines,
64
75
  labelEllipsizeOverflowValue,
76
+ labelHasLinks,
77
+ labelLinksHaveTooltip,
78
+ labelLinkVisited,
79
+ labelMarkupRender,
65
80
  labelWidthCharsExtent,
66
81
  labelYalignAlignItems,
67
82
  normalizeLabelEllipsize,
@@ -74,10 +89,14 @@ import {
74
89
  normalizeLabelYalign,
75
90
  type LabelEllipsizeMode,
76
91
  type LabelJustification,
92
+ type LabelLink,
93
+ type LabelMarkupRender,
77
94
  type LabelWrapMode,
78
95
  } from '@gjsify/adwaita-core';
79
96
 
80
97
  import { JUSTIFY_TEXT_ALIGN } from './justification.js';
98
+ import { LABEL_LINK_ATTRIBUTE, labelMarkupAnchorIndex, labelMarkupAnchors, labelMarkupNodes } from '../label-markup.js';
99
+ import type { DispatchedSignals } from '../signals.js';
81
100
  import { WidgetElement } from '../widget-element.js';
82
101
 
83
102
  /** The attributes that carry a property — also the `notify::` roster. */
@@ -108,12 +127,51 @@ export class GtkLabel extends WidgetElement {
108
127
  * gets one text node").
109
128
  */
110
129
  private _wrapSpan: HTMLSpanElement | null = null;
130
+
131
+ /**
132
+ * The box the markup's runs are drawn in while the label is a SINGLE LINE — the case
133
+ * {@link _wrapSpan} does not cover, and the runs still may not go without a box.
134
+ *
135
+ * THE RUNS MAY NOT BE THE LABEL'S OWN CHILDREN. `gtk-label` is a flex container (the
136
+ * `xalign` spacers, `_labels.scss`), so each node directly under it is a FLEX ITEM —
137
+ * and a flex item is not text: `vertical-align` does not apply to one, so `<sup>` and
138
+ * `<sub>` stop being raised and lowered, and `align-items` centres each run on its own
139
+ * rather than the line sharing one baseline. A plain label never showed this because a
140
+ * bare text node is ONE anonymous item; a run list is as many items as it has runs. So
141
+ * the runs get a box and the box is the item. `null` while plain text is drawn, so a
142
+ * label without markup keeps the single text node `childElementCount` asserts on.
143
+ */
144
+ private _runSpan: HTMLSpanElement | null = null;
111
145
  private _resizes: ResizeObserver | null = null;
112
146
 
113
147
  /** Whether `_sync` last wrote `min-width` / `max-width` itself, and so may clear them. */
114
148
  private _wroteMinWidth = false;
115
149
  private _wroteMaxWidth = false;
116
150
 
151
+ /**
152
+ * The `<a href>` elements the label currently draws, in reading order.
153
+ *
154
+ * HELD ACROSS RENDERS because `visited` is: `gtk_label_set_markup` carries it over by
155
+ * URI (`labelLinksInheritVisited`), so a followed link stays followed when the text
156
+ * around it is rewritten. Empty whenever the markup has no links or does not parse.
157
+ */
158
+ private _links: readonly LabelLink[] = [];
159
+ private _wired = false;
160
+
161
+ /**
162
+ * The inputs of the run list currently in the DOM, or `null` when plain text is.
163
+ *
164
+ * `_render` runs on EVERY observed attribute — `xalign` included — and rebuilding the
165
+ * run elements each time would drop a selection and the focus inside a link for a
166
+ * change that never touched the text. The plain-text path compares `textContent`; a
167
+ * list of elements has no such single string to compare against, so the two inputs
168
+ * that produced it stand in for it.
169
+ */
170
+ private _drawn: string | null = null;
171
+
172
+ /** The GTK signals this element dispatches, each with the DOM event it arrives as (ADR 0093). */
173
+ static readonly signals: DispatchedSignals = { 'activate-link': 'activate-link' };
174
+
117
175
  static get observedAttributes() {
118
176
  return [...PROPERTY_ATTRIBUTES];
119
177
  }
@@ -279,6 +337,7 @@ export class GtkLabel extends WidgetElement {
279
337
  }
280
338
 
281
339
  connectedCallback() {
340
+ this._wire();
282
341
  this._render();
283
342
  this._resizes = new ResizeObserver(() => this._remeasure());
284
343
  this._resizes.observe(this);
@@ -318,15 +377,28 @@ export class GtkLabel extends WidgetElement {
318
377
  }
319
378
 
320
379
  private _render(): void {
321
- // `textContent`, the whole of the XSS answer: whatever the label holds, the DOM
322
- // gets one text node (wrapped in {@link _wrapSpan} while {@link wrap} or
323
- // {@link ellipsize} needs a box of its own — see `_renderSpan`).
380
+ // THE MARKUP IS DRAWN WHEN IT PARSES, and `null` here is both cases where it is
381
+ // not: `use-markup` unset, and a string pango itself rejects — for which GTK
382
+ // shows the raw text (`labelDisplayText`, the C fallback). Either way no string
383
+ // becomes markup the browser parses: the run path builds every node with
384
+ // `createElement` (`../label-markup.js`) and the text path is `textContent`.
385
+ const render = this.useMarkup ? labelMarkupRender(this.label, this._links) : null;
386
+ this._links = render?.links ?? [];
387
+ const drawn = render === null ? null : `${this.useUnderline ? '1' : '0'}\u0000${this.label}`;
388
+
324
389
  const text = this.getText();
325
390
  const wrap = this.wrap;
326
391
  const ellipsize = this.ellipsize;
327
392
  const clamped = ellipsize !== 'none';
328
- if (wrap || clamped) this._renderSpan(text, clamped);
329
- else this._renderSingleLine(text);
393
+ if (wrap || clamped) this._renderSpan(text, render, drawn, clamped);
394
+ else this._renderSingleLine(text, render, drawn);
395
+
396
+ // `gtk_label_set_markup`'s two side effects on the WIDGET (gtklabel.c:3896,
397
+ // gtk_label_ensure_has_tooltip): the class libadwaita's stylesheet selects on, and
398
+ // the flag that says a tooltip may be asked for. The tooltip TEXT is per link and
399
+ // sits on the anchor itself, which is where a browser reads one from.
400
+ this.classList.toggle('link', labelHasLinks(this._links));
401
+ this.toggleAttribute('has-tooltip', labelLinksHaveTooltip(this._links));
330
402
 
331
403
  this.style.setProperty('--gtk-label-xalign', String(this.xalign));
332
404
  this.style.textAlign = JUSTIFY_TEXT_ALIGN[this.justify];
@@ -373,10 +445,118 @@ export class GtkLabel extends WidgetElement {
373
445
  this._wroteMaxWidth = extent.maxCh !== null;
374
446
  }
375
447
 
376
- private _renderSingleLine(text: string): void {
377
- this._wrapSpan?.remove();
378
- this._wrapSpan = null;
379
- if (this.textContent !== text) this.textContent = text;
448
+ private _renderSingleLine(text: string, render: LabelMarkupRender | null, drawn: string | null): void {
449
+ if (this._wrapSpan) {
450
+ this._wrapSpan.remove();
451
+ this._wrapSpan = null;
452
+ // The box the runs were in is gone, so what `_drawn` describes is gone with it.
453
+ this._drawn = null;
454
+ }
455
+
456
+ // PLAIN TEXT GOES STRAIGHT IN, runs may not — see {@link _runSpan}. The text node
457
+ // is one anonymous flex item whichever way the label is aligned; a run list under
458
+ // the same parent would be one flex item PER RUN.
459
+ if (render === null) {
460
+ if (this._runSpan) {
461
+ this._runSpan.remove();
462
+ this._runSpan = null;
463
+ this._drawn = null;
464
+ }
465
+ this._draw(this, text, render, drawn);
466
+ return;
467
+ }
468
+
469
+ if (!this._runSpan) {
470
+ this.textContent = '';
471
+ this._runSpan = document.createElement('span');
472
+ this._runSpan.className = 'adw-label-runs';
473
+ this.appendChild(this._runSpan);
474
+ // A NEW, EMPTY box: whatever `_drawn` described was in the old one.
475
+ this._drawn = null;
476
+ }
477
+ this._draw(this._runSpan, text, render, drawn);
478
+ }
479
+
480
+ /**
481
+ * `render`'s runs — or `text` when there are none — into `target`.
482
+ *
483
+ * A PLAIN LABEL KEEPS ITS SINGLE TEXT NODE: `textContent` is still the whole of the
484
+ * write whenever there is no markup to draw, so the shape `gtk-label.spec.ts` asserts
485
+ * for the XSS answer (no elements at all) is unchanged for every label that does not
486
+ * ask for markup.
487
+ */
488
+ private _draw(target: HTMLElement, text: string, render: LabelMarkupRender | null, drawn: string | null): void {
489
+ if (render === null) {
490
+ this._drawn = null;
491
+ if (target.textContent !== text) target.textContent = text;
492
+ return;
493
+ }
494
+ if (this._drawn === drawn) return;
495
+ this._drawn = drawn;
496
+ target.replaceChildren(...labelMarkupNodes(render, target.ownerDocument, this.useUnderline));
497
+ }
498
+
499
+ /**
500
+ * ONE delegated `click` listener, added once and never removed: the anchors are rebuilt
501
+ * on every render, so a listener per anchor would have to be re-added with them, and
502
+ * the element is the node they are always under.
503
+ */
504
+ private _wire(): void {
505
+ if (this._wired) return;
506
+ this._wired = true;
507
+ this.addEventListener('click', (event) => this._click(event));
508
+ }
509
+
510
+ private _click(event: MouseEvent): void {
511
+ const target = event.target;
512
+ const anchor = target instanceof Element ? target.closest(`a[${LABEL_LINK_ATTRIBUTE}]`) : null;
513
+ if (anchor === null) return;
514
+ const index = labelMarkupAnchorIndex(anchor);
515
+ const link = index === null ? undefined : this._links[index];
516
+ if (index === null || link === undefined) return;
517
+
518
+ // THE DEFAULT IS CANCELLED, every time. The anchor carries a real `href` — it has
519
+ // to, or it is not a link to the keyboard, to the accessibility tree or to "copy
520
+ // link address" — and a browser would therefore NAVIGATE this window. GTK does
521
+ // not: `gtk_label_activate_link` hands the URI to the desktop and leaves the
522
+ // window standing. So the signal runs in the navigation's place.
523
+ event.preventDefault();
524
+ const visited = labelActivateLink(link.href, {
525
+ // `GTK_IS_WINDOW (gtk_widget_get_root (widget))` — a label with no window
526
+ // above it launches nothing. A document with no `defaultView` (one built by
527
+ // `DOMParser`, a template's content) is this port's version of that: there is
528
+ // no window to open anything from.
529
+ rooted: this.isConnected && this.ownerDocument.defaultView !== null,
530
+ emit: () =>
531
+ !this.dispatchEvent(
532
+ new CustomEvent('activate-link', { bubbles: true, cancelable: true, detail: { uri: link.href } }),
533
+ ),
534
+ launch: (uri) => this.launch(uri),
535
+ });
536
+ if (!visited) return;
537
+ this._links = labelLinkVisited(this._links, index);
538
+ this._paintVisited();
539
+ }
540
+
541
+ /**
542
+ * The launcher call, on its own method so a caller (or a spec) can stand in for the
543
+ * window the desktop would have opened — `<gtk-link-button>`'s `launch`, and the same
544
+ * door, because the two must not disagree about what following a link does.
545
+ */
546
+ protected launch(uri: string): void {
547
+ window.open(uri, '_blank', 'noopener');
548
+ }
549
+
550
+ /**
551
+ * `visited` onto the anchors already in the DOM, rather than through a re-render: the
552
+ * link that was just clicked holds the focus, and rebuilding the runs under it would
553
+ * take that away for a change of one class.
554
+ */
555
+ private _paintVisited(): void {
556
+ for (const anchor of labelMarkupAnchors(this)) {
557
+ const index = labelMarkupAnchorIndex(anchor);
558
+ if (index !== null) anchor.classList.toggle('visited', this._links[index]?.visited === true);
559
+ }
380
560
  }
381
561
 
382
562
  /**
@@ -415,15 +595,25 @@ export class GtkLabel extends WidgetElement {
415
595
  * min-content/max-content gap, the exact shrink-to-fit collapse the wrapped case above
416
596
  * was already written for. One code path now, for both.
417
597
  */
418
- private _renderSpan(text: string, clamped: boolean): void {
598
+ private _renderSpan(text: string, render: LabelMarkupRender | null, drawn: string | null, clamped: boolean): void {
599
+ // THIS BOX SUPERSEDES THE RUN BOX. `.adw-label-text` is `display: block`, so the
600
+ // runs inside it are inline boxes and the flex-item problem {@link _runSpan}
601
+ // answers cannot arise — one box, not two nested ones.
602
+ if (this._runSpan) {
603
+ this._runSpan.remove();
604
+ this._runSpan = null;
605
+ this._drawn = null;
606
+ }
419
607
  if (!this._wrapSpan) {
420
608
  this.textContent = '';
421
609
  this._wrapSpan = document.createElement('span');
422
610
  this._wrapSpan.className = 'adw-label-text';
423
611
  this.appendChild(this._wrapSpan);
612
+ // A NEW, EMPTY box: whatever `_drawn` described was in the old one.
613
+ this._drawn = null;
424
614
  }
425
615
  this._wrapSpan.classList.toggle('adw-label-clamp', clamped);
426
- if (this._wrapSpan.textContent !== text) this._wrapSpan.textContent = text;
616
+ this._draw(this._wrapSpan, text, render, drawn);
427
617
  this._remeasure();
428
618
  }
429
619
  }