@gjsify/adwaita-web 0.64.2 → 0.64.3-next.260

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.
@@ -31,9 +31,12 @@
31
31
  // support-url a support-forum URL (a main-page Support Questions row)
32
32
  // issue-url an issue-tracker URL (a main-page Report an Issue row)
33
33
  // copyright the copyright line (Legal page)
34
- // license the license text/name (Legal page); with a `license-url`
35
- // the name becomes a link
36
- // license-url a URL the license name links to
34
+ // license the custom license text (Legal page, Pango markup); setting it makes
35
+ // `license-type` custom, as in libadwaita
36
+ // license-type a `Gtk.License`: its integer value or its nick (`mit-x11`, `gpl-3-0`);
37
+ // a known type shows the stock warranty line and clears `license`
38
+ // license-url NOT in libadwaita: a URL a custom `license` text links to
39
+ // translator-credits the translators, one per line (a "Translated by" credits group)
37
40
  // release-notes AppStream release-notes markup — a What's New row + page,
38
41
  // the buffer from the core's `releaseNotesBuffer`
39
42
  // release-notes-version the version the What's New heading names, else `version`
@@ -44,12 +47,18 @@
44
47
  // AdwAboutDialog `developers` / `designers` / `artists` / `documenters` properties
45
48
  // — entries may be `Name`, `Name <email>` or `Name https://url`, parsed by the
46
49
  // core's `parseCreditPerson` (`parse_person`). `add_link (title, url)` appends a
47
- // Details-page link row, as on GTK.
50
+ // Details-page link row, as on GTK; `add_credit_section`, `add_acknowledgement_section`
51
+ // and `add_legal_section` append a Credits, Acknowledgements or Legal group.
48
52
  //
49
- // NOT implemented here, so the core derivations see them empty: `add_credit_section`,
50
- // `translator-credits`, the Acknowledgements page, and the debug page's "Save As…"
51
- // button (`debug-info-filename`), which needs a file dialog. `new_from_appdata` is
52
- // refused by name: it needs an AppStream parser.
53
+ // NOT implemented here: the link rows' context menu ("Open Link" / "Copy Link Address", right
54
+ // click or long press) of libadwaita's private `AdwLinkRow`, the debug page's "Save As…" button (`debug-info-filename`), which
55
+ // needs a file dialog, and `add_other_app` / `other-apps-title`. `new_from_appdata` is
56
+ // refused by name: it needs an AppStream parser. An `add_*_section` call with no people
57
+ // draws no row, where libadwaita 1.9 leaves a Credits row that opens a blank page.
58
+ //
59
+ // Events (CustomEvent, bubbles): `notify::license` and `notify::license-type` (detail
60
+ // `{ license, licenseType }`), in the order `adw_about_dialog_set_license` and
61
+ // `set_license_type` notify them.
53
62
  //
54
63
  // Events (CustomEvent, bubbles): `notify::open` (detail `{ open }`), `closed`, and
55
64
  // `activate-link` (detail `{ uri }`, CANCELABLE — the signal's `true_handled`
@@ -67,16 +76,29 @@
67
76
 
68
77
  import {
69
78
  ADW_ABOUT_DIALOG_LABELS,
79
+ linkRowIconName,
80
+ linkRowTooltip,
81
+ ADW_LICENSE_DEFAULTS,
70
82
  type AdwCreditPerson,
71
- GTK_LICENSE,
83
+ type AdwCreditsSectionInput,
84
+ type AdwLegalSectionInput,
85
+ type AdwLicenseState,
72
86
  type ReleaseNotesSegment,
73
87
  Toast,
74
88
  aboutDialogVisibility,
89
+ acknowledgementSections,
75
90
  creditsSections,
91
+ isLicenseType,
76
92
  legalSectionVisible,
93
+ legalSections,
94
+ legalVisible,
95
+ licenseTypeForNick,
77
96
  refuseNewFromAppdata,
78
97
  releaseNotesBuffer,
98
+ setLicense,
99
+ setLicenseType,
79
100
  stripMnemonic,
101
+ GTK_LICENSE,
80
102
  } from '@gjsify/adwaita-core';
81
103
 
82
104
  import { webClipboardHost } from '../clipboard-host.js';
@@ -84,6 +106,7 @@ import type { AdwToastOverlay } from './adw-toast-overlay.js';
84
106
  import './adw-toast-overlay.js';
85
107
  import { createGtkImage } from './gtk-image.js';
86
108
  import { AdwModalSurface } from './modal-surface.js';
109
+ import { renderPangoMarkup } from '../pango-markup.js';
87
110
  import { WidgetElement } from '../widget-element.js';
88
111
 
89
112
  /** A template label with its mnemonic marker removed — this renderer has no accelerator layer. */
@@ -108,6 +131,15 @@ export class AdwAboutDialog extends WidgetElement {
108
131
  private _artists: string[] = [];
109
132
  private _documenters: string[] = [];
110
133
 
134
+ /** `license` + `license-type`, one state driven by the core transitions. */
135
+ private _licenseState: AdwLicenseState = { ...ADW_LICENSE_DEFAULTS };
136
+ /** Set while an attribute is being written back from `_licenseState`. */
137
+ private _reflecting = false;
138
+ /** `add_credit_section`, `add_acknowledgement_section` and `add_legal_section`, in call order. */
139
+ private _creditSections: AdwCreditsSectionInput[] = [];
140
+ private _acknowledgementSections: AdwCreditsSectionInput[] = [];
141
+ private _legalSections: AdwLegalSectionInput[] = [];
142
+
111
143
  /** `add_link` rows, in order; `has_custom_links` latches with the first. */
112
144
  private _links: { title: string; url: string }[] = [];
113
145
  /**
@@ -129,7 +161,9 @@ export class AdwAboutDialog extends WidgetElement {
129
161
  'issue-url',
130
162
  'copyright',
131
163
  'license',
164
+ 'license-type',
132
165
  'license-url',
166
+ 'translator-credits',
133
167
  'release-notes',
134
168
  'release-notes-version',
135
169
  'debug-info',
@@ -214,14 +248,32 @@ export class AdwAboutDialog extends WidgetElement {
214
248
  this.setAttribute('copyright', value);
215
249
  }
216
250
 
251
+ /** `license`: the custom licence text, `''` for a stock licence. */
217
252
  get license(): string {
218
- return this.getAttribute('license') ?? '';
253
+ return this._licenseState.license;
219
254
  }
220
255
 
221
256
  set license(value: string) {
222
257
  this.setAttribute('license', value);
223
258
  }
224
259
 
260
+ /** `license-type`: a `Gtk.License` value (see `GTK_LICENSE`). */
261
+ get licenseType(): number {
262
+ return this._licenseState.licenseType;
263
+ }
264
+
265
+ set licenseType(value: number) {
266
+ this.setAttribute('license-type', String(value));
267
+ }
268
+
269
+ get translatorCredits(): string {
270
+ return this.getAttribute('translator-credits') ?? '';
271
+ }
272
+
273
+ set translatorCredits(value: string) {
274
+ this.setAttribute('translator-credits', value);
275
+ }
276
+
225
277
  get licenseUrl(): string {
226
278
  return this.getAttribute('license-url') ?? '';
227
279
  }
@@ -260,6 +312,28 @@ export class AdwAboutDialog extends WidgetElement {
260
312
  if (this._initialized) this._render();
261
313
  }
262
314
 
315
+ /** `adw_about_dialog_add_credit_section`: a Credits group; `name` may be `null`. */
316
+ add_credit_section(name: string | null, people: string[]): void {
317
+ this._creditSections.push({ title: name, people: [...people] });
318
+ if (this._initialized) this._render();
319
+ }
320
+
321
+ /** `adw_about_dialog_add_acknowledgement_section`: an Acknowledgements group. */
322
+ add_acknowledgement_section(name: string | null, people: string[]): void {
323
+ this._acknowledgementSections.push({ title: name, people: [...people] });
324
+ if (this._initialized) this._render();
325
+ }
326
+
327
+ /**
328
+ * `adw_about_dialog_add_legal_section`: a Legal-page section. A `license_type` outside
329
+ * `Gtk.License` is rejected, like the property setter.
330
+ */
331
+ add_legal_section(title: string, copyright: string | null, license_type: number, license: string | null): void {
332
+ if (!isLicenseType(license_type)) return;
333
+ this._legalSections.push({ title, copyright, licenseType: license_type, license });
334
+ if (this._initialized) this._render();
335
+ }
336
+
263
337
  get open(): boolean {
264
338
  return this.hasAttribute('open');
265
339
  }
@@ -353,6 +427,13 @@ export class AdwAboutDialog extends WidgetElement {
353
427
 
354
428
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
355
429
  if (oldValue === newValue) return;
430
+ // Before the guard: the two attributes are ONE state, and a value set before the
431
+ // element connects has to be in it when the first render reads it.
432
+ if (name === 'license' || name === 'license-type') {
433
+ if (!this._reflecting) this._applyLicenseAttribute(name, newValue);
434
+ if (this._initialized && !this._reflecting) this._render();
435
+ return;
436
+ }
356
437
  // Before the guard: the heading reads `version` as it is when the NOTES change.
357
438
  if (name === 'release-notes' || name === 'release-notes-version') {
358
439
  this._releaseNotesSegments = releaseNotesBuffer(
@@ -374,6 +455,53 @@ export class AdwAboutDialog extends WidgetElement {
374
455
  this._render();
375
456
  }
376
457
 
458
+ /** Run the core setter an attribute maps to, notify what it notified, and write the pair back. */
459
+ private _applyLicenseAttribute(name: string, value: string | null): void {
460
+ const transition =
461
+ name === 'license'
462
+ ? setLicense(this._licenseState, value ?? '')
463
+ : setLicenseType(this._licenseState, this._parseLicenseType(value));
464
+ this._licenseState = transition.state;
465
+ this._reflectLicense();
466
+ for (const property of transition.notify) {
467
+ this.dispatchEvent(
468
+ new CustomEvent(`notify::${property}`, {
469
+ bubbles: true,
470
+ detail: { license: this._licenseState.license, licenseType: this._licenseState.licenseType },
471
+ }),
472
+ );
473
+ }
474
+ }
475
+
476
+ /** An integer or a `Gtk.License` nick; anything else is out of range, so the setter refuses it. */
477
+ private _parseLicenseType(value: string | null): number {
478
+ if (value === null) return GTK_LICENSE.UNKNOWN;
479
+ if (/^-?\d+$/.test(value.trim())) return Number(value);
480
+ return licenseTypeForNick(value.trim()) ?? -1;
481
+ }
482
+
483
+ /**
484
+ * Write `_licenseState` back onto the attributes, which a setter may have moved: a stock
485
+ * licence clears `license`. A `license-type` the app wrote as a nick is left as it wrote it.
486
+ */
487
+ private _reflectLicense(): void {
488
+ const { license, licenseType } = this._licenseState;
489
+ this._reflecting = true;
490
+ try {
491
+ if (license.length === 0) this.removeAttribute('license');
492
+ else if (this.getAttribute('license') !== license) this.setAttribute('license', license);
493
+
494
+ const written = this.getAttribute('license-type');
495
+ if (licenseType === GTK_LICENSE.UNKNOWN) {
496
+ if (written !== null) this.removeAttribute('license-type');
497
+ } else if (written === null || this._parseLicenseType(written) !== licenseType) {
498
+ this.setAttribute('license-type', String(licenseType));
499
+ }
500
+ } finally {
501
+ this._reflecting = false;
502
+ }
503
+ }
504
+
377
505
  /** Reflect the presented state onto the host + reset the navigation stack. */
378
506
  private _syncOpen(): void {
379
507
  this.classList.toggle('open', this.open);
@@ -396,22 +524,24 @@ export class AdwAboutDialog extends WidgetElement {
396
524
  designers: this._designers,
397
525
  artists: this._artists,
398
526
  documenters: this._documenters,
527
+ translatorCredits: this.translatorCredits,
528
+ creditSections: this._creditSections,
399
529
  });
400
530
  }
401
531
 
402
- /**
403
- * Whether the Legal page has anything on it. This element's licence model is the
404
- * simplified one — a name plus an optional URL — so it maps onto `GtkLicense` as
405
- * "custom text when a licence is set, unknown otherwise", which is what
406
- * `adw_about_dialog_set_license` does to the type anyway.
407
- */
408
- private _hasLegal(): boolean {
409
- const licenseType = this.license.length > 0 ? GTK_LICENSE.CUSTOM : GTK_LICENSE.UNKNOWN;
410
- return legalSectionVisible(this.copyright, licenseType, this.license);
532
+ /** What `update_legal` reads: the dialog's own copyright + licence, then the added sections. */
533
+ private _legalInput() {
534
+ return {
535
+ copyright: this.copyright,
536
+ licenseType: this._licenseState.licenseType,
537
+ license: this._licenseState.license,
538
+ legalSections: this._legalSections,
539
+ };
411
540
  }
412
541
 
413
542
  private _render(): void {
414
543
  const sections = this._creditsSections();
544
+ const acknowledgements = acknowledgementSections(this._acknowledgementSections);
415
545
  const visibility = aboutDialogVisibility({
416
546
  applicationIcon: this.applicationIcon,
417
547
  applicationName: this.applicationName,
@@ -425,7 +555,8 @@ export class AdwAboutDialog extends WidgetElement {
425
555
  releaseNotes: this.releaseNotes,
426
556
  hasCustomLinks: this._links.length > 0,
427
557
  hasCredits: sections.length > 0,
428
- hasLegal: this._hasLegal(),
558
+ hasLegal: legalVisible(this._legalInput()),
559
+ hasAcknowledgements: acknowledgements.length > 0,
429
560
  });
430
561
 
431
562
  this._navView?.remove();
@@ -441,6 +572,7 @@ export class AdwAboutDialog extends WidgetElement {
441
572
  }
442
573
  if (visibility.creditsRow) this._navView.append(this._buildCreditsPage(sections));
443
574
  if (visibility.legalRow) this._navView.append(this._buildLegalPage());
575
+ if (visibility.acknowledgementsRow) this._navView.append(this._buildAcknowledgementsPage(acknowledgements));
444
576
 
445
577
  // Straight into the overlay's content box: appended to the overlay itself, the view
446
578
  // is re-homed a microtask later, and that move drops the focus `present` put in it.
@@ -583,6 +715,9 @@ export class AdwAboutDialog extends WidgetElement {
583
715
  const creditsLegalRows: HTMLElement[] = [];
584
716
  if (visibility.creditsRow) creditsLegalRows.push(this._buildNavRow(rowLabel('creditsRow'), 'credits'));
585
717
  if (visibility.legalRow) creditsLegalRows.push(this._buildNavRow(rowLabel('legalRow'), 'legal'));
718
+ if (visibility.acknowledgementsRow) {
719
+ creditsLegalRows.push(this._buildNavRow(rowLabel('acknowledgementsRow'), 'acknowledgements'));
720
+ }
586
721
 
587
722
  if (visibility.detailsGroup) body.appendChild(this._buildGroup('details', detailsGroupRows));
588
723
  if (visibility.supportGroup) body.appendChild(this._buildGroup('support', supportGroupRows));
@@ -711,20 +846,46 @@ export class AdwAboutDialog extends WidgetElement {
711
846
  private _buildLinkRow(title: string, uri: string): HTMLElement {
712
847
  const row = document.createElement('adw-action-row');
713
848
  row.setAttribute('title', title);
849
+ this._makeLinkRow(row, uri);
850
+ return row;
851
+ }
852
+
853
+ /**
854
+ * `AdwLinkRow`: `adw_link_row_init` + `adw_link_row_set_uri`. The `link` class and the
855
+ * LINK state flag, accessible role LINK, the unescaped URI as tooltip, and a suffix glyph
856
+ * that says "leaves the app" (an envelope for `mailto:`) instead of a navigation chevron.
857
+ * Activation is `_activateLink`, which emits `activate-link` and then opens the URI.
858
+ */
859
+ private _makeLinkRow(row: HTMLElement, uri: string): void {
714
860
  row.setAttribute('activatable', '');
715
- row.classList.add('adw-about-dialog-link-row');
861
+ row.setAttribute('role', 'link');
862
+ row.classList.add('link', 'adw-about-dialog-link-row');
863
+ const tooltip = linkRowTooltip(uri);
864
+ if (tooltip !== null) row.setAttribute('tooltip-text', tooltip);
716
865
 
717
- const chevron = createGtkImage('go-next', 'adw-about-dialog-chevron');
718
- chevron.setAttribute('slot', 'suffix');
719
- row.appendChild(chevron);
866
+ const icon = createGtkImage(linkRowIconName(uri), 'adw-about-dialog-link-icon');
867
+ icon.setAttribute('slot', 'suffix');
868
+ icon.setAttribute('aria-hidden', 'true');
869
+ row.appendChild(icon);
720
870
 
721
871
  row.addEventListener('activated', () => this._activateLink(uri));
722
-
723
- return row;
724
872
  }
725
873
 
726
874
  private _buildCreditsPage(sections: ReturnType<typeof creditsSections>): HTMLElement {
727
- const { page, body } = this._buildPage('credits', ADW_ABOUT_DIALOG_LABELS.creditsPage, false);
875
+ return this._buildCreditGroupsPage('credits', ADW_ABOUT_DIALOG_LABELS.creditsPage, sections);
876
+ }
877
+
878
+ private _buildAcknowledgementsPage(sections: ReturnType<typeof acknowledgementSections>): HTMLElement {
879
+ return this._buildCreditGroupsPage('acknowledgements', ADW_ABOUT_DIALOG_LABELS.acknowledgementsPage, sections);
880
+ }
881
+
882
+ /** Credits and Acknowledgements are one structure: a group per section, a row per person. */
883
+ private _buildCreditGroupsPage(
884
+ tag: string,
885
+ title: string,
886
+ sections: ReturnType<typeof creditsSections>,
887
+ ): HTMLElement {
888
+ const { page, body } = this._buildPage(tag, title, false);
728
889
 
729
890
  for (const section of sections) {
730
891
  const rows = section.people.map((person) => this._buildCreditRow(person));
@@ -746,12 +907,7 @@ export class AdwAboutDialog extends WidgetElement {
746
907
 
747
908
  if (person.link !== null) {
748
909
  const uri = person.uri ?? '';
749
- row.setAttribute('activatable', '');
750
- row.classList.add('adw-about-dialog-link-row');
751
- const chevron = createGtkImage('go-next', 'adw-about-dialog-chevron');
752
- chevron.setAttribute('slot', 'suffix');
753
- row.appendChild(chevron);
754
- row.addEventListener('activated', () => this._activateLink(uri));
910
+ this._makeLinkRow(row, uri);
755
911
  }
756
912
 
757
913
  return row;
@@ -759,36 +915,84 @@ export class AdwAboutDialog extends WidgetElement {
759
915
 
760
916
  private _buildLegalPage(): HTMLElement {
761
917
  const { page, body } = this._buildPage('legal', ADW_ABOUT_DIALOG_LABELS.legalPage, false);
918
+ const input = this._legalInput();
919
+ const sections = legalSections(input);
920
+ const hasDefault = legalSectionVisible(input.copyright, input.licenseType, input.license);
921
+
922
+ for (const [index, section] of sections.entries()) {
923
+ if (section.title !== null) {
924
+ const heading = document.createElement('p');
925
+ heading.className = 'adw-about-dialog-legal-title';
926
+ heading.textContent = section.title;
927
+ body.appendChild(heading);
928
+ }
762
929
 
763
- if (this.copyright) {
764
- const copyright = document.createElement('p');
765
- copyright.className = 'adw-about-dialog-copyright';
766
- copyright.textContent = this.copyright;
767
- body.appendChild(copyright);
930
+ // `license-url` is the one input libadwaita does not have: the DEFAULT section's custom
931
+ // text becomes a link to it.
932
+ const label = document.createElement('p');
933
+ label.className = 'adw-about-dialog-license';
934
+ if (index === 0 && hasDefault && this.licenseUrl && input.licenseType === GTK_LICENSE.CUSTOM) {
935
+ this._renderLegalLabel(label, this.copyright);
936
+ if (this.copyright.length > 0) label.append('\n\n');
937
+ label.append(this._buildLicenseUrlLink());
938
+ } else {
939
+ this._renderLegalLabel(label, section.markup);
940
+ }
941
+ if (section.markup.length > 0) body.appendChild(label);
768
942
  }
769
943
 
770
- if (this.license) {
771
- const license = document.createElement('p');
772
- license.className = 'adw-about-dialog-license';
773
- if (this.licenseUrl) {
774
- // GTK connects the legal label's `activate-link` to the same
775
- // handler as the rows, so this link is cancelable too.
776
- const link = document.createElement('a');
777
- link.href = this.licenseUrl;
944
+ return page;
945
+ }
946
+
947
+ /** The custom licence text as a link to `license-url`. Cancelable like every other link. */
948
+ private _buildLicenseUrlLink(): HTMLElement {
949
+ const url = this.licenseUrl;
950
+ const link = document.createElement('a');
951
+ link.href = url;
952
+ link.rel = 'noopener noreferrer';
953
+ link.textContent = this.license;
954
+ link.addEventListener('click', (event) => {
955
+ event.preventDefault();
956
+ this._activateLink(url);
957
+ });
958
+ return link;
959
+ }
960
+
961
+ /**
962
+ * A legal label's markup: Pango markup, with the `<a href>` GtkLabel adds on top — the
963
+ * stock warranty line is one. Each link raises the cancelable `activate-link`. Only
964
+ * `http`, `https` and `mailto` targets become links; any other is drawn as its text,
965
+ * since the default action hands the URI to `window.open`.
966
+ */
967
+ private _renderLegalLabel(target: HTMLElement, markup: string): void {
968
+ target.replaceChildren();
969
+ // `\x22` is `"`: an odd number of literal quotes derails check-adwaita-connect-rebind's masker.
970
+ const anchor = /<a\s+href="([^\x22]*)">([\s\S]*?)<\/a>/g;
971
+ let last = 0;
972
+ const text = (value: string) => {
973
+ if (value.length === 0) return;
974
+ const span = document.createElement('span');
975
+ renderPangoMarkup(span, value);
976
+ target.appendChild(span);
977
+ };
978
+ for (const match of markup.matchAll(anchor)) {
979
+ text(markup.slice(last, match.index));
980
+ last = match.index + match[0].length;
981
+
982
+ const uri = match[1]!.replace(/&amp;/g, '&');
983
+ const link = document.createElement('a');
984
+ renderPangoMarkup(link, match[2]!);
985
+ if (/^(https?:|mailto:)/i.test(uri)) {
986
+ link.href = uri;
778
987
  link.rel = 'noopener noreferrer';
779
- link.textContent = this.license;
780
988
  link.addEventListener('click', (event) => {
781
989
  event.preventDefault();
782
- this._activateLink(this.licenseUrl);
990
+ this._activateLink(uri);
783
991
  });
784
- license.appendChild(link);
785
- } else {
786
- license.textContent = this.license;
787
992
  }
788
- body.appendChild(license);
993
+ target.appendChild(link);
789
994
  }
790
-
791
- return page;
995
+ text(markup.slice(last));
792
996
  }
793
997
 
794
998
  /** Show the header title only once the main page is scrolled (AdwAboutDialog). */
@@ -121,7 +121,8 @@ export class AdwPreferencesRow extends TooltipElement {
121
121
  bindSlottedChildren(this, [{ name: 'child', into: this._contentEl }]).install(this._titleEl, this._contentEl);
122
122
  bindEmptySections(this._contentEl);
123
123
 
124
- this.setAttribute('role', 'listitem');
124
+ // An author-set role wins: `AdwLinkRow` is a row whose accessible role is LINK.
125
+ if (!this.hasAttribute('role')) this.setAttribute('role', 'listitem');
125
126
  this._render();
126
127
  }
127
128
 
package/src/glib.spec.ts CHANGED
@@ -3,15 +3,24 @@
3
3
  // that stops being exported fails here.
4
4
 
5
5
  import {
6
+ GLIB_LANGUAGE_TAG_VECTORS,
6
7
  GLIB_PATH_VECTORS,
7
8
  GLIB_TIMER_VECTORS,
9
+ GLIB_UNICHAR_VECTORS,
8
10
  GLIB_URI_VECTORS,
11
+ GLIB_VARIANT_VECTORS,
12
+ driveGLibLanguageTagVectors,
9
13
  driveGLibPathVectors,
10
14
  driveGLibTimerVectors,
15
+ driveGLibUnicharVectors,
11
16
  driveGLibUriVectors,
17
+ driveGLibVariantVectors,
18
+ type GLibLanguageLike,
12
19
  type GLibPathsLike,
13
20
  type GLibTimersLike,
21
+ type GLibUnicharLike,
14
22
  type GLibUriLike,
23
+ type GLibVariantLike,
15
24
  } from '@gjsify/adwaita-core/conformance';
16
25
  import { describe, expect, it } from '@gjsify/unit';
17
26
 
@@ -46,11 +55,64 @@ export const GLibTest = async () => {
46
55
  { describe, it, expect },
47
56
  GLIB_URI_VECTORS,
48
57
  );
58
+ await driveGLibVariantVectors(
59
+ { name: 'adwaita-web', isOracle: false, GLib: GLib as unknown as GLibVariantLike },
60
+ { describe, it, expect },
61
+ GLIB_VARIANT_VECTORS,
62
+ );
63
+ await driveGLibUnicharVectors(
64
+ { name: 'adwaita-web', isOracle: false, GLib: GLib as unknown as GLibUnicharLike },
65
+ { describe, it, expect },
66
+ GLIB_UNICHAR_VECTORS,
67
+ );
68
+ await driveGLibLanguageTagVectors(
69
+ {
70
+ name: 'adwaita-web',
71
+ isOracle: false,
72
+ GLib: GLib as unknown as GLibLanguageLike,
73
+ withTags: (tags, read) => {
74
+ const original = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
75
+ Object.defineProperty(globalThis, 'navigator', {
76
+ value: { languages: tags, language: tags[0] },
77
+ configurable: true,
78
+ });
79
+ try {
80
+ return read();
81
+ } finally {
82
+ if (original) Object.defineProperty(globalThis, 'navigator', original);
83
+ else delete (globalThis as { navigator?: unknown }).navigator;
84
+ }
85
+ },
86
+ },
87
+ { describe, it, expect },
88
+ GLIB_LANGUAGE_TAG_VECTORS,
89
+ );
49
90
  await describe('adwaita-web: GLib door', async () => {
50
91
  await it('answers the host-bound path questions with the root and no data dirs', () => {
51
92
  expect(GLib.get_current_dir()).toBe('/');
52
93
  expect(GLib.get_system_data_dirs().length).toBe(0);
53
94
  });
95
+ await it('answers C alone where the host names no language', () => {
96
+ const original = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
97
+ Object.defineProperty(globalThis, 'navigator', { value: undefined, configurable: true });
98
+ try {
99
+ expect(GLib.get_language_names().join(',')).toBe('C');
100
+ } finally {
101
+ if (original) Object.defineProperty(globalThis, 'navigator', original);
102
+ }
103
+ });
104
+ await it('falls back to navigator.language when languages is empty', () => {
105
+ const original = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
106
+ Object.defineProperty(globalThis, 'navigator', {
107
+ value: { languages: [], language: 'fr-CA' },
108
+ configurable: true,
109
+ });
110
+ try {
111
+ expect(GLib.get_language_names().join(',')).toBe('fr_CA,fr,C');
112
+ } finally {
113
+ if (original) Object.defineProperty(globalThis, 'navigator', original);
114
+ }
115
+ });
54
116
  await it('has a vector row for every vector', () => {
55
117
  expect(GLIB_TIMER_VECTORS.length > 0 && GLIB_PATH_VECTORS.length > 0).toBe(true);
56
118
  });
@@ -0,0 +1,10 @@
1
+ // What the browser knows of the user's languages, for `GLib.get_language_names()`: the ordered
2
+ // `navigator.languages`, or `navigator.language` where a host has only that. Read at CALL time, never
3
+ // at import — the user can change the list while the page is open, and a module may be evaluated where
4
+ // there is no `navigator` at all (SSR, a worker without one).
5
+
6
+ export function hostLanguageTags(): string[] {
7
+ const host = typeof navigator === 'undefined' ? undefined : navigator;
8
+ if (host?.languages && host.languages.length > 0) return [...host.languages];
9
+ return host?.language ? [host.language] : [];
10
+ }
@@ -7,24 +7,34 @@
7
7
  // in `glib-paths.ts` of the core; the host has no filesystem, so `get_current_dir()` is `"/"` and
8
8
  // `get_system_data_dirs()` is `[]`. A port that knows better replaces the member in its own file.
9
9
  //
10
- // `uri_escape_string` is the core's `glib-uri.ts`, a pure function over UTF-8 bytes.
10
+ // `get_language_names` is the core's `glib-language.ts` over the host's language tags
11
+ // (`language-host.ts`: `navigator.languages`), read when it is CALLED. The tag rule is in the core's header.
12
+ //
13
+ // `uri_escape_string` is the core's `glib-uri.ts`, a pure function over UTF-8 bytes;
14
+ // `unichar_isalpha` is `glib-unichar.ts`, a generated table (no `\p{L}`: no ICU on NativeScript).
11
15
  //
12
16
  // `Bytes` and `Error` are the ADR 0100 subset a `Gio.File` reads and fails with (`get_data`,
13
17
  // `matches`, `code`, `message`), re-exported from the core's `glib-bytes.ts` / `glib-error.ts`.
14
18
  //
19
+ // `Variant` is the ADR 0118 subset (`b i u d s` and arrays of them) `Gio.Settings` stores array
20
+ // keys with, re-exported from the core's `glib-variant.ts`.
21
+ //
15
22
  // A TRUE SUBSET: everything else in GLib (`MainLoop`, `Source`, `timeout_add_seconds`,
16
23
  // `get_home_dir`, …) is absent, and the `gi://GLib` arm refuses it by name.
17
24
 
18
- export { GLibBytes as Bytes, GLibError as Error } from '@gjsify/adwaita-core';
25
+ export { GLibBytes as Bytes, GLibError as Error, GLibVariant as Variant } from '@gjsify/adwaita-core';
19
26
 
20
27
  import {
21
28
  buildFilenamev,
22
29
  createGLibTimers,
23
30
  getCurrentDir,
24
31
  getSystemDataDirs,
32
+ languageNamesFromTags,
25
33
  MAXUINT32 as GLIB_MAXUINT32,
34
+ unicharIsalpha,
26
35
  uriEscapeString,
27
36
  } from '@gjsify/adwaita-core';
37
+ import { hostLanguageTags } from '../language-host.js';
28
38
 
29
39
  const glib = createGLibTimers();
30
40
 
@@ -43,4 +53,6 @@ export const MAXUINT32 = GLIB_MAXUINT32;
43
53
  export const build_filenamev = buildFilenamev;
44
54
  export const get_current_dir = getCurrentDir;
45
55
  export const get_system_data_dirs = getSystemDataDirs;
56
+ export const get_language_names = (): string[] => languageNamesFromTags(hostLanguageTags());
46
57
  export const uri_escape_string = uriEscapeString;
58
+ export const unichar_isalpha = unicharIsalpha;