@gjsify/adwaita-web 0.60.0 → 0.61.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 (43) hide show
  1. package/README.md +11 -0
  2. package/lib/types/elements/gtk-drop-down.d.ts +4 -0
  3. package/lib/types/elements/gtk-label.d.ts +4 -0
  4. package/lib/types/elements/gtk-text-view.d.ts +15 -0
  5. package/lib/types/file-chooser-host.d.ts +55 -0
  6. package/lib/types/file-chooser.spec.d.ts +1 -0
  7. package/lib/types/gtk-platform.spec.d.ts +1 -0
  8. package/lib/types/namespace/adw.d.ts +1 -0
  9. package/lib/types/namespace/gdk.d.ts +2 -0
  10. package/lib/types/namespace/gio.d.ts +3 -0
  11. package/lib/types/namespace/glib.d.ts +1 -0
  12. package/lib/types/namespace/gtk-enums.d.ts +22 -0
  13. package/lib/types/namespace/gtk-platform.d.ts +13 -0
  14. package/lib/types/namespace/gtk.d.ts +4 -1
  15. package/lib/types/platform-host.d.ts +2 -0
  16. package/lib/types/style-manager.d.ts +7 -0
  17. package/package.json +18 -9
  18. package/src/capabilities.mjs +1 -4
  19. package/src/construct-vectors.spec.ts +10 -1
  20. package/src/elements/gtk-drop-down.ts +10 -0
  21. package/src/elements/gtk-label.ts +10 -0
  22. package/src/elements/gtk-text-view.ts +40 -0
  23. package/src/file-chooser-host.ts +421 -0
  24. package/src/file-chooser.spec.ts +376 -0
  25. package/src/gobject-door.ts +7 -1
  26. package/src/gobject-elements.ts +4 -0
  27. package/src/gtk-drop-down.spec.ts +11 -0
  28. package/src/gtk-label.spec.ts +12 -0
  29. package/src/gtk-platform.spec.ts +19 -0
  30. package/src/gtk-text-view.spec.ts +22 -0
  31. package/src/namespace/adw.ts +1 -0
  32. package/src/namespace/gdk.ts +18 -1
  33. package/src/namespace/gio.ts +20 -2
  34. package/src/namespace/glib.ts +6 -1
  35. package/src/namespace/gtk-enums.ts +7 -2
  36. package/src/namespace/gtk-platform.ts +16 -0
  37. package/src/namespace/gtk.ts +12 -1
  38. package/src/platform-host.ts +88 -0
  39. package/src/shared-tree-builder.ts +40 -0
  40. package/src/shared-trees.spec.ts +36 -0
  41. package/src/style-manager.ts +10 -1
  42. package/src/test.browser.mts +4 -0
  43. package/src/widget-members.spec.ts +12 -0
@@ -0,0 +1,88 @@
1
+ // How the web door reaches the platform for `Gtk.StyleContext`, `Gtk.Settings`, `Gtk.show_uri` and
2
+ // `Gtk.EventControllerKey` (the behaviour is in `@gjsify/adwaita-core`, `gtk-platform.ts`).
3
+ //
4
+ // - styles: one `<style>` element per provider, kept in priority order at the end of `<head>`.
5
+ // - uris: `window.open` with `noopener`; a popup blocker may refuse it outside a user gesture.
6
+ // - keys: `keydown` / `keyup` on the element; a handled key gets `preventDefault()`.
7
+
8
+ import type { HostKeyEvent, PlatformHost } from '@gjsify/adwaita-core';
9
+
10
+ const SHIFT_MASK = 1 << 0;
11
+ const CONTROL_MASK = 1 << 2;
12
+ const ALT_MASK = 1 << 3;
13
+ const SUPER_MASK = 1 << 26;
14
+
15
+ const sheets = new Map<object, HTMLStyleElement>();
16
+
17
+ function modifierState(event: KeyboardEvent): number {
18
+ return (
19
+ (event.shiftKey ? SHIFT_MASK : 0) |
20
+ (event.ctrlKey ? CONTROL_MASK : 0) |
21
+ (event.altKey ? ALT_MASK : 0) |
22
+ (event.metaKey ? SUPER_MASK : 0)
23
+ );
24
+ }
25
+
26
+ export const webPlatformHost: PlatformHost = {
27
+ setStyles(list) {
28
+ if (typeof document === 'undefined') return;
29
+ const keep = new Set(list.map((entry) => entry.owner));
30
+ for (const [owner, element] of sheets) {
31
+ if (keep.has(owner)) continue;
32
+ element.remove();
33
+ sheets.delete(owner);
34
+ }
35
+ // Re-appending in order is what makes the later sheet win at equal specificity.
36
+ for (const { owner, css, priority } of list) {
37
+ let element = sheets.get(owner);
38
+ if (!element) {
39
+ element = document.createElement('style');
40
+ sheets.set(owner, element);
41
+ }
42
+ element.dataset.gtkPriority = String(priority);
43
+ if (element.textContent !== css) element.textContent = css;
44
+ document.head.append(element);
45
+ }
46
+ },
47
+
48
+ openUri(uri) {
49
+ if (typeof window === 'undefined') return;
50
+ window.open(uri, '_blank', 'noopener');
51
+ },
52
+
53
+ fontName() {
54
+ if (typeof document === 'undefined' || !document.body) return null;
55
+ const style = getComputedStyle(document.body);
56
+ const family = style.fontFamily
57
+ .split(',')[0]
58
+ ?.trim()
59
+ .replace(/^["']|["']$/g, '');
60
+ const px = Number.parseFloat(style.fontSize);
61
+ if (!family || !Number.isFinite(px)) return null;
62
+ return `${family} ${Math.round(px * 0.75 * 10) / 10}`;
63
+ },
64
+
65
+ listenKeys(widget, handler) {
66
+ const element = widget as HTMLElement;
67
+ // A bare element takes no keys until it can be focused.
68
+ if (!element.hasAttribute('tabindex')) element.setAttribute('tabindex', '-1');
69
+ const listen = (pressed: boolean) => (event: Event) => {
70
+ const key = event as KeyboardEvent;
71
+ const host: HostKeyEvent = {
72
+ key: key.key,
73
+ keycode: key.keyCode,
74
+ state: modifierState(key),
75
+ pressed,
76
+ };
77
+ if (handler(host)) key.preventDefault();
78
+ };
79
+ const down = listen(true);
80
+ const up = listen(false);
81
+ element.addEventListener('keydown', down);
82
+ element.addEventListener('keyup', up);
83
+ return () => {
84
+ element.removeEventListener('keydown', down);
85
+ element.removeEventListener('keyup', up);
86
+ };
87
+ },
88
+ };
@@ -420,11 +420,51 @@ function writeLook(el: HTMLElement, node: SharedTreeNode, menus?: SharedTreeNode
420
420
  writeExtensions(el, node);
421
421
  }
422
422
 
423
+ /** The `GtkGridLayoutChild` properties `layout { }` may carry, and the CSS Grid line each one is. */
424
+ const GRID_CELL: Readonly<Record<string, { property: 'gridRow' | 'gridColumn'; span: boolean }>> = {
425
+ row: { property: 'gridRow', span: false },
426
+ column: { property: 'gridColumn', span: false },
427
+ 'row-span': { property: 'gridRow', span: true },
428
+ 'column-span': { property: 'gridColumn', span: true },
429
+ };
430
+
431
+ /**
432
+ * ADR 0092's `layout { }`: the child's cell in a `Gtk.Grid`. GTK counts from 0 and CSS Grid from 1,
433
+ * and a span is `span N` after the start line. The one layout manager here is the grid, so a
434
+ * layout under any other parent, a key the grid child has no spelling for, or a value that is not
435
+ * a whole number is REFUSED by name: the cell would otherwise be dropped at exit 0.
436
+ */
437
+ function writeGridCell(parent: HTMLElement, childEl: HTMLElement, child: SharedTreeNode): void {
438
+ const layout = child.layout ?? {};
439
+ if (parent.localName !== 'gtk-grid') {
440
+ throw new Error(
441
+ `layout (${Object.keys(layout).join(', ')}) on \`${child.tag}\` under \`${parent.localName}\`: ` +
442
+ "this renderer's only layout manager is the grid, so the placement would be dropped.",
443
+ );
444
+ }
445
+ const cell = { row: 0, column: 0, 'row-span': 1, 'column-span': 1 } as Record<string, number>;
446
+ for (const [key, value] of Object.entries(layout)) {
447
+ if (GRID_CELL[key] === undefined) {
448
+ throw new Error(
449
+ `layout \`${key}\` on \`${child.tag}\` reaches nothing: a grid child takes ` +
450
+ `${Object.keys(GRID_CELL).join(', ')}.`,
451
+ );
452
+ }
453
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
454
+ throw new Error(`layout \`${key}: ${String(value)}\` on \`${child.tag}\` is not a whole number.`);
455
+ }
456
+ cell[key] = value;
457
+ }
458
+ childEl.style.gridRow = `${cell.row + 1} / span ${cell['row-span']}`;
459
+ childEl.style.gridColumn = `${cell.column + 1} / span ${cell['column-span']}`;
460
+ }
461
+
423
462
  /** The built children of `node`, placed (`page`, `slot`) but not yet appended to `el`. */
424
463
  function buildChildren(el: HTMLElement, node: SharedTreeNode, record: BuildRecord): HTMLElement[] {
425
464
  const built: HTMLElement[] = [];
426
465
  for (const child of node.children ?? []) {
427
466
  const childEl = buildNode(child, record);
467
+ if (child.layout !== undefined) writeGridCell(el, childEl, child);
428
468
  if (child.page !== undefined) writePage(el, childEl, child);
429
469
  if (child.slot !== undefined) {
430
470
  childEl.setAttribute('slot', child.slot);
@@ -364,6 +364,42 @@ export const AdwSharedTreesTest = async () => {
364
364
  ).toThrow('has no slot "middle"');
365
365
  });
366
366
 
367
+ // ADR 0092's `layout { }`: a grid child's cell. GTK counts from 0, CSS Grid from 1.
368
+ await it('`layout { }` places a grid child in its cell, spans defaulting to one', () => {
369
+ mounted(
370
+ {
371
+ tag: 'GtkGrid',
372
+ children: [
373
+ { tag: 'GtkLabel', id: 'a', layout: { row: 0, column: 0 } },
374
+ { tag: 'GtkLabel', id: 'b', layout: { row: 1, column: 1, 'column-span': 2 } },
375
+ ],
376
+ },
377
+ (root) => {
378
+ const a = root.querySelector<HTMLElement>('#a')!;
379
+ const b = root.querySelector<HTMLElement>('#b')!;
380
+ expect(a.style.gridRow).toBe('1 / span 1');
381
+ expect(a.style.gridColumn).toBe('1 / span 1');
382
+ expect(b.style.gridRow).toBe('2 / span 1');
383
+ expect(b.style.gridColumn).toBe('2 / span 2');
384
+ },
385
+ );
386
+ });
387
+
388
+ await it('`layout { }` under a parent that is not a grid is refused BY NAME', () => {
389
+ expect(() =>
390
+ mountSharedTree({ tag: 'GtkBox', children: [{ tag: 'GtkLabel', layout: { row: 0, column: 0 } }] }),
391
+ ).toThrow('only layout manager is the grid');
392
+ });
393
+
394
+ await it('a `layout { }` key or value the grid child cannot take is refused BY NAME', () => {
395
+ expect(() =>
396
+ mountSharedTree({ tag: 'GtkGrid', children: [{ tag: 'GtkLabel', layout: { colum: 0 } }] }),
397
+ ).toThrow('layout `colum`');
398
+ expect(() =>
399
+ mountSharedTree({ tag: 'GtkGrid', children: [{ tag: 'GtkLabel', layout: { row: 1.5 } }] }),
400
+ ).toThrow('is not a whole number');
401
+ });
402
+
367
403
  // `child: Gtk.Label {…}` in a `.blp` authors `slot: 'child'` — the widget's own
368
404
  // GObject property name — for every single-child widget the corpus never places
369
405
  // one on. `<adw-clamp>` has no separate box to route into (every child is already
@@ -3,7 +3,7 @@
3
3
  // core's (`style-manager.ts`); this file is the SOURCE: the root's `class` attribute and the media
4
4
  // query are the two things that can flip it. Without a `document` (SSR) it is light and never changes.
5
5
 
6
- import { StyleManagerBase, type StyleSource } from '@gjsify/adwaita-core';
6
+ import { ColorScheme, StyleManagerBase, type StyleSource } from '@gjsify/adwaita-core';
7
7
 
8
8
  import { isAdwaitaDark } from './accent.js';
9
9
 
@@ -22,6 +22,15 @@ export const webStyleSource: StyleSource = {
22
22
  },
23
23
  };
24
24
 
25
+ // FORCE_DARK / FORCE_LIGHT write the same root class an app writes by hand; DEFAULT takes it away.
26
+ webStyleSource.force = (dark) => {
27
+ if (typeof document === 'undefined') return;
28
+ const root = document.documentElement;
29
+ root.classList.remove('theme-dark', 'theme-light');
30
+ if (dark !== null) root.classList.add(dark ? 'theme-dark' : 'theme-light');
31
+ };
32
+
25
33
  export class AdwStyleManager extends StyleManagerBase {
34
+ static readonly ColorScheme = ColorScheme;
26
35
  static override source = webStyleSource;
27
36
  }
@@ -142,8 +142,10 @@ import { AdwConstructVectorsTest } from './construct-vectors.spec.js';
142
142
  import { GObjectDoorTest } from './gobject-door.spec.js';
143
143
  import { ApplicationTest } from './application.spec.js';
144
144
  import { GdkTest } from './gdk.spec.js';
145
+ import { FileChooserTest } from './file-chooser.spec.js';
145
146
  import { WidgetMembersTest } from './widget-members.spec.js';
146
147
  import { GLibTest } from './glib.spec.js';
148
+ import { GtkPlatformTest } from './gtk-platform.spec.js';
147
149
  import { StyleManagerTest } from './style-manager.spec.js';
148
150
  import { GioActionsTest } from './gio.spec.js';
149
151
  import { MenuActionsTest } from './menu-actions.spec.js';
@@ -163,8 +165,10 @@ run({
163
165
  GObjectDoorTest,
164
166
  ApplicationTest,
165
167
  GdkTest,
168
+ FileChooserTest,
166
169
  WidgetMembersTest,
167
170
  GLibTest,
171
+ GtkPlatformTest,
168
172
  StyleManagerTest,
169
173
  GioActionsTest,
170
174
  MenuActionsTest,
@@ -68,6 +68,18 @@ export const WidgetMembersTest = async () => {
68
68
  expect(parent.get_parent()).toBe(null);
69
69
  });
70
70
 
71
+ await it('adds, tests and removes a CSS class with the GTK names', () => {
72
+ const widget = make('WidgetMembersCss') as unknown as {
73
+ add_css_class(c: string): void;
74
+ remove_css_class(c: string): void;
75
+ has_css_class(c: string): boolean;
76
+ };
77
+ widget.add_css_class('dim-label');
78
+ expect(widget.has_css_class('dim-label')).toBe(true);
79
+ widget.remove_css_class('dim-label');
80
+ expect(widget.has_css_class('dim-label')).toBe(false);
81
+ });
82
+
71
83
  await it('takes an action group and drops it again with null', () => {
72
84
  const widget = make('WidgetMembersActions');
73
85
  const group = new Gio.SimpleActionGroup();