@gjsify/adwaita-web 0.51.1 → 0.53.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 (113) hide show
  1. package/README.md +34 -0
  2. package/dist/adwaita-web.css +259 -31
  3. package/dist/adwaita-web.css.map +1 -1
  4. package/lib/types/accent.d.ts +12 -2
  5. package/lib/types/adw-appearance.spec.d.ts +2 -0
  6. package/lib/types/appearance.d.ts +41 -0
  7. package/lib/types/blueprint-layout.spec.d.ts +1 -0
  8. package/lib/types/blueprint-markup.spec.d.ts +4 -0
  9. package/lib/types/blueprint-tree.spec.d.ts +1 -0
  10. package/lib/types/elements/adw-bottom-sheet.d.ts +31 -13
  11. package/lib/types/elements/adw-carousel.d.ts +5 -0
  12. package/lib/types/elements/adw-header-bar.d.ts +15 -0
  13. package/lib/types/elements/adw-inline-view-switcher.d.ts +1 -0
  14. package/lib/types/elements/adw-navigation-view.d.ts +1 -0
  15. package/lib/types/elements/adw-sidebar.d.ts +10 -0
  16. package/lib/types/elements/adw-tab-view.d.ts +1 -0
  17. package/lib/types/elements/adw-toggle-group.d.ts +11 -0
  18. package/lib/types/elements/gtk-action-bar.d.ts +19 -0
  19. package/lib/types/elements/gtk-box.d.ts +17 -0
  20. package/lib/types/elements/gtk-button.d.ts +2 -0
  21. package/lib/types/elements/gtk-label.d.ts +122 -0
  22. package/lib/types/font-stack.spec.d.ts +2 -0
  23. package/lib/types/gio-menu.spec.d.ts +1 -0
  24. package/lib/types/gtk-box.spec.d.ts +1 -0
  25. package/lib/types/gtk-label.spec.d.ts +1 -0
  26. package/lib/types/gtk-value-doors.spec.d.ts +1 -0
  27. package/lib/types/index.d.ts +6 -1
  28. package/lib/types/namespace/gio.d.ts +1 -0
  29. package/lib/types/namespace/gtk.d.ts +5 -0
  30. package/lib/types/shared-tree-builder.d.ts +64 -0
  31. package/lib/types/source-view/asm6502.d.ts +1 -20
  32. package/lib/types/source-view/index.d.ts +3 -2
  33. package/lib/types/source-view/theme.d.ts +1 -1
  34. package/lib/types/string-list-slot.d.ts +4 -0
  35. package/lib/types/styles.generated.d.ts +1 -1
  36. package/lib/types/tags.spec.d.ts +1 -0
  37. package/lib/types/value-lists.spec.d.ts +1 -0
  38. package/package.json +8 -6
  39. package/scss/_action_bar.scss +51 -0
  40. package/scss/_banner.scss +8 -1
  41. package/scss/_bottom_sheet.scss +57 -0
  42. package/scss/_box.scss +23 -0
  43. package/scss/_button.scss +29 -7
  44. package/scss/_button_content.scss +6 -1
  45. package/scss/_button_row.scss +5 -2
  46. package/scss/_carousel.scss +6 -0
  47. package/scss/_labels.scss +126 -12
  48. package/scss/_navigation_split_view.scss +18 -0
  49. package/scss/_overlay_split_view.scss +5 -0
  50. package/scss/_reset.scss +29 -4
  51. package/scss/_split_button.scss +5 -2
  52. package/scss/_status_page.scss +31 -17
  53. package/scss/_theme.scss +11 -3
  54. package/scss/_toolbar_view.scss +10 -0
  55. package/scss/_variables.scss +23 -3
  56. package/scss/_widget.scss +55 -0
  57. package/scss/_window_title.scss +9 -0
  58. package/scss/adwaita-skin.scss +8 -2
  59. package/src/accent.ts +28 -5
  60. package/src/adw-appearance.spec.ts +178 -0
  61. package/src/adw-banner.spec.ts +16 -0
  62. package/src/adw-button-row.spec.ts +8 -0
  63. package/src/adw-header-bar.spec.ts +17 -0
  64. package/src/appearance.ts +207 -0
  65. package/src/blueprint-layout.spec.ts +223 -0
  66. package/src/blueprint-markup.spec.ts +155 -0
  67. package/src/blueprint-tree.spec.ts +174 -0
  68. package/src/bottom-sheet.spec.ts +166 -1
  69. package/src/chrome.spec.ts +5 -2
  70. package/src/elements/adw-bottom-sheet.ts +166 -59
  71. package/src/elements/adw-carousel.ts +10 -5
  72. package/src/elements/adw-clamp.ts +12 -0
  73. package/src/elements/adw-combo-row.ts +13 -4
  74. package/src/elements/adw-header-bar.ts +46 -3
  75. package/src/elements/adw-inline-view-switcher.ts +11 -0
  76. package/src/elements/adw-navigation-view.ts +10 -0
  77. package/src/elements/adw-sidebar.ts +22 -0
  78. package/src/elements/adw-spin-row.ts +44 -5
  79. package/src/elements/adw-status-page.ts +12 -6
  80. package/src/elements/adw-tab-view.ts +20 -3
  81. package/src/elements/adw-toggle-group.ts +45 -4
  82. package/src/elements/adw-toolbar-view.ts +9 -0
  83. package/src/elements/gtk-action-bar.ts +90 -0
  84. package/src/elements/gtk-box.ts +87 -0
  85. package/src/elements/gtk-button.ts +60 -13
  86. package/src/elements/gtk-drop-down.ts +10 -1
  87. package/src/elements/gtk-label.ts +415 -0
  88. package/src/elements/swipe-drag.ts +1 -1
  89. package/src/font-stack.spec.ts +110 -0
  90. package/src/gio-menu.spec.ts +125 -0
  91. package/src/globals.d.ts +7 -0
  92. package/src/gtk-box.spec.ts +168 -0
  93. package/src/gtk-button.spec.ts +123 -1
  94. package/src/gtk-label.spec.ts +595 -0
  95. package/src/gtk-value-doors.spec.ts +104 -0
  96. package/src/index.ts +32 -1
  97. package/src/keyboard-operable.spec.ts +5 -4
  98. package/src/namespace/gio.ts +19 -0
  99. package/src/namespace/gtk.ts +14 -0
  100. package/src/shared-tree-builder.ts +268 -0
  101. package/src/shared-trees.spec.ts +296 -32
  102. package/src/source-view/adw-source-view.ts +3 -3
  103. package/src/source-view/asm6502.ts +11 -191
  104. package/src/source-view/index.ts +17 -11
  105. package/src/source-view/theme.ts +23 -17
  106. package/src/split-button.spec.ts +7 -0
  107. package/src/string-list-slot.ts +21 -0
  108. package/src/styles.generated.ts +1 -1
  109. package/src/tags.spec.ts +51 -0
  110. package/src/test.browser.mts +25 -0
  111. package/src/value-lists.spec.ts +93 -0
  112. package/lib/types/source-view/hex.d.ts +0 -21
  113. package/src/source-view/hex.ts +0 -37
@@ -16,6 +16,12 @@
16
16
  // no per-block case — a `switch` on a widget name here would be the per-surface branch
17
17
  // § 9 forbids, and the reason the corpus admits a block only when it needs no alias.
18
18
  //
19
+ // `mountSharedTree` is SHIPPED code, not this driver's own — the renderer-specific half of
20
+ // ADR 0051 that any consumer of this package may need (the same move #1726 made for
21
+ // `gtk-host`), so it lives in `./shared-tree-builder.ts` rather than here. Its header
22
+ // explains why THIS renderer needs a mount step the other two do not: these elements build
23
+ // on connect.
24
+ //
19
25
  // The readers below go through the REAL DOM the element rendered, never a state object of
20
26
  // its own: an element asserting against its own bookkeeping agrees with itself while the
21
27
  // page is wrong.
@@ -27,59 +33,43 @@
27
33
 
28
34
  import { describe, expect, it } from '@gjsify/unit';
29
35
 
36
+ import { parseBlueprint, projectToSharedNode } from '@gjsify/blueprint';
30
37
  import {
38
+ ADJUSTMENT_AUTHORED_VECTORS,
31
39
  authoredTags,
32
40
  sharedTreeExpectations,
41
+ sharedTreePlacements,
33
42
  subjectIndexOf,
43
+ TOGGLE_ACTIVE_NAME_VECTORS,
44
+ withoutPlacements,
34
45
  type SharedTreeExpectation,
35
46
  type SharedTreeNode,
36
47
  } from '@gjsify/adwaita-core/conformance';
48
+ import { attributeOf, hostTagOf } from '@gjsify/adwaita-core/tags';
37
49
 
38
- // `attributeOf` — `buttonLabel` -> `button-label` — comes from the corpus rather than
39
- // being spelled again here, because arm 13 of `check-generated-website-data.mjs` reads
40
- // gallery fences back through the SAME rule: two copies would let this driver and that
41
- // check agree with each other while the fence a reader copies means something else.
42
- import {
43
- ADWAITA_GALLERY_SHARED_TREES,
44
- attributeOf,
45
- hostTagOf,
46
- } from '../../../../scripts/adwaita-gallery-shared-trees.mjs';
50
+ // The corpus data itself, not the transforms: `scripts/` still owns the ONE gallery corpus
51
+ // (ADR 0051), and this driver reads it here rather than transcribing it, the same as the
52
+ // GTK and NativeScript drivers do.
53
+ import { ADWAITA_GALLERY_SHARED_TREES } from '../../../../scripts/adwaita-gallery-shared-trees.mjs';
47
54
 
48
55
  import '@gjsify/adwaita-web';
49
56
 
50
- /**
51
- * The whole renderer-specific half of this driver: an element, its authored properties as
52
- * attributes, its children, in that order.
53
- *
54
- * A boolean is the ATTRIBUTE'S PRESENCE, which is what every element here reads
55
- * (`hasAttribute('revealed')`, `hasAttribute('expanded')`) — spelling `"true"` would set a
56
- * present attribute for `false` as well.
57
- */
58
- function build(node: SharedTreeNode): HTMLElement {
59
- const el = document.createElement(hostTagOf(node.tag));
60
- for (const [prop, value] of Object.entries(node.props ?? {})) {
61
- if (typeof value === 'boolean') el.toggleAttribute(attributeOf(prop), value);
62
- else el.setAttribute(attributeOf(prop), String(value));
63
- }
64
- for (const child of node.children ?? []) el.append(build(child));
65
- return el;
66
- }
57
+ import { mountSharedTree } from './shared-tree-builder.js';
67
58
 
68
59
  /**
69
60
  * Connect the authored root — these elements build on connect — run, and take it down again.
70
61
  *
71
62
  * The teardown is in a `finally` because a RED test must not leave a mounted tree behind:
72
63
  * the next test would then be reading a document two blocks deep, and the failure it
73
- * reported would name the wrong renderer.
64
+ * reported would name the wrong renderer. `mountSharedTree` is the shipped instantiation
65
+ * half; the `finally` around it is this suite's own isolation POLICY, not part of it.
74
66
  */
75
67
  function mounted<T>(node: SharedTreeNode, use: (root: Element) => T): T {
76
- const host = document.createElement('div');
77
- host.append(build(node));
78
- document.body.append(host);
68
+ const { root, unmount } = mountSharedTree(node);
79
69
  try {
80
- return use(host.firstElementChild!);
70
+ return use(root);
81
71
  } finally {
82
- host.remove();
72
+ unmount();
83
73
  }
84
74
  }
85
75
 
@@ -116,6 +106,26 @@ function realised(root: Element, wanted: readonly string[]): Element[] {
116
106
  return [root, ...root.querySelectorAll('*')].filter((el) => set.has(el.tagName.toLowerCase()));
117
107
  }
118
108
 
109
+ /**
110
+ * A Blueprint source as the `?shared-tree` door hands it over: projected by the one
111
+ * projection `@gjsify/vite-plugin-blueprint` serves, and refused when it dropped anything,
112
+ * as that door refuses it. Projected here rather than imported as a `.blp` file so these
113
+ * fixtures stay out of ADR 0053's census of shipped `.blp` files. The NativeScript driver
114
+ * (`packages/nativescript-bridge/adwaita/src/shared-trees.spec.ts`) builds the same sources.
115
+ */
116
+ function blueprintTree(body: string): SharedTreeNode {
117
+ const { node, lost } = projectToSharedNode(
118
+ parseBlueprint(`using Gtk 4.0;\nusing Adw 1;\n\n${body}\n`, 'shared-trees.spec.blp'),
119
+ );
120
+ if (lost.length > 0) {
121
+ throw new Error(`the projection dropped ${lost.map((loss) => `${loss.kind} at line ${loss.line}`).join(', ')}`);
122
+ }
123
+ return node as SharedTreeNode;
124
+ }
125
+
126
+ /** The ids of an element's children, in DOM order. */
127
+ const childIds = (el: Element | null): string[] => Array.from(el?.children ?? [], (child) => child.id);
128
+
119
129
  export const AdwSharedTreesTest = async () => {
120
130
  await describe('the shared corpus builds through adwaita-web', async () => {
121
131
  for (const block of ADWAITA_GALLERY_SHARED_TREES) {
@@ -130,6 +140,260 @@ export const AdwSharedTreesTest = async () => {
130
140
  }
131
141
  });
132
142
 
143
+ // THE TAGS A REAL `.blp` WRITES, through the real projection — the same sources the
144
+ // NativeScript driver builds, so the two renderers answer the same trees.
145
+ await describe('a .blp tree with the tags the builders learned', async () => {
146
+ await it('Gtk.Adjustment at `adjustment:` becomes the spin row range, and the value the row shows', () => {
147
+ const tree = blueprintTree(
148
+ 'Adw.SpinRow { title: "Size"; adjustment: Adjustment { lower: 2; upper: 10; value: 4; step-increment: 2; }; }',
149
+ );
150
+ mounted(tree, (root) => {
151
+ expect((root as unknown as { adjustment: unknown }).adjustment).toStrictEqual({
152
+ value: 4,
153
+ lower: 2,
154
+ upper: 10,
155
+ stepIncrement: 2,
156
+ pageIncrement: 2,
157
+ pageSize: 0,
158
+ });
159
+ expect(root.querySelector('input')?.value).toBe('4');
160
+ // Consumed as data, as GtkBuilder leaves no adjustment in the widget tree.
161
+ expect(root.querySelector('gtk-adjustment')).toBe(null);
162
+ });
163
+ });
164
+
165
+ for (const vector of ADJUSTMENT_AUTHORED_VECTORS) {
166
+ await it(`ADJUSTMENT_AUTHORED_VECTORS through a .blp — ${vector.rule}`, () => {
167
+ const fields = Object.entries(vector.input)
168
+ .map(([field, value]) => `${attributeOf(field)}: ${String(value)};`)
169
+ .join(' ');
170
+ mounted(blueprintTree(`Adw.SpinRow { adjustment: Adjustment { ${fields} }; }`), (root) => {
171
+ expect((root as unknown as { adjustment: unknown }).adjustment).toStrictEqual(vector.adjustment);
172
+ });
173
+ });
174
+ }
175
+
176
+ for (const vector of TOGGLE_ACTIVE_NAME_VECTORS) {
177
+ await it(`TOGGLE_ACTIVE_NAME_VECTORS — ${vector.rule}`, () => {
178
+ const toggles = vector.names
179
+ .map(
180
+ (name, index) =>
181
+ `Adw.Toggle { ${name === null ? '' : `name: "${name}"; `}label: "T${index}"; }`,
182
+ )
183
+ .join(' ');
184
+ const tree = blueprintTree(`Adw.ToggleGroup { active-name: "${vector.activeName}"; ${toggles} }`);
185
+ mounted(tree, (root) => {
186
+ // The ACTIVE BUTTON, read off the DOM — not the element's index.
187
+ const buttons = Array.from(root.querySelectorAll('button.adw-toggle'));
188
+ expect(buttons.length).toBe(vector.kept);
189
+ expect(buttons.findIndex((button) => button.classList.contains('active'))).toBe(vector.active);
190
+ });
191
+ });
192
+ }
193
+
194
+ await it('Adw.Toggle carries its label into the button it becomes', () => {
195
+ const tree = blueprintTree(
196
+ 'Adw.ToggleGroup { active: 1; Adw.Toggle { label: "List"; icon-name: "view-list-symbolic"; } Adw.Toggle { label: "Grid"; } }',
197
+ );
198
+ mounted(tree, (root) => {
199
+ const buttons = Array.from(root.querySelectorAll('button.adw-toggle'));
200
+ expect(buttons.map((button) => button.textContent)).toStrictEqual(['List', 'Grid']);
201
+ expect(buttons[1]?.classList.contains('active')).toBe(true);
202
+ expect(root.querySelector('gtk-image') !== null).toBe(true);
203
+ });
204
+ });
205
+
206
+ await it('Adw.NavigationPage brings its tag, title, can-pop and child into the navigation view', () => {
207
+ const tree = blueprintTree(`Adw.NavigationView {
208
+ Adw.NavigationPage { tag: "home"; title: "Home"; child: Gtk.Label { label: "Welcome"; }; }
209
+ Adw.NavigationPage { tag: "details"; title: "Details"; can-pop: false; child: Gtk.Label { label: "More"; }; }
210
+ }`);
211
+ mounted(tree, (root) => {
212
+ const view = root as unknown as {
213
+ pages: readonly Element[];
214
+ visiblePageTag: string | null;
215
+ pushByTag(tag: string): boolean;
216
+ canGoBack: boolean;
217
+ };
218
+ expect(view.pages.length).toBe(2);
219
+ expect(view.visiblePageTag).toBe('home');
220
+ expect(view.pages[0]?.querySelector('gtk-label')?.getAttribute('label')).toBe('Welcome');
221
+ expect(view.pushByTag('details')).toBe(true);
222
+ expect(view.visiblePageTag).toBe('details');
223
+ // `can-pop: false` is a GTK default of TRUE authored false — the case
224
+ // absence cannot spell, so the builder writes it through `canPop`.
225
+ expect(view.canGoBack).toBe(false);
226
+ });
227
+ });
228
+
229
+ await it('Adw.SidebarSection and Adw.SidebarItem become the sidebar model and its rows', () => {
230
+ const tree = blueprintTree(`Adw.Sidebar {
231
+ Adw.SidebarSection { title: "Places"; Adw.SidebarItem { title: "Home"; icon-name: "go-home-symbolic"; } Adw.SidebarItem { title: "Trash"; enabled: false; } }
232
+ Adw.SidebarSection { Adw.SidebarItem { title: "Hidden"; visible: false; } Adw.SidebarItem { title: "Music"; subtitle: "Library"; } }
233
+ }`);
234
+ mounted(tree, (root) => {
235
+ const sections = (
236
+ root as unknown as {
237
+ sections: readonly {
238
+ title?: string;
239
+ items: readonly { title: string; enabled?: boolean; visible?: boolean }[];
240
+ }[];
241
+ }
242
+ ).sections;
243
+ expect(sections.map((section) => section.title)).toStrictEqual(['Places', '']);
244
+ expect(sections.flatMap((section) => section.items.map((item) => item.title))).toStrictEqual([
245
+ 'Home',
246
+ 'Trash',
247
+ 'Hidden',
248
+ 'Music',
249
+ ]);
250
+ expect(sections[0]?.items[1]?.enabled).toBe(false);
251
+ expect(sections[1]?.items[0]?.visible).toBe(false);
252
+ // The ROWS: the hidden item's row is hidden, the disabled one insensitive.
253
+ const rows = Array.from(root.querySelectorAll<HTMLButtonElement>('button.adw-sidebar-item'));
254
+ expect(rows.map((row) => row.querySelector('.adw-sidebar-item-title')?.textContent)).toStrictEqual([
255
+ 'Home',
256
+ 'Trash',
257
+ 'Hidden',
258
+ 'Music',
259
+ ]);
260
+ expect(rows.map((row) => row.hidden)).toStrictEqual([false, false, true, false]);
261
+ expect(rows.map((row) => row.disabled)).toStrictEqual([false, true, false, false]);
262
+ });
263
+ });
264
+
265
+ await it('Adw.TabPage becomes a titled page around its child; a bare widget an untitled one', () => {
266
+ const tree = blueprintTree(`Adw.TabView {
267
+ Adw.TabPage { title: "Inbox"; child: Gtk.Label inbox { label: "3 new"; }; }
268
+ Adw.TabPage { title: "Drafts"; child: Gtk.Label drafts { label: "None"; }; }
269
+ Gtk.Label bare { label: "Loose"; }
270
+ }`);
271
+ mounted(tree, (root) => {
272
+ const pages = (root as unknown as { pages: readonly { title: string; content?: Element }[] }).pages;
273
+ expect(pages.map((page) => page.title)).toStrictEqual(['Inbox', 'Drafts', '']);
274
+ expect(
275
+ pages.map((page) => page.content?.querySelector('gtk-label')?.id ?? page.content?.id),
276
+ ).toStrictEqual(['inbox', 'drafts', 'bare']);
277
+ });
278
+ });
279
+
280
+ await it('Gtk.ActionBar packs [start], [end], [center] and an untyped child as GtkBuildable does', () => {
281
+ const tree = blueprintTree(`Gtk.ActionBar {
282
+ revealed: false;
283
+ [start] Gtk.Button a {}
284
+ [end] Gtk.Button c {}
285
+ [start] Gtk.Button b {}
286
+ [end] Gtk.Button d {}
287
+ Gtk.Button e {}
288
+ [center] Gtk.Label centre { label: "Centre"; }
289
+ }`);
290
+ mounted(tree, (root) => {
291
+ expect(childIds(root.querySelector('.adw-action-bar-start'))).toStrictEqual(['a', 'b', 'e']);
292
+ expect(childIds(root.querySelector('.adw-action-bar-end'))).toStrictEqual(['d', 'c']);
293
+ expect(childIds(root.querySelector('.adw-action-bar-center'))).toStrictEqual(['centre']);
294
+ expect((root as HTMLElement).hidden).toBe(true);
295
+ });
296
+ });
297
+
298
+ await it('an authored false on a getter-only property keeps the presence rule instead of throwing', () => {
299
+ // `<adw-split-button>` exposes `active` as a read-only getter; assigning through it
300
+ // threw a bare TypeError out of the builder before the writability check.
301
+ mounted({ tag: 'AdwSplitButton', props: { active: false } }, (root) => {
302
+ expect(root.hasAttribute('active')).toBe(false);
303
+ });
304
+ });
305
+
306
+ await it('`center-widget:` is refused — GTK has no such property, so Gtk.Builder refuses it too', () => {
307
+ expect(() => mountSharedTree(blueprintTree('Gtk.ActionBar { center-widget: Gtk.Label {}; }'))).toThrow(
308
+ 'has no slot "center-widget"',
309
+ );
310
+ });
311
+ });
312
+
313
+ await describe('the shared corpus is placed where it says', async () => {
314
+ // A CONTROL, not a table of where each slot lands. `withoutPlacements` is the tree a
315
+ // builder that never read `slot` hands this renderer — which is what all three
316
+ // builders did — so the two realised DOMs being identical is exactly that
317
+ // regression. NESTING AND ELEMENT NAME ONLY, because the `slot=` attribute is itself
318
+ // part of what the placed build writes: comparing markup would differ on the
319
+ // attribute alone and pass while the widget sat beside its destination.
320
+ const structure = (el: Element): string => `${el.localName}[${Array.from(el.children, structure).join(',')}]`;
321
+
322
+ for (const block of ADWAITA_GALLERY_SHARED_TREES) {
323
+ const placements = sharedTreePlacements(block.root);
324
+ if (placements.length === 0) continue;
325
+ const named = placements.map(({ path, slot }) => `${path} -> ${slot}`).join(', ');
326
+ await it(`${block.widget} — ${named}: the mounted DOM is not the unplaced one`, () => {
327
+ const placed = mounted(block.root, structure);
328
+ const unplaced = mounted(withoutPlacements(block.root), structure);
329
+
330
+ expect(placed === unplaced).toBe(false);
331
+ });
332
+ }
333
+
334
+ // Otherwise the loop above is green from emptiness — the corpus authored no slot at
335
+ // all until a `.blp` forced the question.
336
+ await it('the corpus authors a placement at all', () => {
337
+ expect(ADWAITA_GALLERY_SHARED_TREES.some((block) => sharedTreePlacements(block.root).length > 0)).toBe(
338
+ true,
339
+ );
340
+ });
341
+
342
+ // THE OTHER HALF OF READING A SLOT, and the corpus cannot carry it: a name this
343
+ // renderer has no destination for. `bindSlottedChildren` copies the NATIVE rule — an
344
+ // unmatched name is assigned nowhere and the child stays put — which is right for
345
+ // hand-written markup and says nothing to a builder realising an authored tree.
346
+ await it('a placement this renderer has no destination for is refused BY NAME', () => {
347
+ expect(() =>
348
+ mountSharedTree({ tag: 'AdwToolbarView', children: [{ tag: 'AdwBanner', slot: 'middle' }] }),
349
+ ).toThrow('has no slot "middle"');
350
+ });
351
+
352
+ // `child: Gtk.Label {…}` in a `.blp` authors `slot: 'child'` — the widget's own
353
+ // GObject property name — for every single-child widget the corpus never places
354
+ // one on. `<adw-clamp>` has no separate box to route into (every child is already
355
+ // the placement, clamped in place), which is why it took no `bindSlottedChildren`
356
+ // call at all until this slot needed one — and why it stayed unnoticed: the corpus
357
+ // above authors zero placements on it.
358
+ await it("AdwClamp accepts its GIR child property, 'child', as a named slot", () => {
359
+ mounted(
360
+ { tag: 'AdwClamp', children: [{ tag: 'GtkLabel', slot: 'child', props: { label: 'Clamped' } }] },
361
+ (root) => {
362
+ expect(root.querySelector('gtk-label')?.getAttribute('label')).toBe('Clamped');
363
+ },
364
+ );
365
+ });
366
+
367
+ await it("AdwStatusPage accepts its GIR child property, 'child', as a named slot", () => {
368
+ mounted(
369
+ { tag: 'AdwStatusPage', children: [{ tag: 'GtkButton', slot: 'child', props: { label: 'Retry' } }] },
370
+ (root) => {
371
+ expect(root.querySelector('.adw-status-page-child gtk-button')?.getAttribute('label')).toBe(
372
+ 'Retry',
373
+ );
374
+ },
375
+ );
376
+ });
377
+
378
+ await it('the PROPERTY spelling of the title slot reaches the centre', () => {
379
+ // `title-widget:` is what a Blueprint source writes the centre at, and this
380
+ // element spells its own centre `center`; naming both is the element's own
381
+ // declaration, not a table in the builder.
382
+ //
383
+ // READ BY THE AUTHORED VALUE, not by the tag: an unrouted child is destroyed by
384
+ // the bar's own `replaceChildren`, and the bar then DERIVES an `<adw-window-title>`
385
+ // into the same centre — so a test asking only where an `<adw-window-title>` sits
386
+ // passed against the derived one with the authored one gone. Measured, by dropping
387
+ // the builder's `slot=` write: green.
388
+ const authored = { tag: 'AdwWindowTitle', slot: 'title-widget', props: { title: 'Placed' } };
389
+ mounted({ tag: 'AdwHeaderBar', children: [authored] }, (root) => {
390
+ const title = root.querySelector('adw-window-title[title="Placed"]');
391
+
392
+ expect(title?.parentElement?.className).toBe('adw-header-bar-center');
393
+ });
394
+ });
395
+ });
396
+
133
397
  await describe('the shared corpus against the adwaita-core vectors it reaches', async () => {
134
398
  for (const block of ADWAITA_GALLERY_SHARED_TREES) {
135
399
  for (const expectation of sharedTreeExpectations(block.root)) {
@@ -33,7 +33,7 @@ import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
33
33
 
34
34
  import { asm6502 } from './asm6502.js';
35
35
  import { adwaitaEditorTheme, adwaitaHighlightStyle, ensureSourceViewStyleInjected } from './theme.js';
36
- import { formatHexAddress, formatLineNumber, stripWhitespace } from './hex.js';
36
+ import { formatHexAddress, formatLineNumber, stripSourceWhitespace } from '@gjsify/adwaita-core';
37
37
  import { createGtkImage } from '../elements/gtk-image.js';
38
38
 
39
39
  /** A recognisable copy glyph rendered inline so the subpath needs no icon CSS. */
@@ -379,7 +379,7 @@ export class AdwSourceView extends HTMLElement {
379
379
  const { from, to } = view.state.selection.main;
380
380
  if (from === to) return false;
381
381
  const selected = view.state.sliceDoc(from, to);
382
- event.clipboardData?.setData('text/plain', stripWhitespace(selected));
382
+ event.clipboardData?.setData('text/plain', stripSourceWhitespace(selected));
383
383
  event.preventDefault();
384
384
  return true;
385
385
  },
@@ -391,7 +391,7 @@ export class AdwSourceView extends HTMLElement {
391
391
  }
392
392
 
393
393
  private _onCopyButtonClick(): void {
394
- const text = this._hexAddresses ? stripWhitespace(this.code) : this.code;
394
+ const text = this._hexAddresses ? stripSourceWhitespace(this.code) : this.code;
395
395
  const clipboard = (globalThis.navigator as Navigator | undefined)?.clipboard;
396
396
  if (clipboard?.writeText) void clipboard.writeText(text).catch(() => undefined);
397
397
  this.dispatchEvent(new CustomEvent('copy', { bubbles: true, detail: { code: this.code } }));
@@ -1,8 +1,14 @@
1
- // A CodeMirror 6 StreamLanguage mode for 6502 assembly — the web twin of the
1
+ // The CodeMirror 6 binding for the 6502 assembly mode — the web twin of the
2
2
  // GtkSourceView `6502-assembler` language definition. A stream tokenizer is
3
3
  // enough here: 6502 asm is line-oriented and needs no nested grammar, so we
4
4
  // avoid a full Lezer grammar (per the task brief).
5
5
  //
6
+ // The LANGUAGE itself — the opcode/register/directive tables and the tokenizer —
7
+ // is not here. It lives in `@gjsify/adwaita-core` (`source.ts`), typed on an
8
+ // eight-member stream interface that CodeMirror's `StringStream` satisfies
9
+ // structurally, so a second renderer can highlight 6502 without CodeMirror and
10
+ // this file stays what it claims to be: a binding.
11
+ //
6
12
  // Token classes (mapped to @lezer/highlight tags via `asm6502TokenTable`):
7
13
  // opcode — the 56 official mnemonics (LDA, STA, JMP, …) → keyword
8
14
  // register — the accumulator/index registers A, X, Y → atom
@@ -13,197 +19,11 @@
13
19
  // label — a definition (`loop:` or a bare name at column 0) → labelName
14
20
  // operator — the immediate `#`, arithmetic + addressing punct → operator
15
21
  // identifier — any other symbol reference (a named operand) → variableName
16
- //
17
- // Reference: refs/gjs-guide + the GtkSourceView `.lang` grammar shape.
18
22
 
19
- import { StreamLanguage, type StreamParser, type StringStream } from '@codemirror/language';
23
+ import { StreamLanguage, type StreamParser } from '@codemirror/language';
20
24
  import { type Extension } from '@codemirror/state';
21
25
  import { tags, type Tag } from '@lezer/highlight';
22
-
23
- /** The 56 official NMOS 6502 mnemonics (uppercased for case-insensitive match). */
24
- export const OPCODES: ReadonlySet<string> = new Set([
25
- 'ADC',
26
- 'AND',
27
- 'ASL',
28
- 'BCC',
29
- 'BCS',
30
- 'BEQ',
31
- 'BIT',
32
- 'BMI',
33
- 'BNE',
34
- 'BPL',
35
- 'BRK',
36
- 'BVC',
37
- 'BVS',
38
- 'CLC',
39
- 'CLD',
40
- 'CLI',
41
- 'CLV',
42
- 'CMP',
43
- 'CPX',
44
- 'CPY',
45
- 'DEC',
46
- 'DEX',
47
- 'DEY',
48
- 'EOR',
49
- 'INC',
50
- 'INX',
51
- 'INY',
52
- 'JMP',
53
- 'JSR',
54
- 'LDA',
55
- 'LDX',
56
- 'LDY',
57
- 'LSR',
58
- 'NOP',
59
- 'ORA',
60
- 'PHA',
61
- 'PHP',
62
- 'PLA',
63
- 'PLP',
64
- 'ROL',
65
- 'ROR',
66
- 'RTI',
67
- 'RTS',
68
- 'SBC',
69
- 'SEC',
70
- 'SED',
71
- 'SEI',
72
- 'STA',
73
- 'STX',
74
- 'STY',
75
- 'TAX',
76
- 'TAY',
77
- 'TSX',
78
- 'TXA',
79
- 'TXS',
80
- 'TYA',
81
- ]);
82
-
83
- /** The registers that appear as bare operands (`ASL A`, `LDA $00,X`). */
84
- export const REGISTERS: ReadonlySet<string> = new Set(['A', 'X', 'Y']);
85
-
86
- /** Assembler pseudo-ops. `define`/`dcb` are the easy6502 dialect; the rest are
87
- * common cross-assembler directives (with the leading dot handled separately). */
88
- export const DIRECTIVE_WORDS: ReadonlySet<string> = new Set([
89
- 'DEFINE',
90
- 'DCB',
91
- 'ORG',
92
- 'BYTE',
93
- 'WORD',
94
- 'DB',
95
- 'DW',
96
- 'DS',
97
- 'ASCII',
98
- 'ASCIIZ',
99
- 'TEXT',
100
- 'INCLUDE',
101
- 'INCBIN',
102
- 'EQU',
103
- 'END',
104
- 'PROC',
105
- 'ENDPROC',
106
- 'MACRO',
107
- 'ENDMACRO',
108
- ]);
109
-
110
- /**
111
- * Classify an identifier-shaped token. Pure + side-effect-free so it is unit
112
- * testable without a CodeMirror stream. `atLineStart` decides whether an
113
- * unknown word is a label DEFINITION (column 0) or a symbol REFERENCE.
114
- */
115
- export function classifyWord(word: string, atLineStart: boolean): string {
116
- const upper = word.toUpperCase();
117
- if (OPCODES.has(upper)) return 'opcode';
118
- if (DIRECTIVE_WORDS.has(upper)) return 'directive';
119
- if (REGISTERS.has(upper)) return 'register';
120
- if (atLineStart) return 'label';
121
- return 'identifier';
122
- }
123
-
124
- /**
125
- * Tokenize one token from the stream. Exported for unit tests (feed it a
126
- * `StringStream`). Returns a token-class name (see file header) or `null` for
127
- * whitespace/unclassified punctuation.
128
- */
129
- export function token6502(stream: StringStream): string | null {
130
- const atLineStart = stream.sol();
131
- if (stream.eatSpace()) return null;
132
-
133
- const ch = stream.peek();
134
- if (ch === null || ch === undefined) return null;
135
-
136
- // `; …` line comment.
137
- if (ch === ';') {
138
- stream.skipToEnd();
139
- return 'comment';
140
- }
141
-
142
- // Quoted string (used by `.ascii "…"` / `.byte "…"`).
143
- if (ch === '"' || ch === "'") {
144
- const quote = ch;
145
- stream.next();
146
- let escaped = false;
147
- while (!stream.eol()) {
148
- const c = stream.next();
149
- if (c === undefined) break;
150
- if (c === quote && !escaped) break;
151
- escaped = c === '\\' && !escaped;
152
- }
153
- return 'string';
154
- }
155
-
156
- // Immediate-addressing prefix.
157
- if (ch === '#') {
158
- stream.next();
159
- return 'operator';
160
- }
161
-
162
- // Hex literal `$1a2b`.
163
- if (ch === '$') {
164
- stream.next();
165
- stream.eatWhile(/[0-9a-fA-F]/);
166
- return 'number';
167
- }
168
-
169
- // Binary literal `%1010`.
170
- if (ch === '%') {
171
- stream.next();
172
- stream.eatWhile(/[01]/);
173
- return 'number';
174
- }
175
-
176
- // Decimal literal.
177
- if (/[0-9]/.test(ch)) {
178
- stream.eatWhile(/[0-9]/);
179
- return 'number';
180
- }
181
-
182
- // Dot-directive `.org` / `.byte`.
183
- if (ch === '.') {
184
- stream.next();
185
- if (stream.eatWhile(/[A-Za-z0-9_]/)) return 'directive';
186
- return null;
187
- }
188
-
189
- // Identifier → opcode / register / directive / label / reference.
190
- if (/[A-Za-z_]/.test(ch)) {
191
- stream.eatWhile(/[A-Za-z0-9_]/);
192
- const word = stream.current();
193
- // An explicit `name:` is always a label definition.
194
- if (stream.peek() === ':') return 'label';
195
- return classifyWord(word, atLineStart);
196
- }
197
-
198
- // Arithmetic / addressing punctuation.
199
- if ('+-*/=<>,()[]&|^~'.includes(ch)) {
200
- stream.next();
201
- return 'operator';
202
- }
203
-
204
- stream.next();
205
- return null;
206
- }
26
+ import { ADW_SOURCE_6502_LINE_COMMENT, tokenizeAsm6502 } from '@gjsify/adwaita-core';
207
27
 
208
28
  /** Maps this mode's token-class names to @lezer/highlight tags. */
209
29
  export const asm6502TokenTable: Record<string, Tag> = {
@@ -221,9 +41,9 @@ export const asm6502TokenTable: Record<string, Tag> = {
221
41
  /** The StreamParser powering the 6502 mode. */
222
42
  export const asm6502Parser: StreamParser<unknown> = {
223
43
  name: '6502',
224
- token: (stream) => token6502(stream),
44
+ token: (stream) => tokenizeAsm6502(stream),
225
45
  tokenTable: asm6502TokenTable,
226
- languageData: { commentTokens: { line: ';' } },
46
+ languageData: { commentTokens: { line: ADW_SOURCE_6502_LINE_COMMENT } },
227
47
  };
228
48
 
229
49
  /** The 6502 assembly language extension for a CodeMirror EditorState. */
@@ -8,16 +8,7 @@ export { AdwSourceView } from './adw-source-view.js';
8
8
 
9
9
  // Building blocks, exported so apps/tests can reuse them (e.g. embed the 6502
10
10
  // mode or the Adwaita theme in a bespoke EditorView).
11
- export {
12
- asm6502,
13
- asm6502Parser,
14
- asm6502TokenTable,
15
- classifyWord,
16
- token6502,
17
- OPCODES,
18
- REGISTERS,
19
- DIRECTIVE_WORDS,
20
- } from './asm6502.js';
11
+ export { asm6502, asm6502Parser, asm6502TokenTable } from './asm6502.js';
21
12
  export {
22
13
  adwaitaEditorTheme,
23
14
  adwaitaHighlightStyle,
@@ -25,4 +16,19 @@ export {
25
16
  SOURCE_VIEW_CSS,
26
17
  SOURCE_VIEW_STYLE_ID,
27
18
  } from './theme.js';
28
- export { formatHexAddress, formatLineNumber, stripWhitespace, HEX_ADDRESS_STRIDE } from './hex.js';
19
+
20
+ // The renderer-free half — language tables, tokenizer, gutter formatters — is
21
+ // owned by `@gjsify/adwaita-core`. Re-exported here under the spellings this
22
+ // subpath has always published, so nothing that imports them has to move.
23
+ export {
24
+ ADW_SOURCE_6502_DIRECTIVES as DIRECTIVE_WORDS,
25
+ ADW_SOURCE_6502_OPCODES as OPCODES,
26
+ ADW_SOURCE_6502_REGISTERS as REGISTERS,
27
+ ADW_SOURCE_HEX_STRIDE as HEX_ADDRESS_STRIDE,
28
+ classifyAsm6502Word as classifyWord,
29
+ formatHexAddress,
30
+ formatLineNumber,
31
+ stripSourceWhitespace as stripWhitespace,
32
+ tokenizeAsm6502 as token6502,
33
+ } from '@gjsify/adwaita-core';
34
+ export type { AdwSourceTokenClass, AdwSourceTokenStream } from '@gjsify/adwaita-core';