@gjsify/adwaita-web 0.51.0 → 0.52.0

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.
Files changed (103) hide show
  1. package/dist/adwaita-web.css +255 -27
  2. package/dist/adwaita-web.css.map +1 -1
  3. package/lib/types/blueprint-layout.spec.d.ts +1 -0
  4. package/lib/types/blueprint-markup.spec.d.ts +4 -0
  5. package/lib/types/blueprint-tree.spec.d.ts +1 -0
  6. package/lib/types/elements/adw-bottom-sheet.d.ts +31 -13
  7. package/lib/types/elements/adw-carousel.d.ts +5 -0
  8. package/lib/types/elements/adw-header-bar.d.ts +15 -0
  9. package/lib/types/elements/adw-inline-view-switcher.d.ts +1 -0
  10. package/lib/types/elements/adw-navigation-view.d.ts +1 -0
  11. package/lib/types/elements/adw-sidebar.d.ts +10 -0
  12. package/lib/types/elements/adw-tab-view.d.ts +1 -0
  13. package/lib/types/elements/adw-toggle-group.d.ts +11 -0
  14. package/lib/types/elements/gtk-action-bar.d.ts +19 -0
  15. package/lib/types/elements/gtk-box.d.ts +17 -0
  16. package/lib/types/elements/gtk-button.d.ts +2 -0
  17. package/lib/types/elements/gtk-label.d.ts +122 -0
  18. package/lib/types/gio-menu.spec.d.ts +1 -0
  19. package/lib/types/gtk-box.spec.d.ts +1 -0
  20. package/lib/types/gtk-label.spec.d.ts +1 -0
  21. package/lib/types/gtk-value-doors.spec.d.ts +1 -0
  22. package/lib/types/index.d.ts +4 -1
  23. package/lib/types/namespace/gio.d.ts +1 -0
  24. package/lib/types/namespace/gtk.d.ts +5 -0
  25. package/lib/types/shared-tree-builder.d.ts +64 -0
  26. package/lib/types/source-view/asm6502.d.ts +1 -20
  27. package/lib/types/source-view/index.d.ts +3 -2
  28. package/lib/types/source-view/theme.d.ts +1 -1
  29. package/lib/types/string-list-slot.d.ts +4 -0
  30. package/lib/types/styles.generated.d.ts +1 -1
  31. package/lib/types/tags.spec.d.ts +1 -0
  32. package/lib/types/value-lists.spec.d.ts +1 -0
  33. package/package.json +8 -6
  34. package/scss/_action_bar.scss +51 -0
  35. package/scss/_banner.scss +8 -1
  36. package/scss/_bottom_sheet.scss +57 -0
  37. package/scss/_box.scss +23 -0
  38. package/scss/_button.scss +29 -7
  39. package/scss/_button_content.scss +6 -1
  40. package/scss/_button_row.scss +5 -2
  41. package/scss/_carousel.scss +6 -0
  42. package/scss/_labels.scss +126 -12
  43. package/scss/_navigation_split_view.scss +18 -0
  44. package/scss/_overlay_split_view.scss +5 -0
  45. package/scss/_reset.scss +29 -4
  46. package/scss/_split_button.scss +5 -2
  47. package/scss/_status_page.scss +31 -17
  48. package/scss/_theme.scss +11 -3
  49. package/scss/_toolbar_view.scss +10 -0
  50. package/scss/_variables.scss +1 -0
  51. package/scss/_widget.scss +55 -0
  52. package/scss/_window_title.scss +9 -0
  53. package/scss/adwaita-skin.scss +8 -2
  54. package/src/adw-banner.spec.ts +16 -0
  55. package/src/adw-button-row.spec.ts +8 -0
  56. package/src/adw-header-bar.spec.ts +17 -0
  57. package/src/blueprint-layout.spec.ts +223 -0
  58. package/src/blueprint-markup.spec.ts +155 -0
  59. package/src/blueprint-tree.spec.ts +174 -0
  60. package/src/bottom-sheet.spec.ts +166 -1
  61. package/src/chrome.spec.ts +5 -2
  62. package/src/elements/adw-bottom-sheet.ts +166 -59
  63. package/src/elements/adw-carousel.ts +10 -5
  64. package/src/elements/adw-clamp.ts +12 -0
  65. package/src/elements/adw-combo-row.ts +13 -4
  66. package/src/elements/adw-header-bar.ts +46 -3
  67. package/src/elements/adw-inline-view-switcher.ts +11 -0
  68. package/src/elements/adw-navigation-view.ts +10 -0
  69. package/src/elements/adw-sidebar.ts +22 -0
  70. package/src/elements/adw-spin-row.ts +44 -5
  71. package/src/elements/adw-status-page.ts +12 -6
  72. package/src/elements/adw-tab-view.ts +20 -3
  73. package/src/elements/adw-toggle-group.ts +45 -4
  74. package/src/elements/adw-toolbar-view.ts +9 -0
  75. package/src/elements/gtk-action-bar.ts +90 -0
  76. package/src/elements/gtk-box.ts +87 -0
  77. package/src/elements/gtk-button.ts +60 -13
  78. package/src/elements/gtk-drop-down.ts +10 -1
  79. package/src/elements/gtk-label.ts +415 -0
  80. package/src/gio-menu.spec.ts +125 -0
  81. package/src/globals.d.ts +7 -0
  82. package/src/gtk-box.spec.ts +168 -0
  83. package/src/gtk-button.spec.ts +123 -1
  84. package/src/gtk-label.spec.ts +595 -0
  85. package/src/gtk-value-doors.spec.ts +104 -0
  86. package/src/index.ts +14 -1
  87. package/src/keyboard-operable.spec.ts +2 -1
  88. package/src/namespace/gio.ts +19 -0
  89. package/src/namespace/gtk.ts +14 -0
  90. package/src/shared-tree-builder.ts +268 -0
  91. package/src/shared-trees.spec.ts +296 -32
  92. package/src/source-view/adw-source-view.ts +3 -3
  93. package/src/source-view/asm6502.ts +11 -191
  94. package/src/source-view/index.ts +17 -11
  95. package/src/source-view/theme.ts +16 -16
  96. package/src/split-button.spec.ts +7 -0
  97. package/src/string-list-slot.ts +21 -0
  98. package/src/styles.generated.ts +1 -1
  99. package/src/tags.spec.ts +51 -0
  100. package/src/test.browser.mts +18 -0
  101. package/src/value-lists.spec.ts +93 -0
  102. package/lib/types/source-view/hex.d.ts +0 -21
  103. package/src/source-view/hex.ts +0 -37
@@ -0,0 +1,415 @@
1
+ // <gtk-label> — a run of text, and the node libadwaita's typography classes are written
2
+ // for (`.title-1` … `.caption`, `.dimmed`, `_labels.scss`).
3
+ //
4
+ // NAMED FOR THE LIBRARY THAT OWNS THE GTYPE (ADR 0034 clause 1): libadwaita ships no label
5
+ // type, so there is no `adw-` spelling. `_labels.scss` used to argue there should be no
6
+ // element at all — a `<span>` with a class does everything a hand-written page needs. What
7
+ // it cannot do is be the target of an authored tree: a Blueprint `Gtk.Label { label: …; }`
8
+ // mounted through `mountSharedTree` arrives as a TAG with a `label` attribute, and without
9
+ // an element that reads it the text never reached the screen.
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.
16
+ //
17
+ // `xalign` IS WHERE THE TEXT SITS IN THE LABEL'S BOX, `justify` how its LINES align with
18
+ // each other — two properties, as in GTK. `xalign` is a continuum and CSS alignment has
19
+ // three keywords, so the box splits its free space between two spacers in the ratio
20
+ // `xalign : 1 − xalign` (`_labels.scss`), which is the C's `xalign * (width − text width)`
21
+ // to the pixel, and mirrors in RTL as `gtk_label` does. `justify` maps through Pango's own
22
+ // switch: LEFT and RIGHT are START and END of the text direction, FILL is start-aligned
23
+ // lines with inter-word justification.
24
+ //
25
+ // `ELLIPSIZE`, `WRAP-MODE`, `LINES`, `WIDTH-CHARS`, `MAX-WIDTH-CHARS` AND `YALIGN` are
26
+ // Pango's text-layout knobs, and each reaches a REAL CSS mechanism rather than being
27
+ // declared unreachable: `wrap-mode` is `word-break`/`overflow-wrap`, `width-chars`/
28
+ // `max-width-chars` are `min-width`/`max-width` in `ch` (`@gjsify/adwaita-core`'s
29
+ // `labelWidthCharsExtent`). `ellipsize` and `lines` are the SAME `-webkit-line-clamp` box
30
+ // (`.adw-label-clamp` in `_labels.scss`) WHENEVER `ellipsize` is active, `wrap` or not —
31
+ // MEASURED (a real `Gtk.Label`, allocated, gjs 1.88.1/gtk 4.22.5, `@gjsify/adwaita-core`'s
32
+ // `label.ts` header carries the numbers): `wrap` gates NOTHING once `ellipsize` is set,
33
+ // and an unset `lines` still caps at Pango's own default of ONE line, never "unlimited" —
34
+ // `labelEffectiveLines` is that whole derivation, shared with the NativeScript port. A
35
+ // single-line, non-ellipsized, non-wrapping label is the one case with no box at all: a
36
+ // bare text node, see `_renderSpan`'s doc comment for why it still needs the measurement
37
+ // pass the wrap-only case always needed. `yalign` is a DECLARED divergence past its three
38
+ // exact values (0, 0.5, 1): CSS `align-items` has three keywords and no ratio the way
39
+ // `flex-grow` gives `xalign` one — `_labels.scss`'s header says why a second spacer pair
40
+ // is not the fix; `labelYalignAlignItems` (`@gjsify/adwaita-core`) is the nearest-of-three
41
+ // zone map. `ellipsize` carries its own declared divergence at `start`/`middle`:
42
+ // `labelEllipsizeOverflowValue` (`@gjsify/adwaita-core`) draws every non-`none` mode as an
43
+ // end-ellipsis, the one truncating value CSS `text-overflow` and NativeScript's
44
+ // `textOverflow` both have.
45
+ //
46
+ // NOT HERE, and declared in `check-adwaita-element-properties.mjs`: `natural-wrap-mode`
47
+ // (a NATURAL-SIZE-REQUEST hint over a size-negotiation protocol this renderer does not
48
+ // run — a browser lays out once, it does not ask a widget for a preferred width first),
49
+ // `single-line-mode` (height pinned to one line's ascent+descent regardless of content —
50
+ // no CSS box does that without measuring the font, which a `<gtk-label>` never needs to:
51
+ // its own height already IS one line's whenever `wrap` is off) and the mnemonic machinery.
52
+ // `selectable` is only the ability to select; there is no caret or context menu.
53
+ //
54
+ // Reference: refs/gtk/gtk/gtklabel.c (properties, the `label` CSS name, the justify
55
+ // switch, get_default_widths, gtk_label_ensure_layout's width/height conditions)
56
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_labels.scss (`label {}`)
57
+ // Copyright (c) The GTK Team, GNOME contributors. LGPLv2.1+.
58
+
59
+ import {
60
+ labelDisplayText,
61
+ labelEffectiveLines,
62
+ labelEllipsizeOverflowValue,
63
+ labelWidthCharsExtent,
64
+ labelYalignAlignItems,
65
+ normalizeLabelEllipsize,
66
+ normalizeLabelJustify,
67
+ normalizeLabelLines,
68
+ normalizeLabelMaxWidthChars,
69
+ normalizeLabelWidthChars,
70
+ normalizeLabelWrapMode,
71
+ normalizeLabelXalign,
72
+ normalizeLabelYalign,
73
+ type LabelEllipsizeMode,
74
+ type LabelJustification,
75
+ type LabelWrapMode,
76
+ } from '@gjsify/adwaita-core';
77
+
78
+ /** The attributes that carry a property — also the `notify::` roster. */
79
+ const PROPERTY_ATTRIBUTES = [
80
+ 'label',
81
+ 'use-markup',
82
+ 'use-underline',
83
+ 'justify',
84
+ 'xalign',
85
+ 'yalign',
86
+ 'wrap',
87
+ 'wrap-mode',
88
+ 'ellipsize',
89
+ 'lines',
90
+ 'width-chars',
91
+ 'max-width-chars',
92
+ 'selectable',
93
+ ] as const;
94
+
95
+ /** `Gtk.Justification` as a CSS `text-align`, through Pango's switch in `gtklabel.c`. */
96
+ const JUSTIFY_TEXT_ALIGN: Record<LabelJustification, string> = {
97
+ left: 'start',
98
+ right: 'end',
99
+ center: 'center',
100
+ fill: 'justify',
101
+ };
102
+
103
+ export class GtkLabel extends HTMLElement {
104
+ /**
105
+ * The rendered text's own box — present while {@link wrap} is set (so it can be pinned
106
+ * to its measured wrap width, see `_remeasure`) OR while {@link ellipsize} is not
107
+ * `none` (so `overflow`/`text-overflow` have a box of their own to clip, rather than
108
+ * the label's flex container, which also holds the `xalign` spacer pseudo-elements).
109
+ * `null` the rest of the time so a single-line, non-ellipsized label keeps the plain
110
+ * text node `childElementCount` asserts on (`gtk-label.spec.ts`'s XSS note: "the DOM
111
+ * gets one text node").
112
+ */
113
+ private _wrapSpan: HTMLSpanElement | null = null;
114
+ private _resizes: ResizeObserver | null = null;
115
+
116
+ static get observedAttributes() {
117
+ return [...PROPERTY_ATTRIBUTES];
118
+ }
119
+
120
+ /** `Gtk.Label:label` — the text as written, before markup and the mnemonic come out. */
121
+ get label(): string {
122
+ return this.getAttribute('label') ?? '';
123
+ }
124
+
125
+ set label(value: string) {
126
+ this.setAttribute('label', value ?? '');
127
+ }
128
+
129
+ /** `Gtk.Label:use-markup` — whether {@link label} is Pango markup (shown as its text). */
130
+ get useMarkup(): boolean {
131
+ return this.hasAttribute('use-markup');
132
+ }
133
+
134
+ set useMarkup(value: boolean) {
135
+ this.toggleAttribute('use-markup', !!value);
136
+ }
137
+
138
+ /** `Gtk.Label:use-underline` — whether an `_` marks a mnemonic (stripped, not bound). */
139
+ get useUnderline(): boolean {
140
+ return this.hasAttribute('use-underline');
141
+ }
142
+
143
+ set useUnderline(value: boolean) {
144
+ this.toggleAttribute('use-underline', !!value);
145
+ }
146
+
147
+ /** `Gtk.Label:justify` — how the lines align with each other. Defaults to `left`. */
148
+ get justify(): LabelJustification {
149
+ return normalizeLabelJustify(this.getAttribute('justify'));
150
+ }
151
+
152
+ set justify(value: LabelJustification) {
153
+ this.setAttribute('justify', value);
154
+ }
155
+
156
+ /** `Gtk.Label:xalign` — where the text sits in the label's box, 0…1. Defaults to 0.5. */
157
+ get xalign(): number {
158
+ return normalizeLabelXalign(this.getAttribute('xalign'));
159
+ }
160
+
161
+ set xalign(value: number) {
162
+ this.setAttribute('xalign', String(value));
163
+ }
164
+
165
+ /**
166
+ * `Gtk.Label:yalign` — where the text sits vertically in the label's box, 0…1.
167
+ * Defaults to 0.5. Exact at 0, 0.5 and 1; a DECLARED divergence between them — see
168
+ * `_labels.scss`'s header for why `align-items` has no continuum the way `xalign`'s
169
+ * flex-grow ratio does.
170
+ */
171
+ get yalign(): number {
172
+ return normalizeLabelYalign(this.getAttribute('yalign'));
173
+ }
174
+
175
+ set yalign(value: number) {
176
+ this.setAttribute('yalign', String(value));
177
+ }
178
+
179
+ /** `Gtk.Label:wrap` — whether the text breaks into lines rather than overflowing. */
180
+ get wrap(): boolean {
181
+ return this.hasAttribute('wrap');
182
+ }
183
+
184
+ set wrap(value: boolean) {
185
+ this.toggleAttribute('wrap', !!value);
186
+ }
187
+
188
+ /**
189
+ * `Gtk.Label:wrap-mode` — where a wrapping line may break. Defaults to `word`. Only
190
+ * affects the formatting while the layout can actually span more than one line —
191
+ * {@link wrap}, or {@link ellipsize} being active (`ellipsize`, not just `wrap`, is
192
+ * what gives GTK's own Pango layout a width to break within: `label.ts`'s header in
193
+ * `@gjsify/adwaita-core`).
194
+ */
195
+ get wrapMode(): LabelWrapMode {
196
+ return normalizeLabelWrapMode(this.getAttribute('wrap-mode'));
197
+ }
198
+
199
+ set wrapMode(value: LabelWrapMode) {
200
+ this.setAttribute('wrap-mode', value);
201
+ }
202
+
203
+ /**
204
+ * `Gtk.Label:ellipsize` — where the string is trimmed when it does not fit. Defaults
205
+ * to `none`. `start` and `middle` are HELD faithfully — read `ellipsize` back and it
206
+ * is exactly what was set — but DRAWN as an end-ellipsis: `labelEllipsizeOverflowValue`
207
+ * (`@gjsify/adwaita-core`) says why.
208
+ */
209
+ get ellipsize(): LabelEllipsizeMode {
210
+ return normalizeLabelEllipsize(this.getAttribute('ellipsize'));
211
+ }
212
+
213
+ set ellipsize(value: LabelEllipsizeMode) {
214
+ this.setAttribute('ellipsize', value);
215
+ }
216
+
217
+ /**
218
+ * `Gtk.Label:lines` — the line count an ellipsized label is held to. Defaults to -1
219
+ * ("unset"), which still caps at ONE line the moment {@link ellipsize} is active —
220
+ * Pango's own default, not "unlimited". Has no effect at all while ellipsize is
221
+ * `none`, `wrap` or not — `labelEffectiveLines` (`@gjsify/adwaita-core`) is that
222
+ * MEASURED guard, and its own doc comment carries the numbers.
223
+ */
224
+ get lines(): number {
225
+ return normalizeLabelLines(this.getAttribute('lines'));
226
+ }
227
+
228
+ set lines(value: number) {
229
+ this.setAttribute('lines', String(value));
230
+ }
231
+
232
+ /**
233
+ * `Gtk.Label:width-chars` — the label's minimum width, in characters. Defaults to -1
234
+ * (calculated automatically, i.e. unset here).
235
+ */
236
+ get widthChars(): number {
237
+ return normalizeLabelWidthChars(this.getAttribute('width-chars'));
238
+ }
239
+
240
+ set widthChars(value: number) {
241
+ this.setAttribute('width-chars', String(value));
242
+ }
243
+
244
+ /**
245
+ * `Gtk.Label:max-width-chars` — the label's natural (maximum) width, in characters.
246
+ * Defaults to -1 (calculated automatically, i.e. unset here).
247
+ */
248
+ get maxWidthChars(): number {
249
+ return normalizeLabelMaxWidthChars(this.getAttribute('max-width-chars'));
250
+ }
251
+
252
+ set maxWidthChars(value: number) {
253
+ this.setAttribute('max-width-chars', String(value));
254
+ }
255
+
256
+ /** `Gtk.Label:selectable` — whether the text can be selected. GTK's default is not. */
257
+ get selectable(): boolean {
258
+ return this.hasAttribute('selectable');
259
+ }
260
+
261
+ set selectable(value: boolean) {
262
+ this.toggleAttribute('selectable', !!value);
263
+ }
264
+
265
+ /** `gtk_label_get_text` — the text as SHOWN, markup reduced and the marker removed. */
266
+ getText(): string {
267
+ return labelDisplayText(this.label, this.useMarkup, this.useUnderline);
268
+ }
269
+
270
+ connectedCallback() {
271
+ this._render();
272
+ this._resizes = new ResizeObserver(() => this._remeasure());
273
+ this._resizes.observe(this);
274
+ void document.fonts?.ready.then(() => this._remeasure());
275
+ }
276
+
277
+ disconnectedCallback() {
278
+ this._resizes?.disconnect();
279
+ this._resizes = null;
280
+ }
281
+
282
+ private _remeasure(): void {
283
+ if (!this._wrapSpan || !this.isConnected) return;
284
+ this._wrapSpan.style.width = '';
285
+ const width = measuredWrapWidth(this._wrapSpan);
286
+ this._wrapSpan.style.width = width !== null ? `${width}px` : '';
287
+ }
288
+
289
+ attributeChangedCallback(name: string, old: string | null, value: string | null) {
290
+ this._render();
291
+ const next = this._normalized(name, value);
292
+ if (next === this._normalized(name, old)) return;
293
+ this.dispatchEvent(new CustomEvent(`notify::${name}`, { bubbles: true, detail: { [name]: next } }));
294
+ }
295
+
296
+ private _normalized(name: string, raw: string | null): string | number | boolean {
297
+ if (name === 'label') return raw ?? '';
298
+ if (name === 'justify') return normalizeLabelJustify(raw);
299
+ if (name === 'xalign') return normalizeLabelXalign(raw);
300
+ if (name === 'yalign') return normalizeLabelYalign(raw);
301
+ if (name === 'wrap-mode') return normalizeLabelWrapMode(raw);
302
+ if (name === 'ellipsize') return normalizeLabelEllipsize(raw);
303
+ if (name === 'lines') return normalizeLabelLines(raw);
304
+ if (name === 'width-chars') return normalizeLabelWidthChars(raw);
305
+ if (name === 'max-width-chars') return normalizeLabelMaxWidthChars(raw);
306
+ return raw !== null;
307
+ }
308
+
309
+ private _render(): void {
310
+ // `textContent`, the whole of the XSS answer: whatever the label holds, the DOM
311
+ // gets one text node (wrapped in {@link _wrapSpan} while {@link wrap} or
312
+ // {@link ellipsize} needs a box of its own — see `_renderSpan`).
313
+ const text = this.getText();
314
+ const wrap = this.wrap;
315
+ const ellipsize = this.ellipsize;
316
+ const clamped = ellipsize !== 'none';
317
+ if (wrap || clamped) this._renderSpan(text, clamped);
318
+ else this._renderSingleLine(text);
319
+
320
+ this.style.setProperty('--gtk-label-xalign', String(this.xalign));
321
+ this.style.textAlign = JUSTIFY_TEXT_ALIGN[this.justify];
322
+ // `yalign`'s three-zone snap — `_labels.scss`'s header explains why there is no
323
+ // continuum here the way `xalign`'s ratio spacers give the main axis one.
324
+ this.style.setProperty('--gtk-label-align-items', labelYalignAlignItems(this.yalign));
325
+
326
+ // The ONE truncating value CSS/NativeScript can both draw — see
327
+ // `labelEllipsizeOverflowValue`'s comment. Left unset (falling through to the
328
+ // `clip` `_labels.scss`'s non-clamping rules default to) while `ellipsize` is
329
+ // `none`, so a label that never asked to be truncated never gets a stray custom
330
+ // property a later `getComputedStyle` read would have to explain.
331
+ const overflow = labelEllipsizeOverflowValue(ellipsize);
332
+ if (overflow === 'ellipsis') this.style.setProperty('--gtk-label-ellipsize-overflow', overflow);
333
+ else this.style.removeProperty('--gtk-label-ellipsize-overflow');
334
+
335
+ // MEASURED (`label.ts`'s header, `@gjsify/adwaita-core`): `wrap` plays no part in
336
+ // whether `lines` has an effect — only `ellipsize` does, and once it is active an
337
+ // UNSET `lines` still caps at Pango's own default of ONE line, never "unlimited".
338
+ // So this custom property is set whenever `ellipsize` is active, `wrap` or not;
339
+ // `_labels.scss`'s `.adw-label-clamp` rule (toggled by `_renderSpan`, not an
340
+ // attribute selector, since the RAW `ellipsize` attribute can hold a value this
341
+ // method's own `normalizeLabelEllipsize` already rejected) is the one place that
342
+ // reads it.
343
+ const effectiveLines = labelEffectiveLines(this.lines, ellipsize);
344
+ if (effectiveLines !== null) this.style.setProperty('--gtk-label-lines', String(effectiveLines));
345
+ else this.style.removeProperty('--gtk-label-lines');
346
+
347
+ // `width-chars` / `max-width-chars`, in `ch` — GTK's MINIMUM and NATURAL widths
348
+ // (`get_default_widths`, gtklabel.c) — on the flex item itself, so wrap/ellipsize
349
+ // resolve against that box rather than the ambient container.
350
+ const extent = labelWidthCharsExtent(this.widthChars, this.maxWidthChars);
351
+ if (extent.minCh !== null) this.style.minWidth = `${extent.minCh}ch`;
352
+ else this.style.removeProperty('min-width');
353
+ if (extent.maxCh !== null) this.style.maxWidth = `${extent.maxCh}ch`;
354
+ else this.style.removeProperty('max-width');
355
+ }
356
+
357
+ private _renderSingleLine(text: string): void {
358
+ this._wrapSpan?.remove();
359
+ this._wrapSpan = null;
360
+ if (this.textContent !== text) this.textContent = text;
361
+ }
362
+
363
+ /**
364
+ * A WRAPPED OR ELLIPSIZED label centres its BLOCK by `xalign` (`gtklabel.c`,
365
+ * `gtk_label_get_layout_location`), not just each line — `_labels.scss`'s two spacer
366
+ * pseudo-elements split the label's FREE space by `xalign`, and a flex item only has
367
+ * free space to give them once it FITS. `flex-basis: auto` sizes the plain text node to
368
+ * its max-content (single-line) width; once that overflows, `flex-shrink` collapses the
369
+ * zero-basis spacers to nothing and stretches the text item to the full container width
370
+ * instead — which is where the offset was lost (the defect this method fixes).
371
+ *
372
+ * NO PURE-CSS FIX EXISTS: `width: fit-content` clamps to that SAME full-container width
373
+ * the moment its content no longer fits one line — CSS shrink-to-fit is
374
+ * `min(max(min-content, available), max-content)`, and `available` IS the container
375
+ * here, not the narrower width GTK's Pango layout actually wrapped to. So this measures
376
+ * instead, in two passes: first let {@link _wrapSpan} wrap at the FULL container width
377
+ * (`width: ''`, the same collapse above, which is exactly the width GTK's own
378
+ * allocation wraps against), then pin the span to its widest resulting line via
379
+ * `Range.getClientRects()`, which gives the pseudo spacers real free space to split.
380
+ * Approximate on purpose, and cheap: one synchronous reflow per render. The pinned
381
+ * width goes stale whenever the available width or the font changes, and a page does
382
+ * both (a rotated phone, an opened sidebar, a web font arriving late), so
383
+ * {@link connectedCallback} re-measures on each resize and once the fonts have loaded.
384
+ *
385
+ * AN ELLIPSIZED SPAN NEEDS THIS TOO, MEASURED THE HARD WAY: an earlier version of this
386
+ * method skipped it for `ellipsize` without `wrap`, reasoning that `white-space: nowrap`
387
+ * text has one width whichever way it is measured. That reasoning stopped applying the
388
+ * moment `ellipsize` alone started wrapping onto MULTIPLE lines to match GTK
389
+ * (`label.ts`'s header) — `white-space: normal` multi-word text has a real
390
+ * min-content/max-content gap, the exact shrink-to-fit collapse the wrapped case above
391
+ * was already written for. One code path now, for both.
392
+ */
393
+ private _renderSpan(text: string, clamped: boolean): void {
394
+ if (!this._wrapSpan) {
395
+ this.textContent = '';
396
+ this._wrapSpan = document.createElement('span');
397
+ this._wrapSpan.className = 'adw-label-text';
398
+ this.appendChild(this._wrapSpan);
399
+ }
400
+ this._wrapSpan.classList.toggle('adw-label-clamp', clamped);
401
+ if (this._wrapSpan.textContent !== text) this._wrapSpan.textContent = text;
402
+ this._remeasure();
403
+ }
404
+ }
405
+
406
+ /** The widest line {@link span}'s text wrapped to, at its CURRENT width — see `_renderSpan`. */
407
+ function measuredWrapWidth(span: HTMLSpanElement): number | null {
408
+ const range = document.createRange();
409
+ range.selectNodeContents(span);
410
+ let max = 0;
411
+ for (const rect of range.getClientRects()) max = Math.max(max, rect.width);
412
+ return max > 0 ? Math.ceil(max) : null;
413
+ }
414
+
415
+ customElements.define('gtk-label', GtkLabel);
@@ -0,0 +1,125 @@
1
+ // `Gio.Menu` as an authoring door — held against the value it has to produce.
2
+ //
3
+ // THE CLAIM UNDER TEST is not "the class has methods": it is that a menu BUILT the GJS way
4
+ // and the same menu WRITTEN as an array reduce to one model, so a `menuModel` property
5
+ // cannot tell them apart. Every case below therefore asserts against
6
+ // `normalizeMenuModel(<the array a reader would have written>)` rather than against a
7
+ // hand-typed expectation — a hand-typed one would agree with whatever this file produces.
8
+ //
9
+ // The class itself lives in `@gjsify/adwaita-core`; what this file drives is the port's
10
+ // own `Gio` DOOR onto it, so a barrel that stops resolving fails here rather than in a
11
+ // consumer.
12
+ //
13
+ // Compared as JSON rather than with `toEqual`: `@gjsify/unit`'s `toEqual` is `==`, so two
14
+ // different objects pass it whatever they hold.
15
+
16
+ import { describe, expect, it } from '@gjsify/unit';
17
+ import { normalizeMenuModel } from '@gjsify/adwaita-core';
18
+
19
+ import { Menu, MenuItem } from './namespace/gio.js';
20
+
21
+ /** What a `menuModel` property does with a value, on both spellings. */
22
+ const model = (value: readonly unknown[]) => JSON.stringify(normalizeMenuModel(value as never));
23
+
24
+ export const GioMenuTest = async () => {
25
+ await describe('Gio.Menu is the array a menuModel already takes', async () => {
26
+ await it('passes Array.isArray, which is the gate normalizeMenuModel opens on', () => {
27
+ expect(Array.isArray(new Menu())).toBe(true);
28
+ });
29
+
30
+ await it('starts empty, as g_menu_new() does', () => {
31
+ expect(new Menu().length).toBe(0);
32
+ });
33
+ });
34
+
35
+ await describe('Gio.Menu.append', async () => {
36
+ await it('builds the gallery menu the array spelling builds', () => {
37
+ const menu = new Menu();
38
+ menu.append('Save as…', 'app.save-as');
39
+ menu.append('Export', 'app.export');
40
+ menu.append('Print', 'app.print');
41
+
42
+ expect(model(menu)).toBe(
43
+ model([
44
+ { label: 'Save as…', action: 'app.save-as' },
45
+ { label: 'Export', action: 'app.export' },
46
+ { label: 'Print', action: 'app.print' },
47
+ ]),
48
+ );
49
+ });
50
+
51
+ await it('takes a label alone, the way the bare-string shorthand does', () => {
52
+ const menu = new Menu();
53
+ menu.append('Export');
54
+
55
+ expect(model(menu)).toBe(model(['Export']));
56
+ });
57
+ });
58
+
59
+ await describe('Gio.Menu.append_item and Gio.MenuItem', async () => {
60
+ await it('appends an item built separately', () => {
61
+ const menu = new Menu();
62
+ menu.append_item(new MenuItem('Save as…', 'app.save-as'));
63
+
64
+ expect(model(menu)).toBe(model([{ label: 'Save as…', action: 'app.save-as' }]));
65
+ });
66
+
67
+ await it('sets both halves after construction', () => {
68
+ const item = new MenuItem();
69
+ item.set_label('Print');
70
+ item.set_detailed_action('app.print');
71
+
72
+ expect(model([item])).toBe(model([{ label: 'Print', action: 'app.print' }]));
73
+ });
74
+
75
+ await it('REMOVES the attribute on null rather than carrying an undefined one', () => {
76
+ const item = new MenuItem('Print', 'app.print');
77
+ item.set_detailed_action(null);
78
+
79
+ expect(Object.hasOwn(item, 'action')).toBe(false);
80
+ expect(model([item])).toBe(model(['Print']));
81
+ });
82
+
83
+ await it('drops an item carrying neither half — g_menu_item_new(NULL, NULL)', () => {
84
+ expect(model([new MenuItem()])).toBe(model([]));
85
+ });
86
+ });
87
+
88
+ await describe('Gio.Menu.append_section', async () => {
89
+ await it('produces the section LINK, not an item with a stray array', () => {
90
+ const section = new Menu();
91
+ section.append('Save as…', 'app.save-as');
92
+
93
+ const menu = new Menu();
94
+ menu.append_section(null, section);
95
+
96
+ expect(model(menu)).toBe(model([{ section: [{ label: 'Save as…', action: 'app.save-as' }] }]));
97
+ });
98
+
99
+ await it('carries the heading when C would have one', () => {
100
+ const menu = new Menu();
101
+ menu.append_section('Document', ['Export']);
102
+
103
+ expect(model(menu)).toBe(model([{ label: 'Document', section: ['Export'] }]));
104
+ });
105
+
106
+ await it('writes NO label attribute when there is none', () => {
107
+ const menu = new Menu();
108
+ menu.append_section(null, ['Export']);
109
+
110
+ expect(Object.hasOwn(menu[0] as object, 'label')).toBe(false);
111
+ });
112
+ });
113
+
114
+ await describe('Gio.Menu.append_submenu', async () => {
115
+ await it('produces the submenu LINK', () => {
116
+ const submenu = new Menu();
117
+ submenu.append('Print', 'app.print');
118
+
119
+ const menu = new Menu();
120
+ menu.append_submenu('More', submenu);
121
+
122
+ expect(model(menu)).toBe(model([{ label: 'More', submenu: [{ label: 'Print', action: 'app.print' }] }]));
123
+ });
124
+ });
125
+ };
@@ -0,0 +1,7 @@
1
+ // Type-check-only ambient declarations. Nothing here is imported at runtime.
2
+ //
3
+ // `tsconfig.json` sets `"types": []`, so an ambient package's declarations reach this project
4
+ // only by an explicit reference. The `*.blp` / `*.blp?shared-tree` module patterns come from
5
+ // the build plugin that serves them, which is the same route every `.blp`-importing showcase
6
+ // already takes (`showcases/gtk/adw-blueprint-layout/src/globals.d.ts`).
7
+ /// <reference types="@gjsify/vite-plugin-blueprint/types" />