@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
@@ -7,5 +7,7 @@
7
7
  * as `Gtk.Builder` refuses a type registered twice.
8
8
  */
9
9
  export declare function registerTemplateClass(name: string, tag: string): void;
10
+ /** Whether `tag` is an element an application class registered, whose properties are GObject properties. */
11
+ export declare function isTemplateTag(tag: string): boolean;
10
12
  /** The tag `name` was registered under, or a refusal that names what is missing. */
11
13
  export declare function templateTagFor(name: string): string;
@@ -0,0 +1 @@
1
+ export declare const WidgetMembersTest: () => Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/adwaita-web",
3
- "version": "0.59.1",
3
+ "version": "0.61.0",
4
4
  "description": "Adwaita/Libadwaita web components for browser targets",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -34,7 +34,11 @@
34
34
  "scss",
35
35
  "dist",
36
36
  "lib",
37
- "style.css.d.ts"
37
+ "style.css.d.ts",
38
+ "!src/**/*.spec.ts",
39
+ "!src/test.browser.mts",
40
+ "!dist/test.browser.mjs",
41
+ "!lib/types/**/*.spec.d.ts"
38
42
  ],
39
43
  "scripts": {
40
44
  "clear": "gjsify clear lib dist tsconfig.tsbuildinfo scss/_icons.generated.scss src/styles.generated.ts",
@@ -50,17 +54,26 @@
50
54
  "@codemirror/language": "^6.12.4",
51
55
  "@codemirror/state": "^6.7.0",
52
56
  "@codemirror/view": "^6.43.4",
53
- "@gjsify/adwaita-core": "^0.59.1",
54
- "@gjsify/adwaita-fonts": "^0.59.1",
55
- "@gjsify/adwaita-icons": "^0.59.1",
56
- "@gjsify/gtksource-core": "^0.59.1",
57
+ "@gjsify/adwaita-core": "^0.61.0",
58
+ "@gjsify/adwaita-fonts": "^0.61.0",
59
+ "@gjsify/adwaita-icons": "^0.61.0",
60
+ "@gjsify/gtksource-core": "^0.61.0",
57
61
  "@lezer/highlight": "^1.2.3"
58
62
  },
63
+ "peerDependencies": {
64
+ "@gjsify/app-settings": "^0.61.0"
65
+ },
66
+ "peerDependenciesMeta": {
67
+ "@gjsify/app-settings": {
68
+ "optional": true
69
+ }
70
+ },
59
71
  "devDependencies": {
60
- "@gjsify/blueprint": "^0.59.1",
61
- "@gjsify/cli": "^0.59.1",
62
- "@gjsify/unit": "^0.59.1",
63
- "@gjsify/vite-plugin-blueprint": "^0.59.1",
72
+ "@gjsify/app-settings": "^0.61.0",
73
+ "@gjsify/blueprint": "^0.61.0",
74
+ "@gjsify/cli": "^0.61.0",
75
+ "@gjsify/unit": "^0.61.0",
76
+ "@gjsify/vite-plugin-blueprint": "^0.61.0",
64
77
  "sass": "^1.101.0",
65
78
  "typescript": "^6.0.3"
66
79
  },
@@ -47,4 +47,13 @@ gtk-viewport {
47
47
  > * {
48
48
  flex: none;
49
49
  }
50
+
51
+ // A `GtkScrollable` is the one child GTK does NOT put in a viewport: `gtk_scrolled_window_set_child`
52
+ // takes it as the window's own scrollable (gtkscrolledwindow.c:4362), so it is allocated the
53
+ // window's size and scrolls itself. The builder wraps every child, so the scrollable has to fill
54
+ // the viewport it lands in, in both axes.
55
+ > gtk-source-view {
56
+ flex: 1 1 auto;
57
+ align-self: stretch;
58
+ }
50
59
  }
@@ -4,10 +4,7 @@
4
4
 
5
5
  /** @type {import('./capabilities.d.mts').capabilities} */
6
6
  export const capabilities = {
7
- layout: {
8
- refused:
9
- 'an <adw-…> element is placed by DOM order and slot=, so a layout-manager placement has no door in the markup this package reads (ADR 0092)',
10
- },
7
+ layout: 'implemented',
11
8
  strings: 'implemented',
12
9
  responses: 'implemented',
13
10
  page: 'implemented',
@@ -23,10 +23,19 @@ customElements.define('corpus-extern', CorpusExtern);
23
23
  registerTemplateClass(EXTERN_VECTOR_CLASS, 'corpus-extern');
24
24
 
25
25
  function observe(vector: ConstructVector): unknown {
26
- if (vector.kind === 'layout' || vector.kind === 'sibling-object') {
26
+ if (vector.kind === 'sibling-object') {
27
27
  buildSharedTree(vector.tree);
28
28
  return undefined;
29
29
  }
30
+ if (vector.kind === 'layout') {
31
+ // CSS Grid lines count from 1, GTK cells from 0: `2 / span 2` is column 1, columnSpan 2.
32
+ const start = (line: string) => Number(line.split(' / ')[0]) - 1;
33
+ const span = (line: string) => Number(line.split('span ')[1]);
34
+ return [...buildSharedTree(vector.tree).children].map((child) => {
35
+ const { gridRow, gridColumn } = (child as HTMLElement).style;
36
+ return { row: start(gridRow), column: start(gridColumn), columnSpan: span(gridColumn) };
37
+ });
38
+ }
30
39
  if (vector.kind === 'page') {
31
40
  const stack = buildSharedTree(vector.tree);
32
41
  return [{ name: stack.children[0]!.getAttribute('name'), title: stack.children[0]!.getAttribute('title') }];
@@ -50,6 +50,40 @@ export class GtkButton extends HTMLElement {
50
50
  ];
51
51
  }
52
52
 
53
+ /**
54
+ * `Gtk.Button:icon-name` and `Gtk.Widget:tooltip-text`, the GObject spellings: a class that
55
+ * holds a button as a template child writes `button.icon_name = …`, which must reach the DOM.
56
+ */
57
+ get icon_name(): string | null {
58
+ return this.getAttribute('icon-name');
59
+ }
60
+ set icon_name(value: string | null) {
61
+ this.reflect('icon-name', value);
62
+ }
63
+ get iconName(): string | null {
64
+ return this.icon_name;
65
+ }
66
+ set iconName(value: string | null) {
67
+ this.icon_name = value;
68
+ }
69
+ get tooltip_text(): string | null {
70
+ return this.getAttribute('tooltip-text');
71
+ }
72
+ set tooltip_text(value: string | null) {
73
+ this.reflect('tooltip-text', value);
74
+ }
75
+ get tooltipText(): string | null {
76
+ return this.tooltip_text;
77
+ }
78
+ set tooltipText(value: string | null) {
79
+ this.tooltip_text = value;
80
+ }
81
+
82
+ private reflect(attribute: string, value: string | null): void {
83
+ if (value === null || value === undefined) this.removeAttribute(attribute);
84
+ else this.setAttribute(attribute, value);
85
+ }
86
+
53
87
  /** The inner native button (for focus/imperative access). */
54
88
  get button(): HTMLButtonElement {
55
89
  return this._button;
@@ -126,6 +126,16 @@ export class GtkDropDown extends HTMLElement {
126
126
  this._selectIndex(value);
127
127
  }
128
128
 
129
+ /** `gtk_drop_down_get_selected`. */
130
+ get_selected(): number {
131
+ return this.selected;
132
+ }
133
+
134
+ /** `gtk_drop_down_set_selected`; an out-of-range index is ignored, as the `selected` property does. */
135
+ set_selected(position: number): void {
136
+ this.selected = position;
137
+ }
138
+
129
139
  /** The selected option's value, or '' when empty (Gtk.DropDown:selected-item). */
130
140
  get selectedValue(): string {
131
141
  return this._state.selectedValue;
@@ -262,6 +262,16 @@ export class GtkLabel extends HTMLElement {
262
262
  this.toggleAttribute('selectable', !!value);
263
263
  }
264
264
 
265
+ /** `gtk_label_get_label`. */
266
+ get_label(): string {
267
+ return this.label;
268
+ }
269
+
270
+ /** `gtk_label_set_label`. */
271
+ set_label(str: string): void {
272
+ this.label = str;
273
+ }
274
+
265
275
  /** `gtk_label_get_text` — the text as SHOWN, markup reduced and the marker removed. */
266
276
  getText(): string {
267
277
  return labelDisplayText(this.label, this.useMarkup, this.useUnderline);
@@ -75,6 +75,14 @@ const WRAP_WHITE_SPACE: Record<TextViewWrapMode, string> = {
75
75
  char: 'pre-wrap',
76
76
  };
77
77
 
78
+ /** The members of `Gtk.TextBuffer` this element has: text in and out, and the insertion cursor. */
79
+ export interface GtkTextBufferSubset {
80
+ readonly cursor_position: number;
81
+ insert_at_cursor(text: string, len?: number): void;
82
+ set_text(text: string, len?: number): void;
83
+ get_text(): string;
84
+ }
85
+
78
86
  export class GtkTextView extends HTMLElement {
79
87
  private _area!: HTMLTextAreaElement;
80
88
  private _initialized = false;
@@ -195,6 +203,38 @@ export class GtkTextView extends HTMLElement {
195
203
  this.setAttribute('right-margin', String(value));
196
204
  }
197
205
 
206
+ /**
207
+ * `gtk_text_view_get_buffer`: the part of `Gtk.TextBuffer` a program writes through. The text
208
+ * stays the element's own, so the buffer is a view onto it: the cursor is the textarea's caret,
209
+ * and a length of -1 means "the whole string", as in GTK.
210
+ */
211
+ get buffer(): GtkTextBufferSubset {
212
+ return {
213
+ cursor_position: this._area ? (this._area.selectionEnd ?? this._area.value.length) : this.text.length,
214
+ insert_at_cursor: (text: string, len = -1) => {
215
+ const piece = len < 0 ? text : text.slice(0, len);
216
+ const area = this._area;
217
+ if (!area) {
218
+ this.text = this.text + piece;
219
+ return;
220
+ }
221
+ const at = area.selectionEnd ?? area.value.length;
222
+ area.setRangeText(piece, at, at, 'end');
223
+ this._emitChanged();
224
+ },
225
+ set_text: (text: string, len = -1) => {
226
+ this.text = len < 0 ? text : text.slice(0, len);
227
+ if (this._area) this._emitChanged();
228
+ },
229
+ get_text: () => this.text,
230
+ } as GtkTextBufferSubset;
231
+ }
232
+
233
+ /** `gtk_text_view_get_buffer`. */
234
+ get_buffer(): GtkTextBufferSubset {
235
+ return this.buffer;
236
+ }
237
+
198
238
  /** The inner native textarea, for focus and selection. */
199
239
  get textarea(): HTMLTextAreaElement {
200
240
  return this._area;
@@ -0,0 +1,421 @@
1
+ // How the web door answers `Gtk.FileDialog` (ADR 0100 § 4, § 4a, § 5). The subset itself — argument
2
+ // checks, the async/finish pairs, `Gio.File`, the stream — is `@gjsify/adwaita-core`'s; this file is
3
+ // the driver below it.
4
+ //
5
+ // THE DOOR CHOOSES BY FEATURE, NEVER BY BROWSER. No line here reads the user agent:
6
+ // - `'showSaveFilePicker' in window`: the system pickers of File System Access, over the user's
7
+ // real files. The filters become `types[].accept`.
8
+ // - otherwise: the Origin Private File System, in a dialog drawn from Adwaita elements
9
+ // ({@link presentOpfsDialog}). A file picked through `<input type=file>` from that dialog is
10
+ // COPIED into OPFS, so the next save writes the copy and not a download.
11
+ // - `createWritable` on the handle: the write streams on the main thread; where it is missing
12
+ // (Safari before 26), a dedicated worker writes with `createSyncAccessHandle`.
13
+ //
14
+ // A download is never a stand-in for save; nothing here creates one.
15
+ //
16
+ // THE DRAWN DIALOG is a clean-room re-implementation: its STRUCTURE follows the current GNOME file
17
+ // chooser (Nautilus' file-chooser mode — an OverlaySplitView with a places sidebar, the file list,
18
+ // and a bottom bar with the filter drop-down, the name entry and the accept button), and no code,
19
+ // template or string is taken from Nautilus (GPL-3.0-or-later). It shows only what OPFS has.
20
+
21
+ import {
22
+ dialogFailedError,
23
+ dismissedError,
24
+ fileFilterAccepts,
25
+ registerFileChooserDriver,
26
+ type FileChooserDriver,
27
+ type FileChooserHandle,
28
+ type FileChooserRequest,
29
+ type FileChooserWriter,
30
+ type FileFilterSpec,
31
+ } from '@gjsify/adwaita-core';
32
+
33
+ import './elements/adw-dialog.js';
34
+ import './elements/adw-overlay-split-view.js';
35
+ import './elements/adw-toolbar-view.js';
36
+ import './elements/gtk-button.js';
37
+ import './elements/gtk-drop-down.js';
38
+ import './elements/gtk-entry.js';
39
+ import './elements/gtk-list-box.js';
40
+ import './elements/gtk-list-box-row.js';
41
+ import type { AdwDialog } from './elements/adw-dialog.js';
42
+ import type { GtkDropDown } from './elements/gtk-drop-down.js';
43
+ import type { GtkEntry } from './elements/gtk-entry.js';
44
+ import type { GtkListBoxRow } from './elements/gtk-list-box-row.js';
45
+
46
+ /** What the drawn dialog hands back: an OPFS entry by name, or a file the `<input>` picked. */
47
+ export type OpfsChoice = { readonly name: string } | { readonly upload: File };
48
+
49
+ /** File System Access `showOpenFilePicker`/`showSaveFilePicker` options, as far as the door fills them. */
50
+ export interface FilePickerOptions {
51
+ types?: { description: string; accept: Record<string, string[]> }[];
52
+ excludeAcceptAllOption?: boolean;
53
+ suggestedName?: string;
54
+ }
55
+
56
+ /** The platform the driver reads. {@link browserFileChooserEnv} is the real one; a spec passes fakes. */
57
+ export interface WebFileChooserEnv {
58
+ showOpenFilePicker?: (options: FilePickerOptions) => Promise<FileSystemFileHandle[]>;
59
+ showSaveFilePicker?: (options: FilePickerOptions) => Promise<FileSystemFileHandle>;
60
+ /** Transient user activation; an environment that cannot say answers `true`. */
61
+ isActive(): boolean;
62
+ opfsRoot(): Promise<FileSystemDirectoryHandle>;
63
+ /** The drawn dialog over `root`; `null` when the user closed it. */
64
+ choose(
65
+ kind: 'open' | 'save',
66
+ request: FileChooserRequest,
67
+ root: FileSystemDirectoryHandle,
68
+ ): Promise<OpfsChoice | null>;
69
+ /** Whether `handle` writes through `createWritable`; the worker writes otherwise. */
70
+ canStreamWrite(handle: FileSystemFileHandle): boolean;
71
+ }
72
+
73
+ // --- Filters ------------------------------------------------------------------------------------
74
+
75
+ /** The extensions a filter names, with the dot, as `accept` and `types[].accept` take them. */
76
+ function extensionsOf(spec: FileFilterSpec): string[] {
77
+ return [...spec.patterns, ...spec.suffixes].map((ext) => `.${ext}`);
78
+ }
79
+
80
+ /**
81
+ * The File System Access options for `request`. An extension-only filter needs a MIME key, and
82
+ * `application/octet-stream` is the one that claims nothing about the content. A filter that
83
+ * accepts everything is the picker's own accept-all option, so it is not a type.
84
+ */
85
+ export function filePickerOptions(request: FileChooserRequest, kind: 'open' | 'save'): FilePickerOptions {
86
+ const types: NonNullable<FilePickerOptions['types']> = [];
87
+ for (const spec of request.filters) {
88
+ if (spec.acceptAll) continue;
89
+ const accept: Record<string, string[]> = {};
90
+ for (const mime of spec.mimeTypes) accept[mime] = [];
91
+ const extensions = extensionsOf(spec);
92
+ if (extensions.length > 0) accept[spec.mimeTypes[0] ?? 'application/octet-stream'] = extensions;
93
+ types.push({ description: spec.name ?? '', accept });
94
+ }
95
+ const options: FilePickerOptions = {};
96
+ if (types.length > 0) {
97
+ options.types = types;
98
+ options.excludeAcceptAllOption = !request.filters.some((spec) => spec.acceptAll);
99
+ }
100
+ if (kind === 'save' && request.initialName !== null) options.suggestedName = request.initialName;
101
+ return options;
102
+ }
103
+
104
+ /** `<input type=file accept>` for one filter: extensions and MIME types; `null` accepts every file. */
105
+ export function inputAccept(spec: FileFilterSpec | null): string | null {
106
+ if (!spec || spec.acceptAll) return null;
107
+ const accept = [...extensionsOf(spec), ...spec.mimeTypes];
108
+ return accept.length > 0 ? accept.join(',') : null;
109
+ }
110
+
111
+ // --- Handles and writers ------------------------------------------------------------------------
112
+
113
+ // Opens, truncates, writes and flushes in one message: `createSyncAccessHandle` exists in a
114
+ // dedicated worker only, and only on OPFS files, which is exactly the handle this path gets.
115
+ const WORKER_SOURCE = `onmessage = async (event) => {
116
+ const { handle, data } = event.data;
117
+ let access = null;
118
+ try {
119
+ access = await handle.createSyncAccessHandle();
120
+ access.truncate(0);
121
+ access.write(data, { at: 0 });
122
+ access.flush();
123
+ postMessage({ ok: true });
124
+ } catch (error) {
125
+ postMessage({ ok: false, message: String((error && error.name) || 'Error') + ': ' + String(error && error.message) });
126
+ } finally {
127
+ if (access) access.close();
128
+ }
129
+ };`;
130
+
131
+ function writeInWorker(handle: FileSystemFileHandle, data: Uint8Array): Promise<void> {
132
+ const url = URL.createObjectURL(new Blob([WORKER_SOURCE], { type: 'text/javascript' }));
133
+ const worker = new Worker(url);
134
+ return new Promise<void>((resolve, reject) => {
135
+ worker.onmessage = (event: MessageEvent<{ ok: boolean; message?: string }>) => {
136
+ if (event.data.ok) resolve();
137
+ else reject(new Error(event.data.message));
138
+ };
139
+ worker.onerror = (event) => reject(new Error(event.message || 'the write worker failed'));
140
+ worker.postMessage({ handle, data });
141
+ }).finally(() => {
142
+ worker.terminate();
143
+ URL.revokeObjectURL(url);
144
+ });
145
+ }
146
+
147
+ function concat(chunks: readonly Uint8Array[]): Uint8Array {
148
+ const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
149
+ let at = 0;
150
+ for (const chunk of chunks) {
151
+ out.set(chunk, at);
152
+ at += chunk.length;
153
+ }
154
+ return out;
155
+ }
156
+
157
+ async function openWriter(handle: FileSystemFileHandle, env: WebFileChooserEnv): Promise<FileChooserWriter> {
158
+ if (env.canStreamWrite(handle)) {
159
+ // `keepExistingData` defaults to false: the stream starts empty, and `close()` commits.
160
+ const stream = await handle.createWritable();
161
+ return { write: (chunk) => stream.write(chunk as Uint8Array<ArrayBuffer>), close: () => stream.close() };
162
+ }
163
+ const chunks: Uint8Array[] = [];
164
+ return {
165
+ write: async (chunk) => void chunks.push(chunk.slice()),
166
+ close: () => writeInWorker(handle, concat(chunks)),
167
+ };
168
+ }
169
+
170
+ function handleFor(handle: FileSystemFileHandle, env: WebFileChooserEnv): FileChooserHandle {
171
+ return {
172
+ basename: handle.name,
173
+ read: async () => new Uint8Array(await (await handle.getFile()).arrayBuffer()),
174
+ openWriter: () => openWriter(handle, env),
175
+ };
176
+ }
177
+
178
+ /** `name`, or `stem (2).ext`, `stem (3).ext` … — whichever `root` does not hold yet. */
179
+ async function freeName(root: FileSystemDirectoryHandle, name: string): Promise<string> {
180
+ const dot = name.lastIndexOf('.');
181
+ const stem = dot > 0 ? name.slice(0, dot) : name;
182
+ const ext = dot > 0 ? name.slice(dot) : '';
183
+ for (let n = 1; ; n++) {
184
+ const candidate = n === 1 ? name : `${stem} (${n})${ext}`;
185
+ try {
186
+ await root.getFileHandle(candidate);
187
+ } catch {
188
+ return candidate;
189
+ }
190
+ }
191
+ }
192
+
193
+ /** Copies a picked `File` into OPFS: the copy is what a later save writes, never an existing file. */
194
+ async function copyIntoOpfs(root: FileSystemDirectoryHandle, file: File, env: WebFileChooserEnv) {
195
+ const target = await root.getFileHandle(await freeName(root, file.name), { create: true });
196
+ const writer = await openWriter(target, env);
197
+ await writer.write(new Uint8Array(await file.arrayBuffer()));
198
+ await writer.close();
199
+ return target;
200
+ }
201
+
202
+ // --- The driver ---------------------------------------------------------------------------------
203
+
204
+ function isAbort(error: unknown): boolean {
205
+ return (error as { name?: unknown } | null)?.name === 'AbortError';
206
+ }
207
+
208
+ /** The `Gtk.DialogError` a platform rejection becomes: `AbortError` is the user closing the picker. */
209
+ function dialogRejection(error: unknown) {
210
+ if (isAbort(error)) return dismissedError();
211
+ const e = error as { name?: string; message?: string } | null;
212
+ if (e && typeof e.message === 'string') return dialogFailedError(`${e.name ?? 'Error'}: ${e.message}`);
213
+ return dialogFailedError(String(error));
214
+ }
215
+
216
+ export function createWebFileChooserDriver(env: WebFileChooserEnv): FileChooserDriver {
217
+ const run = async (kind: 'open' | 'save', request: FileChooserRequest): Promise<FileChooserHandle> => {
218
+ if (!env.isActive()) {
219
+ throw dialogFailedError(`Gtk.FileDialog.${kind}() needs a user activation (a click or a key press)`);
220
+ }
221
+ try {
222
+ if (env.showSaveFilePicker && env.showOpenFilePicker) {
223
+ const options = filePickerOptions(request, kind);
224
+ const handle =
225
+ kind === 'open'
226
+ ? (await env.showOpenFilePicker(options))[0]
227
+ : await env.showSaveFilePicker(options);
228
+ if (!handle) throw dismissedError();
229
+ return handleFor(handle, env);
230
+ }
231
+ const root = await env.opfsRoot();
232
+ const choice = await env.choose(kind, request, root);
233
+ if (choice === null) throw dismissedError();
234
+ if ('upload' in choice) return handleFor(await copyIntoOpfs(root, choice.upload, env), env);
235
+ return handleFor(await root.getFileHandle(choice.name, { create: kind === 'save' }), env);
236
+ } catch (error) {
237
+ throw dialogRejection(error);
238
+ }
239
+ };
240
+ return { open: (request) => run('open', request), save: (request) => run('save', request) };
241
+ }
242
+
243
+ // --- The drawn dialog ---------------------------------------------------------------------------
244
+
245
+ function el<K extends string>(tag: K, attrs: Record<string, string> = {}, text?: string): HTMLElement {
246
+ const node = document.createElement(tag);
247
+ for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value);
248
+ if (text !== undefined) node.textContent = text;
249
+ return node;
250
+ }
251
+
252
+ function sameFilter(a: FileFilterSpec, b: FileFilterSpec): boolean {
253
+ return JSON.stringify(a) === JSON.stringify(b);
254
+ }
255
+
256
+ /**
257
+ * Lets the user pick an OPFS file (`open`) or name one (`save`). Resolves the choice, or `null`
258
+ * when the dialog closes without one. On `open`, "Upload…" picks a file through `<input type=file>`;
259
+ * its `cancel` event dismisses the whole open, as ADR 0100 § 5 maps it.
260
+ *
261
+ * The dialog sets `data-ready` once the list is filled, which is what a spec waits for.
262
+ */
263
+ export function presentOpfsDialog(
264
+ kind: 'open' | 'save',
265
+ request: FileChooserRequest,
266
+ root: FileSystemDirectoryHandle,
267
+ ): Promise<OpfsChoice | null> {
268
+ const filters = request.filters;
269
+ const initial = request.defaultFilter ? filters.findIndex((f) => sameFilter(f, request.defaultFilter!)) : 0;
270
+ let current: FileFilterSpec | null = filters[Math.max(initial, 0)] ?? request.defaultFilter;
271
+
272
+ const dialog = el('adw-dialog', {
273
+ title: request.title ?? (kind === 'open' ? 'Open File' : 'Save File'),
274
+ 'content-width': '720',
275
+ 'content-height': '480',
276
+ }) as AdwDialog;
277
+
278
+ const split = el('adw-overlay-split-view', { breakpoint: 'max-width: 560px' });
279
+ const sidebar = el('div', { slot: 'sidebar' });
280
+ const places = el('gtk-list-box', { class: 'navigation-sidebar', 'selection-mode': 'browse' });
281
+ places.append(el('gtk-list-box-row', {}, 'Origin Storage'));
282
+ sidebar.append(places);
283
+
284
+ const view = el('adw-toolbar-view');
285
+ const list = el('gtk-list-box', { 'selection-mode': 'single', 'activate-on-single-click': 'false' });
286
+ const empty = el('p', { class: 'dimmed', hidden: '' }, 'No files');
287
+ const bottom = el('div', { slot: 'bottom', class: 'toolbar' });
288
+
289
+ let dropDown: GtkDropDown | null = null;
290
+ if (filters.length > 0) {
291
+ dropDown = el('gtk-drop-down') as GtkDropDown;
292
+ dropDown.model = filters.map((f, i) => f.name ?? `Filter ${i + 1}`);
293
+ dropDown.selected = Math.max(initial, 0);
294
+ bottom.append(dropDown);
295
+ }
296
+ let entry: GtkEntry | null = null;
297
+ if (kind === 'save') {
298
+ entry = el('gtk-entry', { 'placeholder-text': 'Name' }) as GtkEntry;
299
+ entry.style.flex = '1';
300
+ entry.value = request.initialName ?? '';
301
+ bottom.append(entry);
302
+ }
303
+ let upload: HTMLElement | null = null;
304
+ if (kind === 'open') {
305
+ upload = el('gtk-button', { label: 'Upload…' });
306
+ bottom.append(upload);
307
+ }
308
+ const spacer = el('div');
309
+ spacer.style.flex = '1';
310
+ const accept = el('gtk-button', { label: kind === 'open' ? 'Open' : 'Save', suggested: '' });
311
+ bottom.append(spacer, accept);
312
+
313
+ view.append(list, empty, bottom);
314
+ split.append(sidebar, view);
315
+ dialog.append(split);
316
+
317
+ let selected: string | null = null;
318
+
319
+ const refill = async () => {
320
+ dialog.removeAttribute('data-ready');
321
+ const names: string[] = [];
322
+ for await (const [name, entryHandle] of root.entries()) {
323
+ if (entryHandle.kind === 'file' && (!current || fileFilterAccepts(current, name))) names.push(name);
324
+ }
325
+ names.sort((a, b) => a.localeCompare(b));
326
+ list.replaceChildren(
327
+ ...names.map((name) => {
328
+ const row = el('gtk-list-box-row', { 'data-name': name }, name);
329
+ return row;
330
+ }),
331
+ );
332
+ selected = null;
333
+ empty.hidden = names.length > 0;
334
+ dialog.setAttribute('data-ready', '');
335
+ };
336
+
337
+ return new Promise<OpfsChoice | null>((resolve) => {
338
+ let settled = false;
339
+ const finish = (choice: OpfsChoice | null) => {
340
+ if (settled) return;
341
+ settled = true;
342
+ resolve(choice);
343
+ dialog.forceClose();
344
+ dialog.remove();
345
+ };
346
+ const acceptCurrent = () => {
347
+ if (kind === 'open') {
348
+ if (selected !== null) finish({ name: selected });
349
+ return;
350
+ }
351
+ const name = entry!.value.trim();
352
+ if (name !== '' && !name.includes('/') && name !== '.' && name !== '..') finish({ name });
353
+ };
354
+
355
+ list.addEventListener('row-selected', (event) => {
356
+ const row = (event as CustomEvent<{ row: GtkListBoxRow | null }>).detail.row;
357
+ selected = row?.dataset.name ?? null;
358
+ if (entry && selected !== null) entry.value = selected;
359
+ });
360
+ list.addEventListener('row-activated', (event) => {
361
+ const row = (event as CustomEvent<{ row: GtkListBoxRow }>).detail.row;
362
+ selected = row.dataset.name ?? null;
363
+ if (entry && selected !== null) entry.value = selected;
364
+ acceptCurrent();
365
+ });
366
+ entry?.addEventListener('activate', acceptCurrent);
367
+ accept.addEventListener('click', acceptCurrent);
368
+ dropDown?.addEventListener('notify::selected', (event) => {
369
+ current = filters[(event as CustomEvent<{ selected: number }>).detail.selected] ?? null;
370
+ void refill();
371
+ });
372
+ upload?.addEventListener('click', () => {
373
+ const input = document.createElement('input');
374
+ input.type = 'file';
375
+ const acceptAttr = inputAccept(current);
376
+ if (acceptAttr !== null) input.accept = acceptAttr;
377
+ input.addEventListener('change', () => {
378
+ const file = input.files?.[0];
379
+ if (file) finish({ upload: file });
380
+ });
381
+ input.addEventListener('cancel', () => finish(null));
382
+ input.click();
383
+ });
384
+ dialog.addEventListener('closed', () => finish(null));
385
+
386
+ dialog.present();
387
+ void refill();
388
+ });
389
+ }
390
+
391
+ // --- The real environment -----------------------------------------------------------------------
392
+
393
+ type PickerWindow = {
394
+ showOpenFilePicker?: (options: FilePickerOptions) => Promise<FileSystemFileHandle[]>;
395
+ showSaveFilePicker?: (options: FilePickerOptions) => Promise<FileSystemFileHandle>;
396
+ };
397
+
398
+ export function browserFileChooserEnv(): WebFileChooserEnv {
399
+ const win = (typeof window === 'undefined' ? {} : window) as PickerWindow;
400
+ const pickers = 'showSaveFilePicker' in win && 'showOpenFilePicker' in win;
401
+ return {
402
+ showOpenFilePicker: pickers ? (options) => win.showOpenFilePicker!(options) : undefined,
403
+ showSaveFilePicker: pickers ? (options) => win.showSaveFilePicker!(options) : undefined,
404
+ isActive: () =>
405
+ typeof navigator !== 'undefined' && navigator.userActivation ? navigator.userActivation.isActive : true,
406
+ opfsRoot: async () => {
407
+ if (typeof navigator === 'undefined' || typeof navigator.storage?.getDirectory !== 'function') {
408
+ throw new Error(
409
+ 'this browser has neither the File System Access pickers nor an Origin Private File System',
410
+ );
411
+ }
412
+ return navigator.storage.getDirectory();
413
+ },
414
+ choose: presentOpfsDialog,
415
+ canStreamWrite: (handle) => typeof (handle as { createWritable?: unknown }).createWritable === 'function',
416
+ };
417
+ }
418
+
419
+ export const webFileChooserDriver = createWebFileChooserDriver(browserFileChooserEnv());
420
+
421
+ registerFileChooserDriver(webFileChooserDriver);