@bun-native/protocol 0.0.0-stage → 0.33.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.
@@ -0,0 +1,444 @@
1
+ import { ProtocolError } from './framing.ts'
2
+ import { integer, keys, number, object, string } from './values.ts'
3
+
4
+ export interface Style {
5
+ direction?: 'row' | 'column'
6
+ gap?: number
7
+ padding?: number
8
+ width?: number | 'fill'
9
+ height?: number | 'fill'
10
+ min_width?: number
11
+ max_width?: number
12
+ min_height?: number
13
+ max_height?: number
14
+ flex_grow?: number
15
+ flex_shrink?: number
16
+ align_items?: 'start' | 'center' | 'end' | 'stretch'
17
+ justify_content?: 'start' | 'center' | 'end' | 'space_between' | 'space_around' | 'space_evenly'
18
+ overflow?: 'visible' | 'hidden' | 'scroll_x' | 'scroll_y' | 'scroll'
19
+ /** Requires `scroll_y` or `scroll`. `end` keeps a viewport already at its end pinned there. */
20
+ scroll_anchor?: 'start' | 'end'
21
+ background?: number
22
+ color?: number
23
+ font_size?: number
24
+ /** `style_decoration_v1`: one uniform border width, 0–64 logical pixels. */
25
+ border_width?: number
26
+ /** `style_decoration_v1`: unsigned 24-bit RGB. */
27
+ border_color?: number
28
+ /** `style_decoration_v1`: one radius for every corner, 0–4,096 logical pixels. */
29
+ corner_radius?: number
30
+ /** `style_decoration_v1`: 0–1, multiplying descendants too. */
31
+ opacity?: number
32
+ /** `style_decoration_v1`: inherited 100–900 in steps of 100. */
33
+ font_weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
34
+ }
35
+
36
+ type LeafStyle = Omit<Style, 'overflow' | 'scroll_anchor'> & { overflow?: 'visible' | 'hidden' }
37
+ /** A split's `direction` selects its axis; gap, alignment and scrolling do not apply. */
38
+ export type SplitStyle = Omit<LeafStyle, 'gap' | 'align_items' | 'justify_content'>
39
+
40
+ /** Main-axis thickness of the host-drawn split divider, in logical pixels. */
41
+ export const SPLIT_DIVIDER = 6
42
+
43
+ /** Style fields that require `style_decoration_v1`. */
44
+ export const DECORATION_FIELDS = Object.freeze([
45
+ 'border_width',
46
+ 'border_color',
47
+ 'corner_radius',
48
+ 'opacity',
49
+ 'font_weight',
50
+ ] as const)
51
+
52
+ /** Overlays nested inside overlays, the outermost counting as one. */
53
+ export const MAX_OVERLAY_DEPTH = 8
54
+
55
+ /** Requires the `overlay_keyboard_v1` capability; defaults are omitted when normalized. */
56
+ export interface OverlayKeyboard {
57
+ /** Focus the layer's first enabled control when it opens. */
58
+ autofocus?: boolean
59
+ /** `menu`: Up/Down/Home/End rove focus among the layer's controls and printable keys type ahead. */
60
+ navigation?: 'tab' | 'menu'
61
+ }
62
+
63
+ /**
64
+ * `window` covers the viewport and blocks pointer input beneath it; `parent`
65
+ * is content-sized below its parent's bottom-left corner. Only window
66
+ * overlays may be modal. `surface: 'window'` (capability `popup_windows_v1`)
67
+ * paints a parent-anchored overlay into an owned popup window that may extend
68
+ * beyond its parent window; it needs a fixed pixel `width` and `height`.
69
+ */
70
+ export type OverlayElement = (
71
+ | ({
72
+ kind: 'overlay'
73
+ anchor?: 'window'
74
+ modal?: boolean
75
+ surface?: 'layer'
76
+ style?: Style
77
+ } & OverlayKeyboard)
78
+ | ({
79
+ kind: 'overlay'
80
+ anchor: 'parent'
81
+ modal?: false
82
+ surface?: 'layer'
83
+ style?: Style
84
+ } & OverlayKeyboard)
85
+ | {
86
+ kind: 'overlay'
87
+ anchor: 'parent'
88
+ modal?: false
89
+ surface: 'window'
90
+ /** A popup window never takes keyboard focus from its parent window. */
91
+ autofocus?: false
92
+ navigation?: 'tab'
93
+ style: Style & { width: number; height: number }
94
+ }
95
+ ) &
96
+ AccessibleLabel
97
+
98
+ /**
99
+ * Requires `accessibility_v1`: the accessible name of an overlay's dialog or
100
+ * a split's divider. It is never painted and counts toward the text budgets.
101
+ */
102
+ export interface AccessibleLabel {
103
+ accessible_label?: string
104
+ }
105
+
106
+ /** The largest `accessible_set_size` and `accessible_position`. */
107
+ export const MAX_ACCESSIBLE_SET_SIZE = 2_147_483_647
108
+
109
+ /**
110
+ * Requires `accessibility_lists_v1`. A box without `accessible_role` is layout
111
+ * only and has no accessible node. A list's `accessible_set_size` counts every
112
+ * item, mounted or not; an item's `accessible_position` is its 1-based place
113
+ * in that full set, so assistive technology knows a windowed list's extent.
114
+ */
115
+ export interface BoxAccessibility {
116
+ accessible_role?: 'list' | 'list_item'
117
+ /** Only with a role: the list's or item's accessible name. */
118
+ accessible_label?: string
119
+ /** Only on a `list`. */
120
+ accessible_set_size?: number
121
+ /** Only on a `list_item`; 1-based. */
122
+ accessible_position?: number
123
+ /** Only on a `list_item`. Present: the item is selectable; true when selected. */
124
+ accessible_selected?: boolean
125
+ }
126
+
127
+ export type Element =
128
+ | ({ kind: 'box'; style?: Style } & BoxAccessibility)
129
+ | {
130
+ kind: 'text'
131
+ text: string
132
+ /**
133
+ * Requires `accessibility_lists_v1`: announce the text when the node
134
+ * appears and whenever it changes.
135
+ */
136
+ accessible_live?: 'polite' | 'assertive'
137
+ style?: LeafStyle
138
+ }
139
+ | { kind: 'button'; text: string; disabled?: boolean; style?: LeafStyle }
140
+ | {
141
+ kind: 'input'
142
+ value: string
143
+ placeholder?: string
144
+ /** Requires `input_states_v1`. Not focusable, editable or selectable. */
145
+ disabled?: boolean
146
+ /** Requires `input_states_v1`. Focusable and copyable; native edits are rejected. */
147
+ read_only?: boolean
148
+ style?: LeafStyle
149
+ }
150
+ /**
151
+ * `split_v1`: at most two panes and a host-owned draggable divider. `ratio`
152
+ * (default 0.5) is the leading pane's share after the divider; the host
153
+ * keeps a user's drag until a different ratio is declared.
154
+ */
155
+ | ({ kind: 'split'; ratio?: number; min_size?: number; style?: SplitStyle } & AccessibleLabel)
156
+ | OverlayElement
157
+
158
+ function choice<T extends string>(value: unknown, name: string, values: readonly T[]): T {
159
+ for (const candidate of values) if (candidate === value) return candidate
160
+ throw new ProtocolError(name + ' must be one of ' + values.join(', '))
161
+ }
162
+
163
+ function requireLeafStyle(style: Style): asserts style is LeafStyle {
164
+ if (style.overflow !== undefined && style.overflow !== 'visible' && style.overflow !== 'hidden')
165
+ throw new ProtocolError('Scrolling is supported only on box and overlay elements')
166
+ }
167
+
168
+ function parseStyle(value: unknown): Style {
169
+ const data = object(value)
170
+ keys(data, [
171
+ 'direction',
172
+ 'gap',
173
+ 'padding',
174
+ 'width',
175
+ 'height',
176
+ 'min_width',
177
+ 'max_width',
178
+ 'min_height',
179
+ 'max_height',
180
+ 'flex_grow',
181
+ 'flex_shrink',
182
+ 'align_items',
183
+ 'justify_content',
184
+ 'overflow',
185
+ 'scroll_anchor',
186
+ 'background',
187
+ 'color',
188
+ 'font_size',
189
+ ...DECORATION_FIELDS,
190
+ ])
191
+ const style: Style = {}
192
+ for (const key of ['gap', 'padding'] as const)
193
+ if (key in data) style[key] = number(data[key], key, 0, 4_096)
194
+ for (const key of ['width', 'height'] as const)
195
+ if (key in data) style[key] = data[key] === 'fill' ? 'fill' : number(data[key], key, 0, 16_384)
196
+ for (const key of ['min_width', 'max_width', 'min_height', 'max_height'] as const)
197
+ if (key in data) style[key] = number(data[key], key, 0, 16_384)
198
+ for (const [minimum, maximum] of [
199
+ [style.min_width, style.max_width],
200
+ [style.min_height, style.max_height],
201
+ ])
202
+ if (minimum !== undefined && maximum !== undefined && minimum > maximum)
203
+ throw new ProtocolError('Minimum size must not exceed maximum size')
204
+ for (const key of ['background', 'color', 'border_color'] as const)
205
+ if (key in data) style[key] = integer(data[key], key, 0, 0xff_ffff)
206
+ if ('direction' in data)
207
+ style.direction = choice(data['direction'], 'direction', ['row', 'column'])
208
+ for (const key of ['flex_grow', 'flex_shrink'] as const)
209
+ if (key in data) style[key] = number(data[key], key, 0, 1_000)
210
+ if ('align_items' in data)
211
+ style.align_items = choice(data['align_items'], 'align_items', [
212
+ 'start',
213
+ 'center',
214
+ 'end',
215
+ 'stretch',
216
+ ])
217
+ if ('justify_content' in data)
218
+ style.justify_content = choice(data['justify_content'], 'justify_content', [
219
+ 'start',
220
+ 'center',
221
+ 'end',
222
+ 'space_between',
223
+ 'space_around',
224
+ 'space_evenly',
225
+ ])
226
+ if ('overflow' in data)
227
+ style.overflow = choice(data['overflow'], 'overflow', [
228
+ 'visible',
229
+ 'hidden',
230
+ 'scroll_x',
231
+ 'scroll_y',
232
+ 'scroll',
233
+ ])
234
+ if ('scroll_anchor' in data) {
235
+ style.scroll_anchor = choice(data['scroll_anchor'], 'scroll_anchor', ['start', 'end'])
236
+ if (style.overflow !== 'scroll_y' && style.overflow !== 'scroll')
237
+ throw new ProtocolError('scroll_anchor requires vertical scrolling overflow')
238
+ }
239
+ if ('font_size' in data) style.font_size = number(data['font_size'], 'font_size', 1, 512)
240
+ if ('border_width' in data)
241
+ style.border_width = number(data['border_width'], 'border_width', 0, 64)
242
+ if ('corner_radius' in data)
243
+ style.corner_radius = number(data['corner_radius'], 'corner_radius', 0, 4_096)
244
+ if ('opacity' in data) style.opacity = number(data['opacity'], 'opacity', 0, 1)
245
+ if ('font_weight' in data) {
246
+ const weight = integer(data['font_weight'], 'font_weight', 100, 900)
247
+ if (weight % 100 !== 0) throw new ProtocolError('font_weight must be a multiple of 100')
248
+ style.font_weight = weight as NonNullable<Style['font_weight']>
249
+ }
250
+ return style
251
+ }
252
+
253
+ function accessibleLabel(element: Record<string, unknown>): AccessibleLabel {
254
+ return 'accessible_label' in element
255
+ ? { accessible_label: string(element['accessible_label'], 'accessible_label') }
256
+ : {}
257
+ }
258
+
259
+ function boxAccessibility(element: Record<string, unknown>): BoxAccessibility {
260
+ const label = accessibleLabel(element)
261
+ if (!('accessible_role' in element)) {
262
+ for (const name of [
263
+ 'accessible_label',
264
+ 'accessible_set_size',
265
+ 'accessible_position',
266
+ 'accessible_selected',
267
+ ])
268
+ if (name in element) throw new ProtocolError(name + ' requires accessible_role')
269
+ return {}
270
+ }
271
+ const role = choice(element['accessible_role'], 'accessible_role', ['list', 'list_item'])
272
+ if (role === 'list') {
273
+ for (const name of ['accessible_position', 'accessible_selected'])
274
+ if (name in element) throw new ProtocolError(name + ' is only accepted on a list_item')
275
+ return {
276
+ accessible_role: role,
277
+ ...label,
278
+ ...('accessible_set_size' in element
279
+ ? {
280
+ accessible_set_size: integer(
281
+ element['accessible_set_size'],
282
+ 'accessible_set_size',
283
+ 0,
284
+ MAX_ACCESSIBLE_SET_SIZE
285
+ ),
286
+ }
287
+ : {}),
288
+ }
289
+ }
290
+ if ('accessible_set_size' in element)
291
+ throw new ProtocolError('accessible_set_size is only accepted on a list')
292
+ if ('accessible_selected' in element && typeof element['accessible_selected'] !== 'boolean')
293
+ throw new ProtocolError('accessible_selected must be a boolean')
294
+ return {
295
+ accessible_role: role,
296
+ ...label,
297
+ ...('accessible_position' in element
298
+ ? {
299
+ accessible_position: integer(
300
+ element['accessible_position'],
301
+ 'accessible_position',
302
+ 1,
303
+ MAX_ACCESSIBLE_SET_SIZE
304
+ ),
305
+ }
306
+ : {}),
307
+ ...('accessible_selected' in element
308
+ ? { accessible_selected: element['accessible_selected'] as boolean }
309
+ : {}),
310
+ }
311
+ }
312
+
313
+ /** Normalize and validate the entire property set, including JavaScript consumers. */
314
+ export function parseElement(value: unknown): Element {
315
+ const element = object(value)
316
+ const style = 'style' in element ? parseStyle(element['style']) : {}
317
+ switch (element['kind']) {
318
+ case 'box':
319
+ keys(element, [
320
+ 'kind',
321
+ 'accessible_role',
322
+ 'accessible_label',
323
+ 'accessible_set_size',
324
+ 'accessible_position',
325
+ 'accessible_selected',
326
+ 'style',
327
+ ])
328
+ return { kind: 'box', ...boxAccessibility(element), style }
329
+ case 'text':
330
+ keys(element, ['kind', 'text', 'accessible_live', 'style'])
331
+ requireLeafStyle(style)
332
+ return {
333
+ kind: 'text',
334
+ text: string(element['text'], 'text'),
335
+ ...('accessible_live' in element
336
+ ? {
337
+ accessible_live: choice(element['accessible_live'], 'accessible_live', [
338
+ 'polite',
339
+ 'assertive',
340
+ ]),
341
+ }
342
+ : {}),
343
+ style,
344
+ }
345
+ case 'button': {
346
+ keys(element, ['kind', 'text', 'disabled', 'style'])
347
+ requireLeafStyle(style)
348
+ if ('disabled' in element && typeof element['disabled'] !== 'boolean')
349
+ throw new ProtocolError('disabled must be a boolean')
350
+ return {
351
+ kind: 'button',
352
+ text: string(element['text'], 'text'),
353
+ disabled: element['disabled'] === true,
354
+ style,
355
+ }
356
+ }
357
+ case 'input': {
358
+ keys(element, ['kind', 'value', 'placeholder', 'disabled', 'read_only', 'style'])
359
+ requireLeafStyle(style)
360
+ for (const name of ['disabled', 'read_only'])
361
+ if (name in element && typeof element[name] !== 'boolean')
362
+ throw new ProtocolError(name + ' must be a boolean')
363
+ // Only true states are carried, so ordinary inputs keep their exact wire form.
364
+ return {
365
+ kind: 'input',
366
+ value: string(element['value'], 'value'),
367
+ placeholder: 'placeholder' in element ? string(element['placeholder'], 'placeholder') : '',
368
+ ...(element['disabled'] === true ? { disabled: true } : {}),
369
+ ...(element['read_only'] === true ? { read_only: true } : {}),
370
+ style,
371
+ }
372
+ }
373
+ case 'split': {
374
+ keys(element, ['kind', 'ratio', 'min_size', 'accessible_label', 'style'])
375
+ requireLeafStyle(style)
376
+ if (
377
+ style.gap !== undefined ||
378
+ style.align_items !== undefined ||
379
+ style.justify_content !== undefined
380
+ )
381
+ throw new ProtocolError('Split panes do not accept gap or alignment')
382
+ return {
383
+ kind: 'split',
384
+ ratio: 'ratio' in element ? number(element['ratio'], 'ratio', 0, 1) : 0.5,
385
+ min_size: 'min_size' in element ? number(element['min_size'], 'min_size', 0, 16_384) : 0,
386
+ ...accessibleLabel(element),
387
+ style,
388
+ }
389
+ }
390
+ case 'overlay': {
391
+ keys(element, [
392
+ 'kind',
393
+ 'anchor',
394
+ 'modal',
395
+ 'surface',
396
+ 'autofocus',
397
+ 'navigation',
398
+ 'accessible_label',
399
+ 'style',
400
+ ])
401
+ const label = accessibleLabel(element)
402
+ const anchor =
403
+ 'anchor' in element ? choice(element['anchor'], 'anchor', ['window', 'parent']) : 'window'
404
+ for (const flag of ['modal', 'autofocus'])
405
+ if (flag in element && typeof element[flag] !== 'boolean')
406
+ throw new ProtocolError(flag + ' must be a boolean')
407
+ const modal = element['modal'] === true
408
+ const keyboard: OverlayKeyboard = {
409
+ ...(element['autofocus'] === true ? { autofocus: true } : {}),
410
+ ...('navigation' in element &&
411
+ choice(element['navigation'], 'navigation', ['tab', 'menu']) === 'menu'
412
+ ? { navigation: 'menu' as const }
413
+ : {}),
414
+ }
415
+ const surface =
416
+ 'surface' in element ? choice(element['surface'], 'surface', ['layer', 'window']) : 'layer'
417
+ if (surface === 'window') {
418
+ if (anchor !== 'parent')
419
+ throw new ProtocolError('A popup window overlay must be anchored to its parent')
420
+ if (modal) throw new ProtocolError('A modal overlay must be anchored to the window')
421
+ if (keyboard.autofocus || keyboard.navigation)
422
+ throw new ProtocolError('A popup window overlay does not take keyboard focus')
423
+ const { width, height } = style
424
+ if (typeof width !== 'number' || width < 1 || typeof height !== 'number' || height < 1)
425
+ throw new ProtocolError('A popup window overlay needs a fixed width and height')
426
+ return {
427
+ kind: 'overlay',
428
+ anchor,
429
+ modal: false,
430
+ surface,
431
+ ...label,
432
+ style: { ...style, width, height },
433
+ }
434
+ }
435
+ if (anchor === 'parent') {
436
+ if (modal) throw new ProtocolError('A modal overlay must be anchored to the window')
437
+ return { kind: 'overlay', anchor, modal: false, ...keyboard, ...label, style }
438
+ }
439
+ return { kind: 'overlay', anchor, modal, ...keyboard, ...label, style }
440
+ }
441
+ default:
442
+ throw new ProtocolError('Unknown native element kind')
443
+ }
444
+ }