@gjsify/adwaita-web 0.59.1 → 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 (55) hide show
  1. package/README.md +11 -0
  2. package/dist/adwaita-web.css +4 -0
  3. package/dist/adwaita-web.css.map +1 -1
  4. package/lib/types/elements/gtk-button.d.ts +13 -0
  5. package/lib/types/elements/gtk-drop-down.d.ts +4 -0
  6. package/lib/types/elements/gtk-label.d.ts +4 -0
  7. package/lib/types/elements/gtk-text-view.d.ts +15 -0
  8. package/lib/types/file-chooser-host.d.ts +55 -0
  9. package/lib/types/file-chooser.spec.d.ts +1 -0
  10. package/lib/types/gtk-platform.spec.d.ts +1 -0
  11. package/lib/types/namespace/adw.d.ts +1 -0
  12. package/lib/types/namespace/gdk.d.ts +2 -0
  13. package/lib/types/namespace/gio.d.ts +3 -0
  14. package/lib/types/namespace/glib.d.ts +1 -0
  15. package/lib/types/namespace/gtk-enums.d.ts +22 -0
  16. package/lib/types/namespace/gtk-platform.d.ts +13 -0
  17. package/lib/types/namespace/gtk.d.ts +4 -1
  18. package/lib/types/platform-host.d.ts +2 -0
  19. package/lib/types/registered-styles.d.ts +2 -0
  20. package/lib/types/style-manager.d.ts +7 -0
  21. package/lib/types/styles.generated.d.ts +1 -1
  22. package/lib/types/template-classes.d.ts +2 -0
  23. package/lib/types/widget-members.spec.d.ts +1 -0
  24. package/package.json +23 -10
  25. package/scss/_viewport.scss +9 -0
  26. package/src/capabilities.mjs +1 -4
  27. package/src/construct-vectors.spec.ts +10 -1
  28. package/src/elements/gtk-button.ts +34 -0
  29. package/src/elements/gtk-drop-down.ts +10 -0
  30. package/src/elements/gtk-label.ts +10 -0
  31. package/src/elements/gtk-text-view.ts +40 -0
  32. package/src/file-chooser-host.ts +421 -0
  33. package/src/file-chooser.spec.ts +376 -0
  34. package/src/gobject-door.ts +15 -2
  35. package/src/gobject-elements.ts +4 -0
  36. package/src/gtk-drop-down.spec.ts +11 -0
  37. package/src/gtk-label.spec.ts +12 -0
  38. package/src/gtk-platform.spec.ts +19 -0
  39. package/src/gtk-text-view.spec.ts +22 -0
  40. package/src/namespace/adw.ts +1 -0
  41. package/src/namespace/gdk.ts +18 -1
  42. package/src/namespace/gio.ts +20 -2
  43. package/src/namespace/glib.ts +6 -1
  44. package/src/namespace/gtk-enums.ts +7 -2
  45. package/src/namespace/gtk-platform.ts +16 -0
  46. package/src/namespace/gtk.ts +12 -1
  47. package/src/platform-host.ts +88 -0
  48. package/src/registered-styles.ts +73 -0
  49. package/src/shared-tree-builder.ts +46 -2
  50. package/src/shared-trees.spec.ts +36 -0
  51. package/src/style-manager.ts +10 -1
  52. package/src/styles.generated.ts +1 -1
  53. package/src/template-classes.ts +6 -0
  54. package/src/test.browser.mts +6 -0
  55. package/src/widget-members.spec.ts +147 -0
@@ -21,7 +21,10 @@
21
21
  // are the same write and nothing in this package had to learn a second input shape.
22
22
 
23
23
  export { GtkAdjustment as Adjustment } from '@gjsify/adwaita-core';
24
- export { PolicyType, TextDirection, TextExtendSelection, TextWindowType } from './gtk-enums.js';
24
+ // ADR 0100: the file dialog is no element either — `present`-less, it asks the driver
25
+ // `../file-chooser-host.ts` registers (imported by `./gio.ts`) and resolves a `Gio.File`.
26
+ export { GtkFileDialog as FileDialog, GtkFileFilter as FileFilter, DialogError } from '@gjsify/adwaita-core';
27
+ export { License, PolicyType, TextDirection, TextExtendSelection, TextWindowType } from './gtk-enums.js';
25
28
  export { GtkAboutDialog as AboutDialog } from '../elements/gtk-about-dialog.js';
26
29
  export { GtkActionBar as ActionBar } from '../elements/gtk-action-bar.js';
27
30
  export { GtkApplicationWindow as ApplicationWindow } from '../elements/gtk-application-window.js';
@@ -97,3 +100,11 @@ export { GtkWindowControls as WindowControls } from '../elements/gtk-window-cont
97
100
  export { GtkViewport as Viewport } from '../elements/gtk-viewport.js';
98
101
  export { GtkWindowHandle as WindowHandle } from '../elements/gtk-window-handle.js';
99
102
  export { GtkApplication as Application } from '../application.js';
103
+ export {
104
+ STYLE_PROVIDER_PRIORITY_APPLICATION,
105
+ STYLE_PROVIDER_PRIORITY_FALLBACK,
106
+ STYLE_PROVIDER_PRIORITY_SETTINGS,
107
+ STYLE_PROVIDER_PRIORITY_THEME,
108
+ STYLE_PROVIDER_PRIORITY_USER,
109
+ } from '@gjsify/adwaita-core';
110
+ export { CssProvider, EventControllerKey, IconTheme, show_uri, StyleContext, Settings } from './gtk-platform.js';
@@ -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
+ };
@@ -0,0 +1,73 @@
1
+ // The stylesheet of a registered class. `<adw-bin>` is styled by a rule on the tag `adw-bin`, and a
2
+ // subclass that `GObject.registerClass` defines as `<gjsify-hexdump>` is another tag, so none of its
3
+ // base's rules (a `display: block`, the box-model reset, the `:only-child` fill) reach it: a Bin
4
+ // subclass collapsed to an inline box of zero width. GTK has no such seam because a subclass keeps
5
+ // its parent's CSS node name.
6
+ //
7
+ // So the rules of the base tag are copied to the subclass tag, once per tag, from the stylesheets
8
+ // the page has when the first instance connects: every selector that names the base tag as a type
9
+ // selector, with that name replaced. The copy is adopted last, so it sits where the base's rules do
10
+ // relative to the author's own.
11
+
12
+ const DONE = new Set<string>();
13
+
14
+ /** Splits a selector list at its top-level commas (`:is(a, b)` stays whole). */
15
+ function splitSelectorList(list: string): string[] {
16
+ const parts: string[] = [];
17
+ let depth = 0;
18
+ let start = 0;
19
+ for (let i = 0; i < list.length; i++) {
20
+ const c = list[i];
21
+ if (c === '(' || c === '[') depth++;
22
+ else if (c === ')' || c === ']') depth--;
23
+ else if (c === ',' && depth === 0) {
24
+ parts.push(list.slice(start, i).trim());
25
+ start = i + 1;
26
+ }
27
+ }
28
+ parts.push(list.slice(start).trim());
29
+ return parts;
30
+ }
31
+
32
+ function rulesOf(sheet: CSSStyleSheet): CSSRuleList | null {
33
+ try {
34
+ return sheet.cssRules;
35
+ } catch {
36
+ return null; // a cross-origin sheet is not readable
37
+ }
38
+ }
39
+
40
+ function copyRules(rules: CSSRuleList, from: RegExp, to: string, out: string[]): void {
41
+ for (const rule of Array.from(rules)) {
42
+ if (rule instanceof CSSStyleRule) {
43
+ const mine = splitSelectorList(rule.selectorText).filter((selector) => from.test(selector));
44
+ if (mine.length === 0) continue;
45
+ const selector = mine.map((s) => s.replace(new RegExp(from.source, 'g'), to)).join(', ');
46
+ out.push(`${selector} { ${rule.style.cssText} }`);
47
+ } else if ('cssRules' in rule) {
48
+ // @media, @supports, @layer: copy the wrapper around what matches inside it
49
+ const inner: string[] = [];
50
+ copyRules((rule as CSSGroupingRule).cssRules, from, to, inner);
51
+ if (inner.length === 0) continue;
52
+ const head = rule.cssText.slice(0, rule.cssText.indexOf('{')).trim();
53
+ out.push(`${head} { ${inner.join('\n')} }`);
54
+ }
55
+ }
56
+ }
57
+
58
+ /** Gives `tag` the rules that `base` has in the page's stylesheets. Runs once per tag. */
59
+ export function inheritStyles(tag: string, base: string | null | undefined): void {
60
+ if (!base || DONE.has(tag)) return;
61
+ DONE.add(tag);
62
+ const from = new RegExp(`(?<![\\w-])${base}(?![\\w-])`);
63
+ const css: string[] = [];
64
+ const sheets = [...Array.from(document.styleSheets), ...document.adoptedStyleSheets];
65
+ for (const sheet of sheets) {
66
+ const rules = rulesOf(sheet);
67
+ if (rules) copyRules(rules, from, tag, css);
68
+ }
69
+ if (css.length === 0) return;
70
+ const copy = new CSSStyleSheet();
71
+ copy.replaceSync(css.join('\n'));
72
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, copy];
73
+ }
@@ -58,7 +58,7 @@ import { capabilities } from './capabilities.mjs';
58
58
  import { endpointOf, hasElementApi, installElementApi } from './gobject-elements.js';
59
59
  import { dispatchedSignalsOf } from './signals.js';
60
60
  import { slottedChildrenOf } from './slotted-children.js';
61
- import { templateTagFor } from './template-classes.js';
61
+ import { isTemplateTag, templateTagFor } from './template-classes.js';
62
62
  import { writeBooleanAttribute } from './attributes.js';
63
63
 
64
64
  /** One authored placement, kept so {@link mountSharedTree} can hold the renderer to it. */
@@ -344,7 +344,11 @@ export function writeProp(el: HTMLElement, prop: string, value: string | number
344
344
  const member = propertyOf(prop);
345
345
  const expand = GTK_WIDGET_EXPAND.find((name) => name === attr);
346
346
  if (expand !== undefined && typeof value === 'boolean') el.setAttribute(expand, value ? '' : 'false');
347
- else if (typeof value === 'boolean' && isValueBasedBooleanAttr(el.localName, attr)) {
347
+ else if (isTemplateTag(el.localName) && member in el) {
348
+ // An application class has no attributeChangedCallback: its properties are GObject properties,
349
+ // set the way `Gtk.Builder` sets them, after the constructor.
350
+ (el as unknown as Record<string, unknown>)[member] = value;
351
+ } else if (typeof value === 'boolean' && isValueBasedBooleanAttr(el.localName, attr)) {
348
352
  writeBooleanAttribute(el, attr, value);
349
353
  } else if (value === false && isWritable(el, member)) {
350
354
  // Non-value-based, writable property: set the property directly (bypasses attribute).
@@ -416,11 +420,51 @@ function writeLook(el: HTMLElement, node: SharedTreeNode, menus?: SharedTreeNode
416
420
  writeExtensions(el, node);
417
421
  }
418
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
+
419
462
  /** The built children of `node`, placed (`page`, `slot`) but not yet appended to `el`. */
420
463
  function buildChildren(el: HTMLElement, node: SharedTreeNode, record: BuildRecord): HTMLElement[] {
421
464
  const built: HTMLElement[] = [];
422
465
  for (const child of node.children ?? []) {
423
466
  const childEl = buildNode(child, record);
467
+ if (child.layout !== undefined) writeGridCell(el, childEl, child);
424
468
  if (child.page !== undefined) writePage(el, childEl, child);
425
469
  if (child.slot !== undefined) {
426
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
  }