@octane-xplat/macos-renderer 0.0.1 → 0.10.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.
package/src/index.ts ADDED
@@ -0,0 +1,4652 @@
1
+ import { showWindowLayer } from './layer.mjs'
2
+ import '@nativescript/macos-node-api'
3
+ import { loadImage } from './image'
4
+ import { installPresentationBridge } from './presentation.mjs'
5
+ import { makeWebView, updateWebView, disposeWebView } from './webview'
6
+ import { createUniversalRoot } from 'octane/universal/native'
7
+ import type {
8
+ UniversalHostCommand,
9
+ UniversalHostDriver,
10
+ UniversalRoot,
11
+ } from 'octane/universal/native'
12
+
13
+ import { resolveFont as fontForFamilyStyle } from './fonts'
14
+ export { registerFontFamily } from './fonts'
15
+
16
+ /** Props arrive from compiled elements as open-ended bags; each leaf
17
+ * factory or applier reads only the keys it understands. */
18
+ type PropBag = Record<string, any>
19
+
20
+ /** The payload `onPan` listeners receive from `ButtonActionTarget.viewPanned`. */
21
+ interface PanEvent {
22
+ deltaX: number
23
+ deltaY: number
24
+ velocityX: number
25
+ velocityY: number
26
+ state: number
27
+ view: NSView
28
+ }
29
+
30
+ /** A retained element in the AppKit tree. `view` is null for `#text` and
31
+ * `span` nodes, whose content folds into the parent's attributed string. */
32
+ export interface ElementNode {
33
+ id: number
34
+ type: string
35
+ view: NSView | null
36
+ childHost?: NSView
37
+ props: PropBag
38
+ parent: ElementNode | null
39
+ children: ElementNode[]
40
+ container: RootContainer
41
+ appliedFontFamily: string | undefined
42
+ actionId?: number
43
+ scrollObserverInstalled: boolean
44
+ text: string
45
+ // Per-kind extras: placeholderView, marginHost, marginConstraints,
46
+ // crossAxisConstraint, sizeConstraints, styleBg, bgSlot, layoutObserver,
47
+ // scrollObserver, scheme, textRuns, appliedClassName, ...
48
+ [key: string]: any
49
+ }
50
+
51
+ /** A `<gridlayout>` child's resolved position after props parsing. */
52
+ interface GridPlacement {
53
+ child: ElementNode
54
+ row: number | null
55
+ col: number | null
56
+ rowSpan: number
57
+ colSpan: number
58
+ }
59
+
60
+ /** One `rows`/`columns` token: `auto`, `Npx`/`N`, `N%`, or `Nfr`/`N*`. */
61
+ interface GridTrack {
62
+ kind: 'auto' | 'fraction' | 'fixed'
63
+ value: number
64
+ }
65
+
66
+ /** Options published through `__xplatAppKit.showAnchoredPopup` (Tooltip/Hoverable). */
67
+ interface PopupOptions {
68
+ anchor?: NSView
69
+ at?: { x: number; y: number }
70
+ component?: any
71
+ placement?: string
72
+ [key: string]: any
73
+ }
74
+
75
+ interface ContextMenuItem {
76
+ id?: string
77
+ title?: string
78
+ disabled?: boolean
79
+ divider?: boolean
80
+ }
81
+
82
+ /** Options published through `__xplatAppKit.attachContextMenu`. */
83
+ interface ContextMenuOptions {
84
+ items?: ContextMenuItem[]
85
+ [key: string]: any
86
+ }
87
+
88
+ /** Per-root state shared by the driver commands. */
89
+ interface RootContainer {
90
+ hostView: NSView
91
+ fontFamily: string | undefined
92
+ nodes: Map<number, ElementNode>
93
+ children: ElementNode[]
94
+ root: UniversalRoot | null
95
+ }
96
+
97
+ const actionHandlers = new Map<number, (() => void) | null>()
98
+ const actionIdsByView = new WeakMap<object, number>()
99
+ const textNodesByView = new WeakMap<object, ElementNode>()
100
+ const panHandlersByView = new WeakMap<object, ((event: PanEvent) => void) | null>()
101
+ const gridLayoutNodesByView = new WeakMap<object, ElementNode>()
102
+ const absoluteLayoutNodesByView = new WeakMap<object, ElementNode>()
103
+ const accessibilityLabels = new Map<number, string>()
104
+ const accessibilityRoles = new Map<number, string>()
105
+ const stackAccessibilityPropsByView = new WeakMap<object, PropBag>()
106
+ const scrollHandlers = new WeakMap<object, () => void>()
107
+ let nextActionId = 1
108
+ const DEFAULT_TEXT_LINE_HEIGHT_RATIO = 21 / 16
109
+
110
+ function invokeAction(actionId: number | undefined) {
111
+ const action = actionId === undefined ? undefined : actionHandlers.get(actionId)
112
+ if (action) {
113
+ action()
114
+ }
115
+ }
116
+
117
+ class ButtonActionTarget extends NSObject {
118
+ static ObjCProtocols = [NSTextViewDelegate]
119
+
120
+ static ObjCExposedMethods = {
121
+ buttonPressed: { params: [NSButton], returns: interop.types.void },
122
+ viewPressed: { params: [NSObject], returns: interop.types.void },
123
+ viewPanned: { params: [NSPanGestureRecognizer], returns: interop.types.void },
124
+ controlChanged: { params: [NSObject], returns: interop.types.void },
125
+ textDidChange: { params: [NSNotification], returns: interop.types.void },
126
+ }
127
+
128
+ static {
129
+ NativeClass(this)
130
+ }
131
+
132
+ buttonPressed(sender: NSButton) {
133
+ invokeAction(sender.tag)
134
+ }
135
+
136
+ viewPressed(sender: NSObject) {
137
+ invokeAction(actionIdsByView.get(sender.view))
138
+ }
139
+
140
+ controlChanged(sender: NSObject) {
141
+ invokeAction(sender.tag ?? actionIdsByView.get(sender))
142
+ }
143
+
144
+ viewPanned(sender: NSPanGestureRecognizer) {
145
+ const handler = panHandlersByView.get(sender.view)
146
+ if (!handler) {
147
+ return
148
+ }
149
+
150
+ const translation = sender.translationInView(sender.view)
151
+ const velocity = sender.velocityInView(sender.view)
152
+ const flipped =
153
+ typeof sender.view.isFlipped === 'function' ? sender.view.isFlipped() : sender.view.isFlipped
154
+
155
+ const signY = flipped ? 1 : -1
156
+ const nativeState = Number(sender.state)
157
+ const state = nativeState === 1 ? 1 : nativeState === 2 ? 2 : nativeState === 3 ? 3 : 0
158
+ try {
159
+ handler({
160
+ deltaX: Number(translation.x ?? 0),
161
+ deltaY: Number(translation.y ?? 0) * signY,
162
+ velocityX: Number(velocity.x ?? 0),
163
+ velocityY: Number(velocity.y ?? 0) * signY,
164
+ state,
165
+ view: sender.view,
166
+ })
167
+ } catch (error) {
168
+ console.error('[macos-event] pan handler failed', error)
169
+ }
170
+ }
171
+
172
+ textDidChange(notification: NSNotification) {
173
+ const node = textNodesByView.get(notification.object)
174
+ syncTextViewPlaceholder(node)
175
+ invokeAction(actionIdsByView.get(notification.object))
176
+ }
177
+ }
178
+
179
+ const buttonActionTarget = ButtonActionTarget.new()
180
+
181
+ class ContentAlignedTextField extends NSTextField {
182
+ static {
183
+ NativeClass(this)
184
+ }
185
+
186
+ // These controls have no bezel or background, so the text area and layout
187
+ // frame should share the same edges.
188
+ alignmentRectInsets() {
189
+ return { top: 0, left: 0, bottom: 0, right: 0 }
190
+ }
191
+ }
192
+
193
+ const inputTransparentViews = new WeakSet<object>()
194
+
195
+ class AccessibleStackView extends NSStackView {
196
+ // Self-drawn AppKit buttons need a responder so modal decisions can use
197
+ // the keyboard as well as accessibility actions.
198
+ acceptsFirstResponder() {
199
+ return typeof actionHandlers.get(actionIdsByView.get(this) ?? -1) === 'function'
200
+ }
201
+
202
+ keyDown(event: any) {
203
+ if (
204
+ (Number(event.keyCode) === 36 || Number(event.keyCode) === 49) &&
205
+ this.acceptsFirstResponder()
206
+ ) {
207
+ invokeAction(actionIdsByView.get(this)!)
208
+ return
209
+ }
210
+
211
+ super.keyDown(event)
212
+ }
213
+
214
+ hitTest(point: any) {
215
+ return inputTransparentViews.has(this) ? null : super.hitTest(point)
216
+ }
217
+
218
+ static ObjCExposedMethods = {
219
+ accessibilityPerformPress: { params: [], returns: interop.types.bool },
220
+ accessibilityRole: { params: [], returns: interop.types.id },
221
+ accessibilityLabel: { params: [], returns: interop.types.id },
222
+ accessibilityValue: { params: [], returns: interop.types.id },
223
+ accessibilityHelp: { params: [], returns: interop.types.id },
224
+ accessibilityIsEnabled: { params: [], returns: interop.types.bool },
225
+ accessibilityIsIgnored: { params: [], returns: interop.types.bool },
226
+ }
227
+
228
+ static {
229
+ NativeClass(this)
230
+ }
231
+
232
+ accessibilityPerformPress() {
233
+ const actionId = actionIdsByView.get(this)
234
+ if (
235
+ actionId === undefined ||
236
+ typeof actionHandlers.get(actionId) !== 'function' ||
237
+ !this.accessibilityIsEnabled()
238
+ ) {
239
+ return false
240
+ }
241
+
242
+ invokeAction(actionId)
243
+ return true
244
+ }
245
+
246
+ accessibilityRole() {
247
+ const role = accessibilityRoles.get(actionIdsByView.get(this) ?? -1)
248
+ return role === 'checkbox' ? 'AXCheckBox' : role === 'button' ? 'AXButton' : 'AXGroup'
249
+ }
250
+
251
+ accessibilityLabel() {
252
+ return accessibilityLabels.get(actionIdsByView.get(this) ?? -1) ?? ''
253
+ }
254
+
255
+ accessibilityValue() {
256
+ const props = stackAccessibilityPropsByView.get(this)
257
+ if (this.accessibilityRole() === 'AXCheckBox') {
258
+ // AppKit uses 0/1/2 for off/on/mixed checkbox values.
259
+ return NSNumber.numberWithDouble(
260
+ props?.accessibilityState?.checked === 'mixed'
261
+ ? 2
262
+ : props?.accessibilityState?.checked === true
263
+ ? 1
264
+ : 0,
265
+ )
266
+ }
267
+
268
+ return props?.accessibilityValue ?? ''
269
+ }
270
+
271
+ accessibilityHelp() {
272
+ return stackAccessibilityPropsByView.get(this)?.accessibilityHint ?? ''
273
+ }
274
+
275
+ accessibilityIsEnabled() {
276
+ return !stackAccessibilityPropsByView.get(this)?.accessibilityState?.disabled
277
+ }
278
+
279
+ accessibilityIsIgnored() {
280
+ return !accessibilityRoles.has(actionIdsByView.get(this) ?? -1)
281
+ }
282
+ }
283
+
284
+ // The macOS Slider uses the grid host as an overlay when no tracks are set.
285
+ class GridLayoutView extends NSView {
286
+ static ObjCExposedMethods = {
287
+ accessibilityRole: { params: [], returns: interop.types.id },
288
+ accessibilityLabel: { params: [], returns: interop.types.id },
289
+ accessibilityValue: { params: [], returns: interop.types.id },
290
+ accessibilityMinValue: { params: [], returns: interop.types.id },
291
+ accessibilityMaxValue: { params: [], returns: interop.types.id },
292
+ accessibilityIsIgnored: { params: [], returns: interop.types.bool },
293
+ accessibilityIsEnabled: { params: [], returns: interop.types.bool },
294
+ accessibilityPerformIncrement: { params: [], returns: interop.types.bool },
295
+ accessibilityPerformDecrement: { params: [], returns: interop.types.bool },
296
+ }
297
+
298
+ static {
299
+ NativeClass(this)
300
+ }
301
+
302
+ accessibilityRole() {
303
+ const node = gridLayoutNodesByView.get(this)
304
+ return node?.props.accessibilityRole === 'adjustable' ? 'AXSlider' : 'AXGroup'
305
+ }
306
+
307
+ accessibilityLabel() {
308
+ return gridLayoutNodesByView.get(this)?.props.accessibilityLabel ?? ''
309
+ }
310
+
311
+ accessibilityValue() {
312
+ return NSNumber.numberWithDouble(
313
+ Number(gridLayoutNodesByView.get(this)?.props.accessibilityValue ?? 0),
314
+ )
315
+ }
316
+
317
+ accessibilityMinValue() {
318
+ return NSNumber.numberWithDouble(
319
+ Number(gridLayoutNodesByView.get(this)?.props.accessibilityMinValue ?? 0),
320
+ )
321
+ }
322
+
323
+ accessibilityMaxValue() {
324
+ return NSNumber.numberWithDouble(
325
+ Number(gridLayoutNodesByView.get(this)?.props.accessibilityMaxValue ?? 100),
326
+ )
327
+ }
328
+
329
+ accessibilityIsIgnored() {
330
+ return !gridLayoutNodesByView.get(this)?.props.accessibilityRole
331
+ }
332
+
333
+ accessibilityIsEnabled() {
334
+ return gridLayoutNodesByView.get(this)?.props.disabled !== true
335
+ }
336
+
337
+ accessibilityPerformIncrement() {
338
+ return performGridAccessibilityAdjustment(this, 'onAccessibilityIncrement')
339
+ }
340
+
341
+ accessibilityPerformDecrement() {
342
+ return performGridAccessibilityAdjustment(this, 'onAccessibilityDecrement')
343
+ }
344
+
345
+ layout() {
346
+ super.layout()
347
+ layoutGridChildren(gridLayoutNodesByView.get(this))
348
+ }
349
+ }
350
+
351
+ // Absolute's host — children position via their own left/top/right/bottom
352
+ // layout-child props; the layout() pass sets frames directly.
353
+ class AbsoluteLayoutView extends NSView {
354
+ static {
355
+ NativeClass(this)
356
+ }
357
+
358
+ layout() {
359
+ super.layout()
360
+ layoutAbsoluteChildren(absoluteLayoutNodesByView.get(this))
361
+ }
362
+ }
363
+
364
+ function makeStack(props: PropBag, StackClass: NSClass<NSStackView> = NSStackView) {
365
+ const stack = StackClass.alloc().initWithFrame({
366
+ origin: { x: 0, y: 0 },
367
+ size: { width: 480, height: 320 },
368
+ })
369
+
370
+ stack.orientation =
371
+ props.flexDirection === 'row'
372
+ ? NSUserInterfaceLayoutOrientation.Horizontal
373
+ : NSUserInterfaceLayoutOrientation.Vertical
374
+
375
+ stack.alignment = stackAlignmentAttribute(stack, props.alignItems ?? 'stretch')
376
+ stack.distribution = NSStackViewDistribution.GravityAreas
377
+ stack.spacing = Number(props.gap ?? props.spacing ?? 0)
378
+ stack.translatesAutoresizingMaskIntoConstraints = false
379
+ return stack
380
+ }
381
+
382
+ function stackAlignmentAttribute(view: any, value: string) {
383
+ const horizontal = view.orientation === NSUserInterfaceLayoutOrientation.Horizontal
384
+ if (value === 'center') {
385
+ return horizontal ? NSLayoutAttribute.CenterY : NSLayoutAttribute.CenterX
386
+ }
387
+
388
+ if (value === 'end' || value === 'flex-end') {
389
+ return horizontal ? NSLayoutAttribute.Bottom : NSLayoutAttribute.Right
390
+ }
391
+
392
+ if (value === 'stretch' || value === 'start' || value === 'flex-start' || value === 'normal') {
393
+ return horizontal ? NSLayoutAttribute.Top : NSLayoutAttribute.Left
394
+ }
395
+
396
+ if (value === 'baseline' && horizontal) {
397
+ return NSLayoutAttribute.FirstBaseline
398
+ }
399
+
400
+ return horizontal ? NSLayoutAttribute.Top : NSLayoutAttribute.Left
401
+ }
402
+
403
+ function stackAlignItems(node: ElementNode) {
404
+ const classes = nodeClasses(node)
405
+ if (classes.includes('vx-button') || classes.includes('items-center')) {
406
+ return 'center'
407
+ }
408
+
409
+ if (classes.includes('items-start')) {
410
+ return 'start'
411
+ }
412
+
413
+ if (classes.includes('items-end')) {
414
+ return 'end'
415
+ }
416
+
417
+ return node.props.alignItems ?? 'stretch'
418
+ }
419
+
420
+ function stackJustifyContent(node: ElementNode) {
421
+ if (nodeClasses(node).includes('vx-button')) {
422
+ return 'center'
423
+ }
424
+
425
+ if (nodeClasses(node).includes('justify-between')) {
426
+ return 'space-between'
427
+ }
428
+
429
+ return node.props.justifyContent ?? 'start'
430
+ }
431
+
432
+ function updateCrossAxisConstraints(parent: ElementNode) {
433
+ const stack = parent.childHost ?? parent.view
434
+ if (!stack || stack.orientation == null) {
435
+ return
436
+ }
437
+
438
+ const horizontal = stack.orientation === NSUserInterfaceLayoutOrientation.Horizontal
439
+ const dimension = horizontal ? 'height' : 'width'
440
+ const alignItems = stackAlignItems(parent)
441
+ // Stretch must respect edgeInsets — an unconstrained child would
442
+ // otherwise span the stack's full cross size, overflowing the padding.
443
+ const insets = stack.edgeInsets ?? { top: 0, left: 0, bottom: 0, right: 0 }
444
+ const inset = horizontal ? insets.top + insets.bottom : insets.left + insets.right
445
+ for (const child of parent.children) {
446
+ if (child.crossAxisConstraint) {
447
+ child.crossAxisConstraint.active = false
448
+ }
449
+
450
+ child.crossAxisConstraint = null
451
+ const childView = arrangedView(child)
452
+ if (alignItems !== 'stretch' || !childView || child.sizeConstraintSpecs?.[dimension]) {
453
+ continue
454
+ }
455
+
456
+ const anchor = horizontal ? 'heightAnchor' : 'widthAnchor'
457
+ child.crossAxisConstraint = inset
458
+ ? childView[anchor].constraintEqualToAnchorConstant(stack[anchor], -inset)
459
+ : childView[anchor].constraintEqualToAnchor(stack[anchor])
460
+
461
+ child.crossAxisConstraint.active = true
462
+ }
463
+ }
464
+
465
+ const MARGIN_SIDES: Record<string, string> = {
466
+ marginTop: 'top',
467
+ marginRight: 'right',
468
+ marginBottom: 'bottom',
469
+ marginLeft: 'left',
470
+ }
471
+
472
+ function marginInsetsOf(node: ElementNode) {
473
+ const insets = node.marginInsets
474
+ return insets && (insets.top || insets.right || insets.bottom || insets.left) ? insets : null
475
+ }
476
+
477
+ /** The view a stack should arrange for a child — a margin wrapper when set. */
478
+ function arrangedView(node: ElementNode) {
479
+ return node.marginHost ?? node.view
480
+ }
481
+
482
+ /** NSStackView has no per-child margins, so a margined child is wrapped in a
483
+ * plain container: the wrapper is the arranged subview and the child pins
484
+ * inside it with margin-offset anchors. */
485
+ function makeMarginHost(node: ElementNode) {
486
+ const host = NSView.alloc().initWithFrame({
487
+ origin: { x: 0, y: 0 },
488
+ size: { width: 0, height: 0 },
489
+ })
490
+
491
+ host.translatesAutoresizingMaskIntoConstraints = false
492
+ host.addSubview(node.view)
493
+ const insets = marginInsetsOf(node)
494
+ const constraints = [
495
+ node.view!.leadingAnchor.constraintEqualToAnchorConstant(host.leadingAnchor, insets.left),
496
+ node.view!.trailingAnchor.constraintEqualToAnchorConstant(host.trailingAnchor, -insets.right),
497
+ node.view!.topAnchor.constraintEqualToAnchorConstant(host.topAnchor, insets.top),
498
+ node.view!.bottomAnchor.constraintEqualToAnchorConstant(host.bottomAnchor, -insets.bottom),
499
+ ]
500
+
501
+ for (const constraint of constraints) {
502
+ constraint.active = true
503
+ }
504
+
505
+ node.marginHost = host
506
+ node.marginConstraints = constraints
507
+ return host
508
+ }
509
+
510
+ /** Wrap/unwrap/re-apply margins on a node that is already arranged inside a
511
+ * stack. Margins set before insert are materialized there instead. */
512
+ function syncMarginHost(node: ElementNode) {
513
+ const parent = node.parent
514
+ if (!node.view || !parent) {
515
+ return
516
+ }
517
+
518
+ const stack = parent.childHost ?? parent.view
519
+ if (typeof stack?.addViewInGravity !== 'function') {
520
+ // Grid children are laid out manually and already honor margins.
521
+ if (marginInsetsOf(node) && parent.type !== 'gridlayout') {
522
+ console.warn('[macos-style] ignored margins on <' + node.type + '> — parent is not a stack')
523
+ }
524
+
525
+ return
526
+ }
527
+
528
+ const insets = marginInsetsOf(node)
529
+ const index = Math.max(parent.children.indexOf(node), 0)
530
+ if (insets && !node.marginHost) {
531
+ const gravity = stackGravity(parent, node)
532
+ stack.removeArrangedSubview(node.view)
533
+ node.view.removeFromSuperview()
534
+ const host = makeMarginHost(node)
535
+ stack.insertViewAtIndexInGravity(host, index, gravity)
536
+ updateCrossAxisConstraints(parent)
537
+ } else if (!insets && node.marginHost) {
538
+ const gravity = stackGravity(parent, node)
539
+ stack.removeArrangedSubview(node.marginHost)
540
+ node.view.removeFromSuperview()
541
+ node.marginHost.removeFromSuperview()
542
+ node.marginHost = null
543
+ node.marginConstraints = null
544
+ stack.insertViewAtIndexInGravity(node.view, index, gravity)
545
+ updateCrossAxisConstraints(parent)
546
+ } else if (insets && node.marginHost) {
547
+ const [leading, trailing, top, bottom] = node.marginConstraints
548
+ leading.constant = insets.left
549
+ trailing.constant = -insets.right
550
+ top.constant = insets.top
551
+ bottom.constant = -insets.bottom
552
+ }
553
+ }
554
+
555
+ function setMarginStyle(node: ElementNode, name: string, value: any) {
556
+ const points = Number(value)
557
+ if (!Number.isFinite(points)) {
558
+ console.warn(
559
+ '[macos-style] ignored unsupported style.' + name + ' value ' + JSON.stringify(value),
560
+ )
561
+
562
+ return
563
+ }
564
+
565
+ const insets: Record<string, number> = (node.marginInsets ??= {
566
+ top: 0,
567
+ right: 0,
568
+ bottom: 0,
569
+ left: 0,
570
+ })
571
+
572
+ if (name === 'margin') {
573
+ insets.top = insets.right = insets.bottom = insets.left = points
574
+ } else {
575
+ insets[MARGIN_SIDES[name]] = points
576
+ }
577
+
578
+ if (node.parent?.type === 'gridlayout') {
579
+ // Grids lay out children manually from props.style — no wrapper needed.
580
+ layoutGridChildren(node.parent)
581
+ return
582
+ }
583
+
584
+ syncMarginHost(node)
585
+ }
586
+
587
+ function stackGravity(parent: ElementNode, child: ElementNode) {
588
+ const stack = parent.childHost ?? parent.view
589
+ const horizontal = stack!.orientation === NSUserInterfaceLayoutOrientation.Horizontal
590
+ const leading = horizontal ? NSStackViewGravity.Leading : NSStackViewGravity.Top
591
+ const trailing = horizontal ? NSStackViewGravity.Trailing : NSStackViewGravity.Bottom
592
+ const justifyContent = stackJustifyContent(parent)
593
+ if (justifyContent === 'center') {
594
+ return NSStackViewGravity.Center
595
+ }
596
+
597
+ if (justifyContent === 'end' || justifyContent === 'flex-end') {
598
+ return trailing
599
+ }
600
+
601
+ if (justifyContent === 'space-between') {
602
+ const index = parent.children.indexOf(child)
603
+ if (index === 0) {
604
+ return leading
605
+ }
606
+
607
+ if (index === parent.children.length - 1) {
608
+ return trailing
609
+ }
610
+
611
+ return NSStackViewGravity.Center
612
+ }
613
+
614
+ return leading
615
+ }
616
+
617
+ function moveStackChildren(parent: ElementNode) {
618
+ const stack = parent.childHost ?? parent.view
619
+ if (typeof stack?.addViewInGravity !== 'function') {
620
+ return
621
+ }
622
+
623
+ for (const child of parent.children) {
624
+ if (!child.view) {
625
+ continue
626
+ }
627
+
628
+ const arranged = arrangedView(child)
629
+ stack.removeArrangedSubview(arranged)
630
+ arranged.removeFromSuperview()
631
+ stack.addViewInGravity(arranged, stackGravity(parent, child))
632
+ setStackChildPriorities(parent, child)
633
+ }
634
+
635
+ updateCrossAxisConstraints(parent)
636
+ }
637
+
638
+ function updateStackDistribution(parent: ElementNode) {
639
+ const stack = parent.childHost ?? parent.view
640
+ if (stack?.orientation == null) {
641
+ return
642
+ }
643
+
644
+ if (stackJustifyContent(parent) === 'space-between') {
645
+ stack.distribution = NSStackViewDistribution.EqualSpacing
646
+ return
647
+ }
648
+
649
+ const grows = parent.children.some((child) => child.view && nodeClasses(child).includes('flex-1'))
650
+
651
+ stack.distribution = grows ? NSStackViewDistribution.Fill : NSStackViewDistribution.GravityAreas
652
+ }
653
+
654
+ function setStackChildPriorities(parent: ElementNode, child: ElementNode) {
655
+ const stack = parent.childHost ?? parent.view
656
+ if (stack?.orientation == null || !child.view) {
657
+ return
658
+ }
659
+
660
+ const mainAxis = stack.orientation
661
+ const grow = nodeClasses(child).includes('flex-1')
662
+ for (const orientation of [
663
+ NSUserInterfaceLayoutOrientation.Horizontal,
664
+ NSUserInterfaceLayoutOrientation.Vertical,
665
+ ]) {
666
+ // Cross-axis hugging must stay below AppKit's WindowSizeStayPut (500):
667
+ // along the required contentView→root pin chain, any nested child
668
+ // hugging higher than 500 outranks the window's own hold and locks the
669
+ // window at the content's fitting size in that axis.
670
+ const priority = orientation === mainAxis ? (grow ? 1 : 750) : 250
671
+ const target = arrangedView(child)
672
+ target.setContentHuggingPriorityForOrientation(priority, orientation)
673
+ target.setContentCompressionResistancePriorityForOrientation(750, orientation)
674
+ }
675
+
676
+ updateStackDistribution(parent)
677
+ }
678
+
679
+ function makeFlexbox(props: PropBag) {
680
+ const stack = makeStack(props, AccessibleStackView)
681
+ const actionId =
682
+ typeof props.onTap === 'function' || props.accessibilityRole === 'button'
683
+ ? nextActionId++
684
+ : undefined
685
+
686
+ if (actionId !== undefined) {
687
+ actionIdsByView.set(stack, actionId)
688
+ actionHandlers.set(actionId, null)
689
+ stack.addGestureRecognizer(
690
+ NSClickGestureRecognizer.alloc().initWithTargetAction(buttonActionTarget, 'viewPressed'),
691
+ )
692
+ }
693
+
694
+ return { view: stack, actionId }
695
+ }
696
+
697
+ function makeLabel(): NSTextField {
698
+ const label = NSTextField.alloc().initWithFrame({
699
+ origin: { x: 0, y: 0 },
700
+ size: { width: 400, height: 32 },
701
+ })
702
+
703
+ label.bezeled = false
704
+ label.drawsBackground = false
705
+ label.editable = false
706
+ label.selectable = false
707
+ label.alignment = NSTextAlignment.Left
708
+ label.cell.wraps = true
709
+ label.cell.usesSingleLineMode = false
710
+ label.translatesAutoresizingMaskIntoConstraints = false
711
+ label.font = fontForStyle(16)
712
+ label.textColor = nativeColor('#0a0a0a')
713
+ return label
714
+ }
715
+
716
+ function makeButton(props: PropBag) {
717
+ const button = NSButton.buttonWithTitleTargetAction(
718
+ String(props.title ?? 'Button'),
719
+ buttonActionTarget,
720
+ 'buttonPressed',
721
+ )
722
+
723
+ button.bezelStyle = NSBezelStyle.Rounded
724
+ button.setButtonType(NSButtonType.MomentaryLight)
725
+ button.translatesAutoresizingMaskIntoConstraints = false
726
+ const actionId = nextActionId++
727
+ button.tag = actionId
728
+ actionHandlers.set(actionId, null)
729
+ return { view: button, actionId }
730
+ }
731
+
732
+ function makeScrollView() {
733
+ const scroll = NSScrollView.alloc().initWithFrame({
734
+ origin: { x: 0, y: 0 },
735
+ size: { width: 480, height: 320 },
736
+ })
737
+
738
+ const content = makeStack({ flexDirection: 'column' })
739
+ scroll.hasVerticalScroller = true
740
+ scroll.drawsBackground = false
741
+ scroll.borderType = NSBorderType.NoBorder
742
+ scroll.translatesAutoresizingMaskIntoConstraints = false
743
+ scroll.documentView = content
744
+ content.widthAnchor.constraintEqualToAnchor(scroll.contentView.widthAnchor).active = true
745
+ return { view: scroll, childHost: content }
746
+ }
747
+
748
+ function setScrollAction(node: ElementNode, handler: ((event: any) => void) | undefined) {
749
+ const clipView = node.view!.contentView
750
+ if (typeof handler !== 'function') {
751
+ scrollHandlers.delete(clipView)
752
+ return
753
+ }
754
+
755
+ if (!node.scrollObserverInstalled) {
756
+ clipView.postsBoundsChangedNotifications = true
757
+ node.scrollObserver =
758
+ NSNotificationCenter.defaultCenter.addObserverForNameObjectQueueUsingBlock(
759
+ NSViewBoundsDidChangeNotification,
760
+ clipView,
761
+ null,
762
+ () => scrollHandlers.get(clipView)?.(),
763
+ )
764
+
765
+ node.scrollObserverInstalled = true
766
+ }
767
+
768
+ scrollHandlers.set(clipView, () => {
769
+ const startedAt = performance.now()
770
+ const bounds = clipView.bounds
771
+ const contentHeight = Number(node.view!.documentView?.frame?.size?.height ?? 0)
772
+ const event = {
773
+ verticalOffset: Number(bounds.origin.y ?? 0),
774
+ viewportHeight: Number(bounds.size.height ?? 0),
775
+ contentHeight,
776
+ }
777
+
778
+ try {
779
+ node.container.root!.eventScope('discrete', () => handler(event))
780
+ } catch (error) {
781
+ console.error('[macos-event] scroll handler failed', error)
782
+ }
783
+
784
+ if (process.env.OCTANE_MACOS_AUTOMATION === '1') {
785
+ node.scrollMetrics ??= { events: [] }
786
+ const sample = {
787
+ verticalOffset: event.verticalOffset,
788
+ callbackMs: performance.now() - startedAt,
789
+ mountedRows: null as number | null,
790
+ afterEventMs: null as number | null,
791
+ }
792
+
793
+ node.scrollMetrics.events.push(sample)
794
+ if (node.scrollMetrics.events.length > 2000) {
795
+ node.scrollMetrics.events.shift()
796
+ }
797
+
798
+ setTimeout(() => {
799
+ sample.afterEventMs = performance.now() - startedAt
800
+ sample.mountedRows = [...node.container.nodes.values()].filter(
801
+ (candidate) =>
802
+ /^(?:row-r|bench-row-)\d+$/.test(String(candidate.props.id ?? '')) &&
803
+ candidate.parent !== null &&
804
+ candidate.view?.superview != null,
805
+ ).length
806
+ }, 0)
807
+ }
808
+ })
809
+ }
810
+
811
+ const layoutHandlers = new WeakMap<object, () => void>()
812
+
813
+ /** `onLayoutChanged` prop: NSViewFrameDidChangeNotification on the arranged
814
+ * view (margin host when present) — the windowed VirtualList leaf measures
815
+ * rows through it. */
816
+ function setLayoutAction(node: ElementNode, handler: ((event: any) => void) | undefined) {
817
+ const target = arrangedView(node)
818
+ if (!target || node.type === '#text' || node.type === 'span') {
819
+ return
820
+ }
821
+
822
+ if (typeof handler !== 'function') {
823
+ layoutHandlers.delete(target)
824
+ return
825
+ }
826
+
827
+ target.postsFrameChangedNotifications = true
828
+ if (!node.layoutObserver) {
829
+ node.layoutObserver =
830
+ NSNotificationCenter.defaultCenter.addObserverForNameObjectQueueUsingBlock(
831
+ typeof NSViewFrameDidChangeNotification !== 'undefined'
832
+ ? NSViewFrameDidChangeNotification
833
+ : 'NSViewFrameDidChangeNotification',
834
+ target,
835
+ null,
836
+ () => layoutHandlers.get(target)?.(),
837
+ )
838
+ }
839
+
840
+ layoutHandlers.set(target, () => {
841
+ const bounds = target.bounds
842
+ try {
843
+ node.container.root!.eventScope('discrete', () =>
844
+ handler({
845
+ object: target,
846
+ width: Number(bounds.size.width ?? 0),
847
+ height: Number(bounds.size.height ?? 0),
848
+ }),
849
+ )
850
+ } catch (error) {
851
+ console.error('[macos-event] layout handler failed', error)
852
+ }
853
+ })
854
+ }
855
+
856
+ function setTextFieldPlaceholder(field: NSTextField, props: PropBag, scheme = 'light') {
857
+ const placeholder = String(props.placeholder ?? '')
858
+ if (!placeholder) {
859
+ field.placeholderAttributedString = null
860
+ field.placeholderString = placeholder
861
+ return
862
+ }
863
+
864
+ field.placeholderString = placeholder
865
+ field.placeholderAttributedString = NSAttributedString.alloc().initWithStringAttributes(
866
+ placeholder,
867
+ {
868
+ [NSFontAttributeName]: field.font,
869
+ [NSForegroundColorAttributeName]: nativeColor(
870
+ props.placeholderTextColor != null
871
+ ? String(props.placeholderTextColor)
872
+ : SCHEME_COLORS[scheme].placeholder,
873
+ ),
874
+ },
875
+ )
876
+ }
877
+
878
+ function makeTextField(props: PropBag, multiline = false) {
879
+ const field = multiline
880
+ ? NSTextView.alloc().initWithFrame({ origin: { x: 0, y: 0 }, size: { width: 320, height: 72 } })
881
+ : ContentAlignedTextField.alloc().initWithFrame({
882
+ origin: { x: 0, y: 0 },
883
+ size: { width: 320, height: 28 },
884
+ })
885
+
886
+ field.translatesAutoresizingMaskIntoConstraints = false
887
+ field.font = fontForStyle(14)
888
+ field.textColor = nativeColor('#0a0a0a')
889
+ if (!multiline) {
890
+ field.bezeled = false
891
+ field.drawsBackground = false
892
+ field.editable = true
893
+ field.selectable = true
894
+ field.sendsActionOnEndEditing = false
895
+ setTextFieldPlaceholder(field, props)
896
+ } else {
897
+ field.editable = true
898
+ field.selectable = true
899
+ field.drawsBackground = false
900
+ field.textContainerInset = { width: 0, height: 0 }
901
+ field.textContainer.lineFragmentPadding = 0
902
+ }
903
+
904
+ if (multiline) {
905
+ field.string = String(props.value ?? '')
906
+ } else {
907
+ field.stringValue = String(props.value ?? '')
908
+ }
909
+
910
+ return field
911
+ }
912
+
913
+ function makeSwitch(props: PropBag) {
914
+ const button = NSButton.buttonWithTitleTargetAction('', buttonActionTarget, 'controlChanged')
915
+ button.setButtonType(NSButtonType.Switch)
916
+ button.state = props.checked ? 1 : 0
917
+ button.translatesAutoresizingMaskIntoConstraints = false
918
+ const actionId = nextActionId++
919
+ button.tag = actionId
920
+ actionHandlers.set(actionId, null)
921
+ return { view: button, actionId }
922
+ }
923
+
924
+ function makeSlider(props: PropBag) {
925
+ const slider = NSSlider.alloc().initWithFrame({
926
+ origin: { x: 0, y: 0 },
927
+ size: { width: 140, height: 24 },
928
+ })
929
+
930
+ slider.minValue = Number(props.minValue ?? 0)
931
+ slider.maxValue = Number(props.maxValue ?? 1)
932
+ slider.doubleValue = Number(props.value ?? 0)
933
+ slider.target = buttonActionTarget
934
+ slider.action = 'controlChanged'
935
+ slider.translatesAutoresizingMaskIntoConstraints = false
936
+ const actionId = nextActionId++
937
+ slider.tag = actionId
938
+ actionHandlers.set(actionId, null)
939
+ return { view: slider, actionId }
940
+ }
941
+
942
+ function makeGridLayout() {
943
+ const view = GridLayoutView.alloc().initWithFrame({
944
+ origin: { x: 0, y: 0 },
945
+ size: { width: 140, height: 28 },
946
+ })
947
+
948
+ view.translatesAutoresizingMaskIntoConstraints = false
949
+ return view
950
+ }
951
+
952
+ function makeAbsoluteLayout() {
953
+ const view = AbsoluteLayoutView.alloc().initWithFrame({
954
+ origin: { x: 0, y: 0 },
955
+ size: { width: 140, height: 28 },
956
+ })
957
+
958
+ view.translatesAutoresizingMaskIntoConstraints = false
959
+ return view
960
+ }
961
+
962
+ function layoutLength(value: any, available: number, fallback: number) {
963
+ if (typeof value === 'string' && value.trim().endsWith('%')) {
964
+ const percent = Number(value.trim().slice(0, -1))
965
+ return Number.isFinite(percent) ? (available * percent) / 100 : fallback
966
+ }
967
+
968
+ const length = Number(value)
969
+ return Number.isFinite(length) ? length : fallback
970
+ }
971
+
972
+ function parseGridTracks(spec: any): GridTrack[] {
973
+ if (typeof spec !== 'string' || !spec.trim()) {
974
+ return []
975
+ }
976
+
977
+ return spec
978
+ .split(/[\s,]+/)
979
+ .filter(Boolean)
980
+ .map((token): GridTrack => {
981
+ if (token === 'auto') {
982
+ return { kind: 'auto', value: 0 }
983
+ }
984
+
985
+ const fraction = /^(\d+(?:\.\d+)?|\.\d+)?(?:\*|fr)$/.exec(token)
986
+ if (fraction) {
987
+ const weight = Number(fraction[1] || 1)
988
+ if (weight > 0) {
989
+ return { kind: 'fraction', value: weight }
990
+ }
991
+ }
992
+
993
+ const fixed = /^(\d+(?:\.\d+)?|\.\d+)(?:px)?$/.exec(token)
994
+ if (fixed) {
995
+ return { kind: 'fixed', value: Number(fixed[1]) }
996
+ }
997
+
998
+ throw new Error('[macos-host] unsupported grid track ' + JSON.stringify(token))
999
+ })
1000
+ }
1001
+
1002
+ function gridIndex(value: any) {
1003
+ const index = Number(value ?? 0)
1004
+ return Number.isFinite(index) ? Math.max(0, Math.floor(index)) : 0
1005
+ }
1006
+
1007
+ function gridSpan(value: any) {
1008
+ const span = Number(value ?? 1)
1009
+ return Number.isFinite(span) ? Math.max(1, Math.floor(span)) : 1
1010
+ }
1011
+
1012
+ function gridAreaIsFree(
1013
+ occupied: Set<string>,
1014
+ row: number,
1015
+ col: number,
1016
+ rowSpan: number,
1017
+ colSpan: number,
1018
+ ) {
1019
+ for (let currentRow = row; currentRow < row + rowSpan; currentRow++) {
1020
+ for (let currentCol = col; currentCol < col + colSpan; currentCol++) {
1021
+ if (occupied.has(currentRow + ':' + currentCol)) {
1022
+ return false
1023
+ }
1024
+ }
1025
+ }
1026
+
1027
+ return true
1028
+ }
1029
+
1030
+ function occupyGridArea(
1031
+ occupied: Set<string>,
1032
+ row: number,
1033
+ col: number,
1034
+ rowSpan: number,
1035
+ colSpan: number,
1036
+ ) {
1037
+ for (let currentRow = row; currentRow < row + rowSpan; currentRow++) {
1038
+ for (let currentCol = col; currentCol < col + colSpan; currentCol++) {
1039
+ occupied.add(currentRow + ':' + currentCol)
1040
+ }
1041
+ }
1042
+ }
1043
+
1044
+ function autoPlaceGridChildren(parent: ElementNode, placements: GridPlacement[]) {
1045
+ const explicitColumns = parseGridTracks(parent.props.columns).length
1046
+ const placedColumnCount = Math.max(
1047
+ 0,
1048
+ ...placements
1049
+ .filter((placement) => placement.col != null)
1050
+ .map((placement) => placement.col! + placement.colSpan),
1051
+ )
1052
+
1053
+ const columnCount = Math.max(
1054
+ 1,
1055
+ explicitColumns,
1056
+ placedColumnCount,
1057
+ ...placements.map((placement) => placement.colSpan),
1058
+ )
1059
+
1060
+ const occupied = new Set<string>()
1061
+ for (const placement of placements) {
1062
+ if (placement.row != null && placement.col != null) {
1063
+ occupyGridArea(occupied, placement.row, placement.col, placement.rowSpan, placement.colSpan)
1064
+ }
1065
+ }
1066
+
1067
+ for (const placement of placements) {
1068
+ if (placement.row == null || placement.col != null) {
1069
+ continue
1070
+ }
1071
+
1072
+ let col = 0
1073
+ while (!gridAreaIsFree(occupied, placement.row, col, placement.rowSpan, placement.colSpan)) {
1074
+ col++
1075
+ }
1076
+
1077
+ placement.col = col
1078
+ occupyGridArea(occupied, placement.row, col, placement.rowSpan, placement.colSpan)
1079
+ }
1080
+
1081
+ for (const placement of placements) {
1082
+ if (placement.col == null || placement.row != null) {
1083
+ continue
1084
+ }
1085
+
1086
+ let row = 0
1087
+ while (!gridAreaIsFree(occupied, row, placement.col, placement.rowSpan, placement.colSpan)) {
1088
+ row++
1089
+ }
1090
+
1091
+ placement.row = row
1092
+ occupyGridArea(occupied, row, placement.col, placement.rowSpan, placement.colSpan)
1093
+ }
1094
+
1095
+ let cursorRow = 0
1096
+ let cursorCol = 0
1097
+ for (const placement of placements) {
1098
+ if (placement.row != null && placement.col != null) {
1099
+ continue
1100
+ }
1101
+
1102
+ while (true) {
1103
+ const availableColumns = Math.max(columnCount, placement.colSpan)
1104
+ if (cursorCol + placement.colSpan > availableColumns) {
1105
+ cursorRow++
1106
+ cursorCol = 0
1107
+ continue
1108
+ }
1109
+
1110
+ if (gridAreaIsFree(occupied, cursorRow, cursorCol, placement.rowSpan, placement.colSpan)) {
1111
+ break
1112
+ }
1113
+
1114
+ cursorCol++
1115
+ }
1116
+
1117
+ placement.row = cursorRow
1118
+ placement.col = cursorCol
1119
+ occupyGridArea(occupied, cursorRow, cursorCol, placement.rowSpan, placement.colSpan)
1120
+ cursorCol += placement.colSpan
1121
+ if (cursorCol >= columnCount) {
1122
+ cursorRow++
1123
+ cursorCol = 0
1124
+ }
1125
+ }
1126
+ }
1127
+
1128
+ function gridAxisTracks(spec: any, placements: GridPlacement[], axis: 'rows' | 'columns') {
1129
+ const indexKey = axis === 'columns' ? 'col' : 'row'
1130
+ const spanKey = axis === 'columns' ? 'colSpan' : 'rowSpan'
1131
+ const explicit = parseGridTracks(spec)
1132
+ const requiredCount = Math.max(
1133
+ 1,
1134
+ ...placements.map((placement) => (placement[indexKey] ?? 0) + placement[spanKey]),
1135
+ )
1136
+
1137
+ const tracks = explicit.length
1138
+ ? explicit
1139
+ : Array.from({ length: requiredCount }, (): GridTrack => ({ kind: 'auto', value: 0 }))
1140
+
1141
+ while (tracks.length < requiredCount) {
1142
+ tracks.push({ kind: 'auto', value: 0 })
1143
+ }
1144
+
1145
+ return tracks
1146
+ }
1147
+
1148
+ function gridPreferredSize(child: ElementNode, axis: 'rows' | 'columns', available: number) {
1149
+ const name = axis === 'columns' ? 'width' : 'height'
1150
+ const styled = child.props.style?.[name]
1151
+ if (styled != null) {
1152
+ return Math.max(0, layoutLength(styled, available, 0))
1153
+ }
1154
+
1155
+ const intrinsic = Number(child.view!.intrinsicContentSize?.[name] ?? 0)
1156
+ return Number.isFinite(intrinsic) ? Math.max(0, intrinsic) : 0
1157
+ }
1158
+
1159
+ function resolveGridTrackSizes(
1160
+ tracks: GridTrack[],
1161
+ placements: GridPlacement[],
1162
+ axis: 'rows' | 'columns',
1163
+ available: number,
1164
+ ) {
1165
+ const sizes = tracks.map((track) => (track.kind === 'fixed' ? track.value : 0))
1166
+ for (const placement of placements) {
1167
+ const start = placement[axis === 'columns' ? 'col' : 'row']!
1168
+ const span = placement[axis === 'columns' ? 'colSpan' : 'rowSpan']
1169
+ const autoTracks: number[] = []
1170
+ let currentSize = 0
1171
+ for (let index = start; index < Math.min(start + span, tracks.length); index++) {
1172
+ currentSize += sizes[index]
1173
+ if (tracks[index].kind === 'auto') {
1174
+ autoTracks.push(index)
1175
+ }
1176
+ }
1177
+
1178
+ if (!autoTracks.length) {
1179
+ continue
1180
+ }
1181
+
1182
+ const preferred = gridPreferredSize(placement.child, axis, available)
1183
+ const extra = Math.max(0, preferred - currentSize) / autoTracks.length
1184
+ for (const index of autoTracks) {
1185
+ sizes[index] += extra
1186
+ }
1187
+ }
1188
+
1189
+ const fixedAndAuto = sizes.reduce(
1190
+ (sum, size, index) => sum + (tracks[index].kind === 'fraction' ? 0 : size),
1191
+ 0,
1192
+ )
1193
+
1194
+ const fractionWeight = tracks.reduce(
1195
+ (sum, track) => sum + (track.kind === 'fraction' ? track.value : 0),
1196
+ 0,
1197
+ )
1198
+
1199
+ const fractionSize =
1200
+ fractionWeight > 0 ? Math.max(0, available - fixedAndAuto) / fractionWeight : 0
1201
+
1202
+ for (let index = 0; index < tracks.length; index++) {
1203
+ if (tracks[index].kind === 'fraction') {
1204
+ sizes[index] = fractionSize * tracks[index].value
1205
+ }
1206
+ }
1207
+
1208
+ return sizes
1209
+ }
1210
+
1211
+ function trackOffset(sizes: number[], index: number) {
1212
+ let offset = 0
1213
+ for (let current = 0; current < index; current++) {
1214
+ offset += sizes[current]
1215
+ }
1216
+
1217
+ return offset
1218
+ }
1219
+
1220
+ function layoutGridChildren(parent: ElementNode | undefined) {
1221
+ if (!parent?.view) {
1222
+ return
1223
+ }
1224
+
1225
+ const width = Number(parent.view.bounds.size.width)
1226
+ const height = Number(parent.view.bounds.size.height)
1227
+ const overlayChildren = parent.props.overlayChildren === true
1228
+ const placements = parent.children
1229
+ .filter((child) => child.view)
1230
+ .map((child) => ({
1231
+ child,
1232
+ row: overlayChildren ? 0 : child.props.row == null ? null : gridIndex(child.props.row),
1233
+ col: overlayChildren ? 0 : child.props.col == null ? null : gridIndex(child.props.col),
1234
+ rowSpan: overlayChildren ? 1 : gridSpan(child.props.rowSpan),
1235
+ colSpan: overlayChildren ? 1 : gridSpan(child.props.colSpan),
1236
+ }))
1237
+
1238
+ const hasTracks = Boolean(
1239
+ String(parent.props.rows ?? '').trim() || String(parent.props.columns ?? '').trim(),
1240
+ )
1241
+
1242
+ const hasPlacement = placements.some(
1243
+ ({ child, row, col, rowSpan, colSpan }) =>
1244
+ (child.props.row != null && row !== 0) ||
1245
+ (child.props.col != null && col !== 0) ||
1246
+ rowSpan !== 1 ||
1247
+ colSpan !== 1,
1248
+ )
1249
+
1250
+ // Preserve overlay behavior for Slider and Stack. Explicit tracks or
1251
+ // non-default cell placement opt into track layout. Configured grids
1252
+ // auto-place children with no row or column using the web renderer's default
1253
+ // row flow, unless the host explicitly requests overlay behavior.
1254
+ if (hasTracks || hasPlacement) {
1255
+ if (!overlayChildren) {
1256
+ autoPlaceGridChildren(parent, placements)
1257
+ }
1258
+
1259
+ const columns = gridAxisTracks(parent.props.columns, placements, 'columns')
1260
+ const rows = gridAxisTracks(parent.props.rows, placements, 'rows')
1261
+ const columnSizes = resolveGridTrackSizes(columns, placements, 'columns', width)
1262
+ const rowSizes = resolveGridTrackSizes(rows, placements, 'rows', height)
1263
+
1264
+ for (const placement of placements) {
1265
+ const { child, row, col, rowSpan, colSpan } = placement
1266
+ const style = child.props.style ?? {}
1267
+ const cellX = trackOffset(columnSizes, col!)
1268
+ const cellTop = trackOffset(rowSizes, row!)
1269
+ const cellWidth = columnSizes.slice(col!, col! + colSpan).reduce((sum, size) => sum + size, 0)
1270
+ const cellHeight = rowSizes.slice(row!, row! + rowSpan).reduce((sum, size) => sum + size, 0)
1271
+ const horizontal = child.props.horizontalAlignment ?? 'stretch'
1272
+ const vertical = child.props.verticalAlignment ?? 'stretch'
1273
+ const intrinsic = child.view!.intrinsicContentSize ?? { width: 0, height: 0 }
1274
+ const childWidth =
1275
+ style.width == null
1276
+ ? horizontal === 'stretch'
1277
+ ? cellWidth
1278
+ : Math.max(0, Number(intrinsic.width ?? 0))
1279
+ : layoutLength(style.width, cellWidth, 0)
1280
+
1281
+ const childHeight =
1282
+ style.height == null
1283
+ ? vertical === 'stretch'
1284
+ ? cellHeight
1285
+ : Math.max(0, Number(intrinsic.height ?? 0))
1286
+ : layoutLength(style.height, cellHeight, 0)
1287
+
1288
+ let x = cellX
1289
+ if (horizontal === 'center' || horizontal === 'middle') {
1290
+ x += (cellWidth - childWidth) / 2
1291
+ } else if (horizontal === 'right') {
1292
+ x += cellWidth - childWidth
1293
+ }
1294
+
1295
+ x += layoutLength(style.left, cellWidth, 0) + Number(style.marginLeft ?? 0)
1296
+
1297
+ let top = cellTop
1298
+ if (vertical === 'middle' || vertical === 'center') {
1299
+ top += (cellHeight - childHeight) / 2
1300
+ } else if (vertical === 'bottom') {
1301
+ top += cellHeight - childHeight
1302
+ }
1303
+
1304
+ top += layoutLength(style.top, cellHeight, 0) + Number(style.marginTop ?? 0)
1305
+ child.view!.frame = {
1306
+ origin: { x, y: height - top - childHeight },
1307
+ size: { width: childWidth, height: childHeight },
1308
+ }
1309
+ }
1310
+
1311
+ return
1312
+ }
1313
+
1314
+ for (const { child } of placements) {
1315
+ const style = child.props.style ?? {}
1316
+ const horizontal = child.props.horizontalAlignment ?? 'stretch'
1317
+ const vertical = child.props.verticalAlignment ?? 'stretch'
1318
+ const intrinsic = child.view!.intrinsicContentSize ?? { width: 0, height: 0 }
1319
+ const childWidth =
1320
+ style.width == null
1321
+ ? horizontal === 'stretch'
1322
+ ? width
1323
+ : Math.max(0, Number(intrinsic.width ?? 0))
1324
+ : layoutLength(style.width, width, 0)
1325
+
1326
+ const childHeight =
1327
+ style.height == null
1328
+ ? vertical === 'stretch'
1329
+ ? height
1330
+ : Math.max(0, Number(intrinsic.height ?? 0))
1331
+ : layoutLength(style.height, height, 0)
1332
+
1333
+ let x = 0
1334
+ if (horizontal === 'center' || horizontal === 'middle') {
1335
+ x = (width - childWidth) / 2
1336
+ } else if (horizontal === 'right') {
1337
+ x = width - childWidth
1338
+ } else if (horizontal === 'left') {
1339
+ x = layoutLength(style.left, width, 0) + Number(style.marginLeft ?? 0)
1340
+ }
1341
+
1342
+ let y = 0
1343
+ if (vertical === 'middle' || vertical === 'center') {
1344
+ y = (height - childHeight) / 2
1345
+ } else if (vertical === 'top') {
1346
+ y = height - childHeight - Number(style.marginTop ?? 0)
1347
+ }
1348
+
1349
+ child.view!.frame = {
1350
+ origin: { x, y },
1351
+ size: { width: childWidth, height: childHeight },
1352
+ }
1353
+ }
1354
+ }
1355
+
1356
+ function layoutAbsoluteChildren(parent: ElementNode | undefined) {
1357
+ if (!parent?.view) {
1358
+ return
1359
+ }
1360
+
1361
+ const width = Number(parent.view.bounds.size.width)
1362
+ const height = Number(parent.view.bounds.size.height)
1363
+
1364
+ for (const child of parent.children) {
1365
+ if (!child.view) {
1366
+ continue
1367
+ }
1368
+
1369
+ const style = child.props.style ?? {}
1370
+ const intrinsic = child.view.intrinsicContentSize ?? { width: 0, height: 0 }
1371
+ const left = layoutLength(child.props.left, width, Number.NaN)
1372
+ const right = layoutLength(child.props.right, width, Number.NaN)
1373
+ const top = layoutLength(child.props.top, height, Number.NaN)
1374
+ const bottom = layoutLength(child.props.bottom, height, Number.NaN)
1375
+ const childWidth =
1376
+ style.width == null
1377
+ ? Number.isFinite(left) && Number.isFinite(right)
1378
+ ? Math.max(0, width - left - right)
1379
+ : Math.max(0, Number(intrinsic.width ?? 0))
1380
+ : layoutLength(style.width, width, 0)
1381
+
1382
+ const childHeight =
1383
+ style.height == null
1384
+ ? Number.isFinite(top) && Number.isFinite(bottom)
1385
+ ? Math.max(0, height - top - bottom)
1386
+ : Math.max(0, Number(intrinsic.height ?? 0))
1387
+ : layoutLength(style.height, height, 0)
1388
+
1389
+ const x = Number.isFinite(left) ? left : Number.isFinite(right) ? width - right - childWidth : 0
1390
+
1391
+ const offsetTop = Number.isFinite(top)
1392
+ ? top
1393
+ : Number.isFinite(bottom)
1394
+ ? height - bottom - childHeight
1395
+ : 0
1396
+
1397
+ child.view.frame = {
1398
+ origin: { x, y: height - offsetTop - childHeight },
1399
+ size: { width: childWidth, height: childHeight },
1400
+ }
1401
+ }
1402
+ }
1403
+
1404
+ function performGridAccessibilityAdjustment(view: any, name: string) {
1405
+ const node = gridLayoutNodesByView.get(view)!
1406
+ const handler = node?.props[name]
1407
+ if (typeof handler !== 'function') {
1408
+ return false
1409
+ }
1410
+
1411
+ try {
1412
+ node!.container.root!.eventScope('discrete', handler)
1413
+ return true
1414
+ } catch (error) {
1415
+ console.error('[macos-event] slider accessibility adjustment failed', error)
1416
+ return false
1417
+ }
1418
+ }
1419
+
1420
+ class InputImageView extends NSImageView {
1421
+ static {
1422
+ NativeClass(this)
1423
+ }
1424
+ hitTest(point: any) {
1425
+ return inputTransparentViews.has(this) ? null : super.hitTest(point)
1426
+ }
1427
+ }
1428
+
1429
+ function makeImageView(props: PropBag) {
1430
+ const image = InputImageView.alloc().initWithFrame({
1431
+ origin: { x: 0, y: 0 },
1432
+ size: { width: 24, height: 24 },
1433
+ })
1434
+
1435
+ image.translatesAutoresizingMaskIntoConstraints = false
1436
+ image.image = loadImage(props.src)
1437
+
1438
+ return image
1439
+ }
1440
+
1441
+ function makeNode(container: RootContainer, id: number, type: string, props: PropBag) {
1442
+ let view
1443
+ let actionId
1444
+ let childHost
1445
+ switch (type) {
1446
+ case 'stack':
1447
+ view = makeStack(props)
1448
+ break
1449
+ case 'flexboxlayout': {
1450
+ const flexbox = makeFlexbox(props)
1451
+ view = flexbox.view
1452
+ actionId = flexbox.actionId
1453
+ break
1454
+ }
1455
+ case 'gridlayout':
1456
+ view = makeGridLayout()
1457
+ break
1458
+ case 'absolutelayout':
1459
+ view = makeAbsoluteLayout()
1460
+ break
1461
+ case 'label':
1462
+ view = makeLabel()
1463
+ break
1464
+ case '#text':
1465
+ view = null
1466
+ break
1467
+ case 'button': {
1468
+ const button = makeButton(props)
1469
+ view = button.view
1470
+ actionId = button.actionId
1471
+ break
1472
+ }
1473
+ case 'scrollview': {
1474
+ const scroll = makeScrollView()
1475
+ view = scroll.view
1476
+ childHost = scroll.childHost
1477
+ break
1478
+ }
1479
+ case 'textfield':
1480
+ view = makeTextField(props)
1481
+ break
1482
+ case 'textview':
1483
+ view = makeTextField(props, true)
1484
+ break
1485
+ case 'switch': {
1486
+ const control = makeSwitch(props)
1487
+ view = control.view
1488
+ actionId = control.actionId
1489
+ break
1490
+ }
1491
+ case 'slider': {
1492
+ const control = makeSlider(props)
1493
+ view = control.view
1494
+ actionId = control.actionId
1495
+ break
1496
+ }
1497
+ case 'webview':
1498
+ view = makeWebView()
1499
+ break
1500
+ case 'image':
1501
+ view = makeImageView(props)
1502
+ break
1503
+ case 'span':
1504
+ view = null
1505
+ break
1506
+ default:
1507
+ throw new Error('AppKit spike does not support <' + type + '>')
1508
+ }
1509
+
1510
+ if (type === 'textfield' || type === 'textview') {
1511
+ actionId = nextActionId++
1512
+ actionIdsByView.set(view, actionId)
1513
+ actionHandlers.set(actionId, null)
1514
+ if (type === 'textfield') {
1515
+ view.tag = actionId
1516
+ view.target = buttonActionTarget
1517
+ view.action = 'controlChanged'
1518
+ } else {
1519
+ view.delegate = buttonActionTarget
1520
+ }
1521
+ }
1522
+
1523
+ const node: ElementNode = {
1524
+ id,
1525
+ type,
1526
+ view,
1527
+ childHost,
1528
+ props: {},
1529
+ parent: null,
1530
+ children: [],
1531
+ container,
1532
+ appliedFontFamily: container.fontFamily,
1533
+ actionId,
1534
+ scrollObserverInstalled: false,
1535
+ text: '',
1536
+ }
1537
+
1538
+ if (['label', 'textfield', 'textview'].includes(type) && view.font) {
1539
+ view.font = fontForFamilyStyle(view.font.pointSize, 400, container.fontFamily)
1540
+ }
1541
+
1542
+ if (type === 'gridlayout') {
1543
+ gridLayoutNodesByView.set(view, node)
1544
+ }
1545
+
1546
+ if (type === 'absolutelayout') {
1547
+ absoluteLayoutNodesByView.set(view, node)
1548
+ }
1549
+
1550
+ if (type === 'textview') {
1551
+ const placeholder = makeLabel()
1552
+ placeholder.font = fontForFamilyStyle(14, 400, node.appliedFontFamily)
1553
+ placeholder.textColor = nativeColor('#666666')
1554
+ placeholder.stringValue = String(props.placeholder ?? '')
1555
+ placeholder.translatesAutoresizingMaskIntoConstraints = false
1556
+ placeholder.heightAnchor.constraintEqualToConstant(
1557
+ Math.ceil(14 * DEFAULT_TEXT_LINE_HEIGHT_RATIO),
1558
+ ).active = true
1559
+
1560
+ placeholder.hidden = String(props.value ?? '').length > 0
1561
+ view.addSubview(placeholder)
1562
+ placeholder.leadingAnchor.constraintEqualToAnchorConstant(view.leadingAnchor, 0).active = true
1563
+ placeholder.topAnchor.constraintEqualToAnchor(view.topAnchor).active = true
1564
+ node.placeholderView = placeholder
1565
+ textNodesByView.set(view, node)
1566
+ }
1567
+
1568
+ applyProps(node, props)
1569
+ return node
1570
+ }
1571
+
1572
+ const EDGE_INSET_PROPS = new Map([
1573
+ ['paddingTop', ['top']],
1574
+ ['paddingRight', ['right']],
1575
+ ['paddingBottom', ['bottom']],
1576
+ ['paddingLeft', ['left']],
1577
+ ['paddingVertical', ['top', 'bottom']],
1578
+ ['paddingHorizontal', ['left', 'right']],
1579
+ ])
1580
+
1581
+ // number | 'N' | 'V H' | 'T R B L' → NSEdgeInsets (top/left/bottom/right).
1582
+ function parseEdgeInsets(value: any) {
1583
+ if (typeof value === 'number' || !isNaN(Number(value))) {
1584
+ const n = Number(value)
1585
+ return { top: n, left: n, bottom: n, right: n }
1586
+ }
1587
+
1588
+ const parts = String(value)
1589
+ .split(/\s+/)
1590
+ .map((p) => parseFloat(p) || 0)
1591
+
1592
+ const top = parts[0] ?? 0
1593
+ const right = parts.length > 1 ? parts[1] : top
1594
+ const bottom = parts.length > 2 ? parts[2] : top
1595
+ const left = parts.length > 3 ? parts[3] : right
1596
+ return { top, left, bottom, right }
1597
+ }
1598
+
1599
+ function nativeColor(value: any): NSColor {
1600
+ const string = String(value)
1601
+ let match = /^#([\da-f]{6})$/i.exec(string)
1602
+ if (match) {
1603
+ const hex = match[1]
1604
+ return NSColor.colorWithRedGreenBlueAlpha(
1605
+ parseInt(hex.slice(0, 2), 16) / 255,
1606
+ parseInt(hex.slice(2, 4), 16) / 255,
1607
+ parseInt(hex.slice(4, 6), 16) / 255,
1608
+ 1,
1609
+ )
1610
+ }
1611
+
1612
+ match = /^#([\da-f]{8})$/i.exec(string)
1613
+ if (match) {
1614
+ const hex = match[1]
1615
+ return NSColor.colorWithRedGreenBlueAlpha(
1616
+ parseInt(hex.slice(0, 2), 16) / 255,
1617
+ parseInt(hex.slice(2, 4), 16) / 255,
1618
+ parseInt(hex.slice(4, 6), 16) / 255,
1619
+ parseInt(hex.slice(6, 8), 16) / 255,
1620
+ )
1621
+ }
1622
+
1623
+ match = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)[,\s/]*([\d.]*)[\s%]*\)$/i.exec(string)
1624
+ if (match) {
1625
+ const alpha = match[4] === '' ? 1 : Number(match[4])
1626
+ return NSColor.colorWithRedGreenBlueAlpha(
1627
+ Number(match[1]) / 255,
1628
+ Number(match[2]) / 255,
1629
+ Number(match[3]) / 255,
1630
+ alpha,
1631
+ )
1632
+ }
1633
+
1634
+ throw new Error(
1635
+ 'AppKit spike expects #rrggbb, #rrggbbaa, or rgb()/rgba() colors, received ' + string,
1636
+ )
1637
+ }
1638
+
1639
+ // The renderer has no CSS layer, so the shared `dark`/`ns-dark` scheme classes
1640
+ // can't cascade through selectors. Each themeable color carries a semantic
1641
+ // slot and the resolved value tracks the nearest `dark`/`ns-dark` ancestor —
1642
+ // the same mechanism ns-dark uses to re-theme native subtrees. Light values
1643
+ // are the pre-existing hardcoded palette; dark values mirror tokens.css's
1644
+ // `.dark` overrides (--color-text/text-secondary/onprimary/primary/surface).
1645
+ const SCHEME_COLORS: Record<string, Record<string, string>> = {
1646
+ light: {
1647
+ text: '#0a0a0a',
1648
+ muted: '#71717a',
1649
+ onprimary: '#ffffff',
1650
+ placeholder: '#666666',
1651
+ primary: '#2563eb',
1652
+ danger: '#dc2626',
1653
+ secondary: '#3f3f46',
1654
+ surface: '#ffffff',
1655
+ },
1656
+ dark: {
1657
+ text: '#ededed',
1658
+ muted: '#a1a1a1',
1659
+ onprimary: '#171717',
1660
+ placeholder: '#a1a1a1',
1661
+ primary: '#ededed',
1662
+ danger: '#dc2626',
1663
+ secondary: '#3f3f46',
1664
+ surface: '#0a0a0a',
1665
+ },
1666
+ }
1667
+
1668
+ function nodeScheme(node: ElementNode) {
1669
+ for (let current: ElementNode | null = node; current; current = current.parent) {
1670
+ const classes = nodeClasses(current)
1671
+ if (classes.includes('dark') || classes.includes('ns-dark')) {
1672
+ return 'dark'
1673
+ }
1674
+ }
1675
+
1676
+ return 'light'
1677
+ }
1678
+
1679
+ // Re-resolve a node's class-driven colors under its current scheme. Explicit
1680
+ // style.color/style.backgroundColor always win over class palette slots.
1681
+ function applyThemeColors(node: ElementNode) {
1682
+ const view = node.view!
1683
+ if (!view) {
1684
+ return
1685
+ }
1686
+
1687
+ node.scheme = nodeScheme(node)
1688
+ const colors = SCHEME_COLORS[node.scheme]
1689
+ if (node.type === 'label' || node.type === 'textfield' || node.type === 'textview') {
1690
+ view.textColor = nativeColor(node.styleColor ?? colors[node.colorSlot ?? 'text'])
1691
+ if (node.type === 'label') {
1692
+ setLabelText(node, String(view.stringValue ?? ''))
1693
+ }
1694
+ }
1695
+
1696
+ if (node.type === 'textfield') {
1697
+ setTextFieldPlaceholder(view, node.props, node.scheme)
1698
+ }
1699
+
1700
+ if (node.type === 'textview' && node.placeholderView) {
1701
+ node.placeholderView.textColor = nativeColor(colors.placeholder)
1702
+ }
1703
+
1704
+ if (node.bgSlot) {
1705
+ view.wantsLayer = true
1706
+ view.layer.backgroundColor = nativeColor(node.styleBg ?? colors[node.bgSlot]).CGColor
1707
+ }
1708
+ }
1709
+
1710
+ // Colors resolve through the parent chain, so a node mounted under (or into)
1711
+ // a differently-schemed subtree re-resolves its whole branch on insertion.
1712
+ function syncScheme(node: ElementNode) {
1713
+ const scheme = nodeScheme(node)
1714
+ if (scheme === (node.scheme ?? 'light')) {
1715
+ return
1716
+ }
1717
+
1718
+ for (const entry of [node, ...descendants(node)]) {
1719
+ applyThemeColors(entry)
1720
+ }
1721
+ }
1722
+
1723
+ function fontForStyle(size: number, weight: number | string = 400) {
1724
+ return fontForFamilyStyle(size, weight)
1725
+ }
1726
+
1727
+ function setSizeConstraint(node: ElementNode, name: string, value: any) {
1728
+ const spec = sizeConstraintSpec(value)
1729
+ const specs = (node.sizeConstraintSpecs ??= {})
1730
+ if (!spec) {
1731
+ delete specs[name]
1732
+ console.warn(
1733
+ '[macos-style] ignored unsupported style.' + name + ' value ' + JSON.stringify(value),
1734
+ )
1735
+ } else {
1736
+ specs[name] = spec
1737
+ }
1738
+
1739
+ if (node.parent?.type === 'gridlayout') {
1740
+ deactivateSizeConstraints(node)
1741
+ layoutGridChildren(node.parent)
1742
+ return
1743
+ }
1744
+
1745
+ if (node.parent?.type === 'absolutelayout') {
1746
+ deactivateSizeConstraints(node)
1747
+ layoutAbsoluteChildren(node.parent)
1748
+ return
1749
+ }
1750
+
1751
+ applySizeConstraint(node, name)
1752
+ if (node.parent) {
1753
+ updateCrossAxisConstraints(node.parent)
1754
+ }
1755
+ }
1756
+
1757
+ function sizeConstraintSpec(value: any) {
1758
+ if (typeof value === 'string') {
1759
+ const text = value.trim()
1760
+ const percent = text.match(/^([+-]?(?:\d+\.?\d*|\.\d+))%$/)
1761
+ if (percent) {
1762
+ const multiplier = Number(percent[1]) / 100
1763
+ return Number.isFinite(multiplier) && multiplier >= 0 ? { kind: 'percent', multiplier } : null
1764
+ }
1765
+
1766
+ const px = text.match(/^([+-]?(?:\d+\.?\d*|\.\d+))px$/i)
1767
+ if (px) {
1768
+ const points = Number(px[1])
1769
+ return Number.isFinite(points) && points >= 0 ? { kind: 'points', points } : null
1770
+ }
1771
+ }
1772
+
1773
+ const points = typeof value === 'number' ? value : Number(value)
1774
+ return Number.isFinite(points) && points >= 0 ? { kind: 'points', points } : null
1775
+ }
1776
+
1777
+ function sizeConstraintParentView(node: ElementNode) {
1778
+ if (node.parent) {
1779
+ return node.parent.childHost ?? node.parent.view
1780
+ }
1781
+
1782
+ return node.container.children.includes(node) ? node.container.hostView : null
1783
+ }
1784
+
1785
+ function applySizeConstraint(node: ElementNode, name: string) {
1786
+ const constraints = (node.sizeConstraints ??= {})
1787
+ if (constraints[name]) {
1788
+ constraints[name].active = false
1789
+ delete constraints[name]
1790
+ }
1791
+
1792
+ const spec = node.sizeConstraintSpecs?.[name]
1793
+ if (!spec || !node.view) {
1794
+ return
1795
+ }
1796
+
1797
+ let constraint
1798
+ if (spec.kind === 'percent') {
1799
+ const parentView = sizeConstraintParentView(node)
1800
+ if (!parentView) {
1801
+ return
1802
+ }
1803
+
1804
+ const anchor = name === 'width' ? node.view.widthAnchor : node.view.heightAnchor
1805
+ const parentAnchor = name === 'width' ? parentView.widthAnchor : parentView.heightAnchor
1806
+ constraint = anchor.constraintEqualToAnchorMultiplier(parentAnchor, spec.multiplier)
1807
+ } else {
1808
+ constraint =
1809
+ name === 'width'
1810
+ ? node.view.widthAnchor.constraintEqualToConstant(spec.points)
1811
+ : node.view.heightAnchor.constraintEqualToConstant(spec.points)
1812
+ }
1813
+
1814
+ constraints[name] = constraint
1815
+ constraint.active = true
1816
+ }
1817
+
1818
+ function applySizeConstraints(node: ElementNode) {
1819
+ for (const name of ['width', 'height']) {
1820
+ applySizeConstraint(node, name)
1821
+ }
1822
+ }
1823
+
1824
+ function deactivateSizeConstraints(node: ElementNode) {
1825
+ for (const name of ['width', 'height']) {
1826
+ const constraint = node.sizeConstraints?.[name]
1827
+ if (constraint) {
1828
+ constraint.active = false
1829
+ delete node.sizeConstraints[name]
1830
+ }
1831
+ }
1832
+ }
1833
+
1834
+ function applyStyle(node: ElementNode, style: PropBag) {
1835
+ if (style == null) {
1836
+ return
1837
+ }
1838
+
1839
+ if (typeof style !== 'object') {
1840
+ throw new Error('AppKit spike expects style to be an object')
1841
+ }
1842
+
1843
+ for (const [name, value] of Object.entries(style)) {
1844
+ if (value == null) {
1845
+ continue
1846
+ }
1847
+
1848
+ if (name === 'pointerEvents') {
1849
+ if (value === 'none') {
1850
+ inputTransparentViews.add(node.view!)
1851
+ } else {
1852
+ inputTransparentViews.delete(node.view!)
1853
+ }
1854
+ } else if (name === 'objectFit' && node.type === 'image') {
1855
+ // NSImageScaleProportionallyUpOrDown / NSImageScaleProportionallyDown.
1856
+ node.view!.imageScaling = value === 'contain' ? 3 : 0
1857
+ } else if (name === 'fontSize' && ['label', 'textfield', 'textview'].includes(node.type)) {
1858
+ const weight = style.fontWeight ?? node.appliedFontWeight ?? 400
1859
+ node.appliedFontWeight = String(weight)
1860
+ node.view!.font = fontForFamilyStyle(
1861
+ value,
1862
+ weight,
1863
+ style.fontFamily ?? node.appliedFontFamily,
1864
+ )
1865
+ } else if (name === 'fontFamily' && ['label', 'textfield', 'textview'].includes(node.type)) {
1866
+ node.appliedFontFamily = String(value)
1867
+ const size = style.fontSize ?? node.view!.font.pointSize
1868
+ const weight = style.fontWeight ?? node.appliedFontWeight ?? 400
1869
+ node.view!.font = fontForFamilyStyle(size, weight, value)
1870
+ } else if (name === 'color' && ['label', 'textfield', 'textview'].includes(node.type)) {
1871
+ node.styleColor = String(value)
1872
+ node.view!.textColor = nativeColor(value)
1873
+ } else if (name === 'lineHeight' && node.type === 'label') {
1874
+ // syncText applies this to each paragraph; it is not the label's fixed height.
1875
+ continue
1876
+ } else if (name === 'padding' && node.type === 'flexboxlayout') {
1877
+ node.view!.edgeInsets = parseEdgeInsets(value)
1878
+ } else if (EDGE_INSET_PROPS.has(name) && node.type === 'flexboxlayout') {
1879
+ const insets = { ...node.view!.edgeInsets }
1880
+ for (const edge of EDGE_INSET_PROPS.get(name)!) {
1881
+ insets[edge] = Number(value) || 0
1882
+ }
1883
+
1884
+ node.view!.edgeInsets = insets
1885
+ } else if (name === 'backgroundColor' && node.view) {
1886
+ node.styleBg = String(value)
1887
+ node.view.wantsLayer = true
1888
+ node.view.layer.backgroundColor = nativeColor(value).CGColor
1889
+ } else if (name === 'borderRadius' && node.view) {
1890
+ node.view.wantsLayer = true
1891
+ node.view.layer.cornerRadius = Number(value)
1892
+ node.view.layer.masksToBounds = true
1893
+ } else if ((name === 'width' || name === 'height') && node.view) {
1894
+ setSizeConstraint(node, name, value)
1895
+ } else if (name === 'left' && node.view) {
1896
+ if (node.parent?.type === 'gridlayout') {
1897
+ layoutGridChildren(node.parent)
1898
+ }
1899
+
1900
+ if (node.parent?.type === 'absolutelayout') {
1901
+ layoutAbsoluteChildren(node.parent)
1902
+ }
1903
+ } else if ((name === 'translateX' || name === 'translateY' || name === 'zIndex') && node.view) {
1904
+ node.view.wantsLayer = true
1905
+ const layer = node.view.layer
1906
+ if (name === 'zIndex') {
1907
+ layer.zPosition = Number(value) || 0
1908
+ } else {
1909
+ // Core Animation layers use y-up coordinates; public drag deltas use y-down.
1910
+ layer.setValueForKeyPath(
1911
+ (Number(value) || 0) * (name === 'translateY' ? -1 : 1),
1912
+ name === 'translateY' ? 'transform.translation.y' : 'transform.translation.x',
1913
+ )
1914
+ }
1915
+ } else if (name === 'opacity' && node.view) {
1916
+ node.view.alphaValue = Number(value)
1917
+ } else if (name === 'fontWeight' && ['label', 'textfield', 'textview'].includes(node.type)) {
1918
+ node.appliedFontWeight = String(value)
1919
+ node.view!.font = fontForFamilyStyle(
1920
+ node.view!.font.pointSize,
1921
+ value,
1922
+ style.fontFamily ?? node.appliedFontFamily,
1923
+ )
1924
+ } else if (name === 'textAlign' && node.type === 'label') {
1925
+ node.view!.alignment =
1926
+ value === 'left'
1927
+ ? NSTextAlignment.Left
1928
+ : value === 'right'
1929
+ ? NSTextAlignment.Right
1930
+ : NSTextAlignment.Center
1931
+ } else if (name === 'borderWidth' && node.view) {
1932
+ node.view.wantsLayer = true
1933
+ node.view.layer.borderWidth = Number(value)
1934
+ } else if (name === 'borderColor' && node.view) {
1935
+ node.view.wantsLayer = true
1936
+ node.view.layer.borderColor = nativeColor(value).CGColor
1937
+ } else if (MARGIN_SIDES[name] || name === 'margin') {
1938
+ setMarginStyle(node, name, value)
1939
+ } else {
1940
+ console.warn('[macos-style] ignored unsupported style.' + name + ' on <' + node.type + '>')
1941
+ }
1942
+ }
1943
+ }
1944
+
1945
+ function applyClassName(node: ElementNode, value: any) {
1946
+ const classes = String(value ?? '')
1947
+ .split(/\s+/)
1948
+ .filter(Boolean)
1949
+
1950
+ if (node.type === 'label') {
1951
+ if (classes.includes('vx-sheet-grabber')) {
1952
+ node.classLineHeight = 24
1953
+ setSizeConstraint(node, 'height', 24)
1954
+ node.view!.alignment = NSTextAlignment.Center
1955
+ }
1956
+
1957
+ const sizes: Record<string, number> = {
1958
+ 'text-xs': 12,
1959
+ 'text-sm': 13,
1960
+ 'text-base': 16,
1961
+ 'text-lg': 18,
1962
+ 'text-xl': 20,
1963
+ 'text-2xl': 28,
1964
+ }
1965
+
1966
+ const lineHeights: Record<string, number> = {
1967
+ 'text-sm': 20,
1968
+ 'text-lg': 28,
1969
+ 'text-xl': 28,
1970
+ 'text-2xl': 36,
1971
+ }
1972
+
1973
+ const headingMetrics: Record<string, { size: number; height: number }> = {
1974
+ 'vx-h1': { size: 32, height: 41 },
1975
+ 'vx-h2': { size: 24, height: 31 },
1976
+ 'vx-h3': { size: 18.72, height: 25 },
1977
+ 'vx-h4': { size: 16, height: 21 },
1978
+ 'vx-h5': { size: 13.28, height: 17 },
1979
+ 'vx-h6': { size: 10.72, height: 14 },
1980
+ }
1981
+
1982
+ node.headingDefaultHeight = undefined
1983
+ node.classLineHeight = undefined
1984
+ for (const name of classes) {
1985
+ const heading = headingMetrics[name]
1986
+ if (heading) {
1987
+ node.appliedFontWeight = '700'
1988
+ node.headingDefaultHeight = heading.height
1989
+ node.view!.font = fontForFamilyStyle(heading.size, 700, node.appliedFontFamily)
1990
+ }
1991
+
1992
+ if (sizes[name]) {
1993
+ node.headingDefaultHeight = undefined
1994
+ node.view!.font = fontForFamilyStyle(
1995
+ sizes[name],
1996
+ node.appliedFontWeight ?? 400,
1997
+ node.appliedFontFamily,
1998
+ )
1999
+ }
2000
+
2001
+ if (lineHeights[name] != null) {
2002
+ node.classLineHeight = lineHeights[name]
2003
+ }
2004
+
2005
+ if (name === 'font-semibold') {
2006
+ node.appliedFontWeight = '600'
2007
+ node.view!.font = fontForFamilyStyle(node.view!.font.pointSize, 600, node.appliedFontFamily)
2008
+ }
2009
+
2010
+ if (name === 'font-bold') {
2011
+ node.appliedFontWeight = '700'
2012
+ node.view!.font = fontForFamilyStyle(node.view!.font.pointSize, 700, node.appliedFontFamily)
2013
+ }
2014
+
2015
+ if (name === 'text-muted') {
2016
+ node.colorSlot = 'muted'
2017
+ }
2018
+
2019
+ if (name === 'text-onprimary') {
2020
+ node.colorSlot = 'onprimary'
2021
+ }
2022
+ }
2023
+ }
2024
+
2025
+ if (
2026
+ node.type === 'flexboxlayout' ||
2027
+ node.type === 'stack' ||
2028
+ node.type === 'scrollview' ||
2029
+ node.type === 'gridlayout'
2030
+ ) {
2031
+ const gaps: Record<string, number> = {
2032
+ 'gap-1': 4,
2033
+ 'gap-2': 8,
2034
+ 'gap-3': 12,
2035
+ 'gap-4': 16,
2036
+ 'gap-6': 24,
2037
+ }
2038
+
2039
+ for (const name of classes) {
2040
+ if (gaps[name] !== undefined) {
2041
+ node.view!.spacing = gaps[name]
2042
+ }
2043
+
2044
+ if (name === 'flex-row') {
2045
+ node.view!.orientation = NSUserInterfaceLayoutOrientation.Horizontal
2046
+ }
2047
+
2048
+ if (name === 'flex-col') {
2049
+ node.view!.orientation = NSUserInterfaceLayoutOrientation.Vertical
2050
+ }
2051
+
2052
+ if (name === 'items-center' || name === 'items-start' || name === 'items-end') {
2053
+ node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
2054
+ }
2055
+
2056
+ if (name === 'vx-toast' && node.type === 'flexboxlayout') {
2057
+ ;(node.view as NSStackView).edgeInsets = { top: 12, left: 12, bottom: 12, right: 12 }
2058
+ }
2059
+
2060
+ if (name === 'vx-toast-viewport') {
2061
+ ;(node.view as NSStackView).spacing = 8
2062
+ }
2063
+
2064
+ if (name === 'vx-button') {
2065
+ node.view!.orientation = NSUserInterfaceLayoutOrientation.Horizontal
2066
+ node.view!.alignment = NSLayoutAttribute.CenterY
2067
+ node.view!.distribution = NSStackViewDistribution.GravityAreas
2068
+ }
2069
+
2070
+ if (name === 'justify-between') {
2071
+ node.view!.distribution = NSStackViewDistribution.EqualSpacing
2072
+ }
2073
+
2074
+ if (name === 'flex-1') {
2075
+ node.view!.setContentHuggingPriorityForOrientation(
2076
+ 1,
2077
+ NSUserInterfaceLayoutOrientation.Vertical,
2078
+ )
2079
+
2080
+ node.view!.setContentCompressionResistancePriorityForOrientation(
2081
+ 1,
2082
+ NSUserInterfaceLayoutOrientation.Vertical,
2083
+ )
2084
+ }
2085
+
2086
+ if (name === 'shrink-0') {
2087
+ node.view!.setContentHuggingPriorityForOrientation(
2088
+ 750,
2089
+ NSUserInterfaceLayoutOrientation.Vertical,
2090
+ )
2091
+ }
2092
+
2093
+ if (name === 'rounded-full' || name.startsWith('rounded-')) {
2094
+ node.view!.wantsLayer = true
2095
+ node.view!.layer.cornerRadius = name === 'rounded-full' ? 12 : 8
2096
+ node.view!.layer.masksToBounds = true
2097
+ }
2098
+
2099
+ if (name === 'bg-primary' || name === 'btn') {
2100
+ node.bgSlot = 'primary'
2101
+ node.view!.wantsLayer = true
2102
+ node.view!.layer.backgroundColor = nativeColor(
2103
+ SCHEME_COLORS[nodeScheme(node)].primary,
2104
+ ).CGColor
2105
+
2106
+ node.view!.edgeInsets = { top: 6, left: 10, bottom: 6, right: 10 }
2107
+ }
2108
+
2109
+ if (name === 'bg-danger') {
2110
+ node.bgSlot = 'danger'
2111
+ node.view!.wantsLayer = true
2112
+ node.view!.layer.backgroundColor = nativeColor(
2113
+ SCHEME_COLORS[nodeScheme(node)].danger,
2114
+ ).CGColor
2115
+ }
2116
+
2117
+ if (name === 'btn-secondary' || name === 'chip' || name === 'chip-off') {
2118
+ node.bgSlot = 'secondary'
2119
+ node.view!.wantsLayer = true
2120
+ node.view!.layer.backgroundColor = nativeColor(
2121
+ SCHEME_COLORS[nodeScheme(node)].secondary,
2122
+ ).CGColor
2123
+
2124
+ node.view!.edgeInsets = { top: 4, left: 8, bottom: 4, right: 8 }
2125
+ }
2126
+
2127
+ // Panels that paint the web body's --color-surface. On this host the
2128
+ // root view fills the window, so the surface slot re-themes the whole
2129
+ // background the way `body { background }` does in a browser.
2130
+ if (
2131
+ name === 'vx-app' ||
2132
+ name === 'sheet-panel' ||
2133
+ name === 'overlay-panel' ||
2134
+ name === 'modal-panel'
2135
+ ) {
2136
+ node.bgSlot = 'surface'
2137
+ node.view!.wantsLayer = true
2138
+ node.view!.layer.backgroundColor = nativeColor(
2139
+ SCHEME_COLORS[nodeScheme(node)].surface,
2140
+ ).CGColor
2141
+ }
2142
+ }
2143
+ }
2144
+
2145
+ if (node.type === 'textfield' || node.type === 'textview') {
2146
+ if (classes.includes('vx-input') || classes.includes('vx-textarea')) {
2147
+ node.view!.font = fontForFamilyStyle(
2148
+ 14,
2149
+ node.appliedFontWeight ?? 400,
2150
+ node.appliedFontFamily,
2151
+ )
2152
+
2153
+ node.colorSlot = 'text'
2154
+ node.view!.drawsBackground = false
2155
+ if (node.type === 'textfield') {
2156
+ node.view!.bezeled = false
2157
+ }
2158
+
2159
+ if (node.type === 'textview') {
2160
+ node.view!.textContainerInset = { width: 0, height: 0 }
2161
+ }
2162
+ }
2163
+ }
2164
+
2165
+ applyThemeColors(node)
2166
+ // `dark`/`ns-dark` re-themes descendants too (the ns-dark cascade), so a
2167
+ // scheme flip on this node re-resolves the whole subtree.
2168
+ const dark = classes.includes('dark') || classes.includes('ns-dark')
2169
+ if (dark !== Boolean(node.hasDarkClass)) {
2170
+ node.hasDarkClass = dark
2171
+ for (const child of descendants(node)) {
2172
+ applyThemeColors(child)
2173
+ }
2174
+ }
2175
+
2176
+ if (node.parent) {
2177
+ setStackChildPriorities(node.parent, node)
2178
+ }
2179
+
2180
+ updateStackDistribution(node)
2181
+ }
2182
+
2183
+ function textContent(node: ElementNode): string {
2184
+ if (node.type === '#text') {
2185
+ return node.text
2186
+ }
2187
+
2188
+ if (node.type === 'label') {
2189
+ return node.view!.stringValue
2190
+ }
2191
+
2192
+ return node.children.map(textContent).join('')
2193
+ }
2194
+
2195
+ function setLabelText(node: ElementNode, text: any) {
2196
+ const lineHeight = Number(node.props?.style?.lineHeight)
2197
+ if (!Number.isFinite(lineHeight) || lineHeight <= 0) {
2198
+ node.view!.stringValue = text
2199
+ return
2200
+ }
2201
+
2202
+ const paragraphStyle = NSMutableParagraphStyle.alloc().init()
2203
+ paragraphStyle.minimumLineHeight = lineHeight
2204
+ paragraphStyle.maximumLineHeight = lineHeight
2205
+ node.view!.attributedStringValue = NSAttributedString.alloc().initWithStringAttributes(text, {
2206
+ [NSParagraphStyleAttributeName]: paragraphStyle,
2207
+ [NSFontAttributeName]: node.view!.font,
2208
+ [NSForegroundColorAttributeName]: node.view!.textColor,
2209
+ })
2210
+ }
2211
+
2212
+ function syncText(parent: ElementNode | null | undefined) {
2213
+ if (parent?.type !== 'label') {
2214
+ return
2215
+ }
2216
+
2217
+ const text = parent.children.length
2218
+ ? parent.children.map(textContent).join('')
2219
+ : String(parent.props?.text ?? '')
2220
+
2221
+ setLabelText(parent, text)
2222
+ }
2223
+
2224
+ function syncTextViewPlaceholder(node: ElementNode | undefined) {
2225
+ if (node?.placeholderView) {
2226
+ node.placeholderView.hidden = String(node.view!.string ?? '').length > 0
2227
+ }
2228
+ }
2229
+
2230
+ function setAction(node: ElementNode, value: any) {
2231
+ if (node.actionId === undefined) {
2232
+ node.actionId = nextActionId++
2233
+ actionIdsByView.set(node.view!, node.actionId)
2234
+ actionHandlers.set(node.actionId, null)
2235
+ node.view!.addGestureRecognizer(
2236
+ NSClickGestureRecognizer.alloc().initWithTargetAction(buttonActionTarget, 'viewPressed'),
2237
+ )
2238
+ }
2239
+
2240
+ actionHandlers.set(
2241
+ node.actionId,
2242
+ typeof value === 'function'
2243
+ ? () => {
2244
+ try {
2245
+ node.container.root!.eventScope('discrete', value)
2246
+ } catch (error) {
2247
+ console.error('[macos-event] press handler failed', error)
2248
+ }
2249
+ }
2250
+ : null,
2251
+ )
2252
+ }
2253
+
2254
+ function setPanAction(node: ElementNode, value: any) {
2255
+ if (typeof value === 'function' && !node.panGestureRecognizer) {
2256
+ node.panGestureRecognizer = NSPanGestureRecognizer.alloc().initWithTargetAction(
2257
+ buttonActionTarget,
2258
+ 'viewPanned',
2259
+ )
2260
+
2261
+ node.view!.addGestureRecognizer(node.panGestureRecognizer)
2262
+ }
2263
+
2264
+ panHandlersByView.set(
2265
+ node.view!,
2266
+ typeof value === 'function'
2267
+ ? (event: PanEvent) => {
2268
+ try {
2269
+ node.container.root!.eventScope('discrete', () => value(event))
2270
+ } catch (error) {
2271
+ console.error('[macos-event] pan handler failed', error)
2272
+ }
2273
+ }
2274
+ : null,
2275
+ )
2276
+ }
2277
+
2278
+ function setControlAction(node: ElementNode, value: any, readValue: () => any) {
2279
+ if (node.actionId === undefined) {
2280
+ return
2281
+ }
2282
+
2283
+ actionHandlers.set(
2284
+ node.actionId,
2285
+ typeof value === 'function'
2286
+ ? () => {
2287
+ try {
2288
+ node.container.root!.eventScope('discrete', () => value(readValue()))
2289
+ } catch (error) {
2290
+ console.error('[macos-event] control handler failed', error)
2291
+ }
2292
+ }
2293
+ : null,
2294
+ )
2295
+ }
2296
+
2297
+ function applyAccessibility(node: ElementNode, name: string, value: any) {
2298
+ if (node.type === 'flexboxlayout' && node.actionId !== undefined) {
2299
+ const props = stackAccessibilityPropsByView.get(node.view!) ?? {}
2300
+ stackAccessibilityPropsByView.set(node.view!, { ...props, [name]: value })
2301
+ if (name === 'accessibilityLabel') {
2302
+ accessibilityLabels.set(node.actionId!, String(value ?? ''))
2303
+ }
2304
+
2305
+ if (name === 'accessibilityRole') {
2306
+ accessibilityRoles.set(node.actionId!, String(value ?? ''))
2307
+ }
2308
+ }
2309
+
2310
+ if (node.type === 'label' && name === 'accessibilityLabel') {
2311
+ node.view!.setAccessibilityLabel?.(String(value ?? ''))
2312
+ }
2313
+ }
2314
+
2315
+ function applyProps(node: ElementNode, props: PropBag) {
2316
+ node.props = { ...node.props, ...props }
2317
+ if (node.type === '#text') {
2318
+ if ('value' in props) {
2319
+ node.text = String(props.value ?? '')
2320
+ }
2321
+
2322
+ syncText(node.parent)
2323
+ return
2324
+ }
2325
+
2326
+ if (node.type === 'span') {
2327
+ return
2328
+ }
2329
+
2330
+ for (const [name, value] of Object.entries(props)) {
2331
+ if (name === 'onLayoutChanged') {
2332
+ setLayoutAction(node, value)
2333
+ continue
2334
+ }
2335
+
2336
+ switch (node.type) {
2337
+ case 'stack':
2338
+ if (name === 'spacing') {
2339
+ node.view!.spacing = Number(value ?? 0)
2340
+ } else if (name === 'style') {
2341
+ applyStyle(node, value)
2342
+ } else if (name === 'className') {
2343
+ applyClassName(node, value)
2344
+ } else if (name === 'id') {
2345
+ continue
2346
+ } else {
2347
+ console.warn('[macos-host] ignored stack prop ' + name)
2348
+ }
2349
+
2350
+ break
2351
+ case 'flexboxlayout':
2352
+ if (name === 'gap') {
2353
+ node.view!.spacing = Number(value ?? 0)
2354
+ } else if (name === 'spacing') {
2355
+ node.view!.spacing = Number(value ?? 0)
2356
+ } else if (name === 'flexDirection') {
2357
+ node.view!.orientation =
2358
+ value === 'row'
2359
+ ? NSUserInterfaceLayoutOrientation.Horizontal
2360
+ : NSUserInterfaceLayoutOrientation.Vertical
2361
+
2362
+ node.view!.distribution = NSStackViewDistribution.GravityAreas
2363
+ node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
2364
+ moveStackChildren(node)
2365
+ updateCrossAxisConstraints(node)
2366
+ } else if (name === 'alignItems') {
2367
+ node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
2368
+ updateCrossAxisConstraints(node)
2369
+ } else if (name === 'justifyContent') {
2370
+ node.view!.distribution = NSStackViewDistribution.GravityAreas
2371
+ moveStackChildren(node)
2372
+ } else if (name === 'style') {
2373
+ applyStyle(node, value)
2374
+ } else if (name === 'className') {
2375
+ applyClassName(node, value)
2376
+ moveStackChildren(node)
2377
+ node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
2378
+ updateCrossAxisConstraints(node)
2379
+ } else if (name === 'id') {
2380
+ continue
2381
+ } else if (name === 'onTap') {
2382
+ setAction(node, value)
2383
+ } else if (name === 'onPan') {
2384
+ setPanAction(node, value)
2385
+ } else if (name === 'onTouch') {
2386
+ continue
2387
+ } else if (name === 'onSwipe') {
2388
+ if (typeof value === 'function') {
2389
+ console.warn('[macos-host] ' + name + ' is unsupported by the AppKit renderer')
2390
+ }
2391
+ } else if (name.startsWith('on') && value == null) {
2392
+ continue
2393
+ } else if (name === 'accessible' || name.startsWith('accessibility')) {
2394
+ applyAccessibility(node, name, value)
2395
+ } else if (
2396
+ [
2397
+ 'alignItems',
2398
+ 'justifyContent',
2399
+ 'flexWrap',
2400
+ 'row',
2401
+ 'col',
2402
+ 'rowSpan',
2403
+ 'colSpan',
2404
+ 'left',
2405
+ 'top',
2406
+ 'flexGrow',
2407
+ 'flexShrink',
2408
+ 'alignSelf',
2409
+ 'order',
2410
+ 'horizontalAlignment',
2411
+ 'verticalAlignment',
2412
+ ].includes(name)
2413
+ ) {
2414
+ continue
2415
+ } else {
2416
+ console.warn('[macos-host] ignored flexboxlayout prop ' + name)
2417
+ }
2418
+
2419
+ break
2420
+ case 'gridlayout':
2421
+ if (name === 'style') {
2422
+ applyStyle(node, value)
2423
+ } else if (name === 'className' || name === 'id') {
2424
+ continue
2425
+ } else if (name === 'overlayChildren') {
2426
+ continue
2427
+ } else if (name === 'disabled') {
2428
+ continue
2429
+ } else if (name === 'onPan') {
2430
+ setPanAction(node, value)
2431
+ } else if (name === 'onAccessibilityIncrement' || name === 'onAccessibilityDecrement') {
2432
+ continue
2433
+ } else if (name === 'onSwipe') {
2434
+ if (typeof value === 'function') {
2435
+ console.warn('[macos-host] onSwipe is unsupported by the AppKit renderer')
2436
+ }
2437
+ } else if (name.startsWith('on') && value == null) {
2438
+ continue
2439
+ } else if (name === 'accessible' || name.startsWith('accessibility')) {
2440
+ applyAccessibility(node, name, value)
2441
+ } else if (['rows', 'columns'].includes(name)) {
2442
+ layoutGridChildren(node)
2443
+ } else {
2444
+ console.warn('[macos-host] ignored gridlayout prop ' + name)
2445
+ }
2446
+
2447
+ break
2448
+ case 'absolutelayout':
2449
+ if (name === 'style') {
2450
+ applyStyle(node, value)
2451
+ } else if (name === 'className' || name === 'id') {
2452
+ continue
2453
+ } else if (name === 'onPan') {
2454
+ setPanAction(node, value)
2455
+ } else if (name.startsWith('on') && value == null) {
2456
+ continue
2457
+ } else if (name === 'accessible' || name.startsWith('accessibility')) {
2458
+ applyAccessibility(node, name, value)
2459
+ } else {
2460
+ console.warn('[macos-host] ignored absolutelayout prop ' + name)
2461
+ }
2462
+
2463
+ break
2464
+ case 'label':
2465
+ if (name === 'text') {
2466
+ setLabelText(node, String(value ?? ''))
2467
+ } else if (name === 'fontSize') {
2468
+ node.view!.font = fontForFamilyStyle(
2469
+ Number(value ?? 16),
2470
+ node.appliedFontWeight ?? 400,
2471
+ node.appliedFontFamily,
2472
+ )
2473
+ } else if (name === 'style') {
2474
+ applyStyle(node, value)
2475
+ syncText(node)
2476
+ } else if (name === 'className') {
2477
+ applyClassName(node, value)
2478
+ } else if (name === 'id') {
2479
+ continue
2480
+ } else if (['maxLines', 'whiteSpace', 'textOverflow', 'accessible'].includes(name)) {
2481
+ continue
2482
+ } else if (name.startsWith('accessibility')) {
2483
+ applyAccessibility(node, name, value)
2484
+ } else if (name.startsWith('on') && value == null) {
2485
+ continue
2486
+ } else {
2487
+ console.warn('[macos-host] ignored label prop ' + name)
2488
+ }
2489
+
2490
+ break
2491
+ case 'button':
2492
+ if (name === 'title') {
2493
+ node.view!.title = String(value ?? '')
2494
+ } else if (name === 'enabled') {
2495
+ node.view!.enabled = value !== false
2496
+ } else if (name === 'onPress') {
2497
+ actionHandlers.set(
2498
+ node.actionId!,
2499
+ typeof value === 'function'
2500
+ ? () => {
2501
+ try {
2502
+ node.container.root!.eventScope('discrete', value)
2503
+ } catch (error) {
2504
+ console.error('[macos-event] button handler failed', error)
2505
+ }
2506
+ }
2507
+ : null,
2508
+ )
2509
+ } else if (name === 'style') {
2510
+ applyStyle(node, value)
2511
+ } else if (name === 'className' || name === 'id') {
2512
+ continue
2513
+ } else {
2514
+ console.warn('[macos-host] ignored button prop ' + name)
2515
+ }
2516
+
2517
+ break
2518
+ case 'scrollview':
2519
+ if (name === 'style') {
2520
+ applyStyle(node, value)
2521
+ } else if (name === 'className') {
2522
+ applyClassName(node, value)
2523
+ } else if (name === 'accessibilityLabel') {
2524
+ node.view!.setAccessibilityLabel?.(String(value ?? ''))
2525
+ } else if (
2526
+ name === 'id' ||
2527
+ name === 'horizontal' ||
2528
+ name === 'showsVerticalScrollIndicator'
2529
+ ) {
2530
+ continue
2531
+ } else if (name === 'onScroll') {
2532
+ setScrollAction(node, value)
2533
+ } else if (name.startsWith('on')) {
2534
+ if (typeof value === 'function') {
2535
+ console.warn('[macos-host] ' + name + ' is unsupported on ScrollView')
2536
+ }
2537
+ } else {
2538
+ console.warn('[macos-host] ignored scrollview prop ' + name)
2539
+ }
2540
+
2541
+ break
2542
+ case 'textfield':
2543
+ case 'textview':
2544
+ if (name === 'value') {
2545
+ if (node.type === 'textview') {
2546
+ node.view!.string = String(value ?? '')
2547
+ syncTextViewPlaceholder(node)
2548
+ } else {
2549
+ node.view!.stringValue = String(value ?? '')
2550
+ }
2551
+ } else if (name === 'placeholder') {
2552
+ if (node.type === 'textfield') {
2553
+ continue
2554
+ } else if (node.placeholderView) {
2555
+ node.placeholderView.stringValue = String(value ?? '')
2556
+ syncTextViewPlaceholder(node)
2557
+ }
2558
+ } else if (name === 'placeholderTextColor' && node.type === 'textfield') {
2559
+ continue
2560
+ } else if (name === 'onTextChange') {
2561
+ setControlAction(node, value, () =>
2562
+ String(node.type === 'textview' ? node.view!.string : (node.view!.stringValue ?? '')),
2563
+ )
2564
+ } else if (name === 'style') {
2565
+ applyStyle(node, value)
2566
+ } else if (name === 'className') {
2567
+ applyClassName(node, value)
2568
+ } else if (name === 'id') {
2569
+ continue
2570
+ } else if (name === 'rows' && node.type === 'textview') {
2571
+ continue
2572
+ } else if (name.startsWith('accessibility')) {
2573
+ applyAccessibility(node, name, value)
2574
+ } else if (name.startsWith('on') && value == null) {
2575
+ continue
2576
+ } else if (
2577
+ [
2578
+ 'editable',
2579
+ 'enabled',
2580
+ 'secure',
2581
+ 'keyboardType',
2582
+ 'returnKeyType',
2583
+ 'autoGrow',
2584
+ 'maxRows',
2585
+ ].includes(name)
2586
+ ) {
2587
+ continue
2588
+ } else {
2589
+ console.warn('[macos-host] ignored text control prop ' + name)
2590
+ }
2591
+
2592
+ break
2593
+ case 'switch':
2594
+ if (name === 'checked') {
2595
+ node.view!.state = value ? 1 : 0
2596
+ } else if (name === 'onCheckedChange') {
2597
+ setControlAction(node, value, () => node.view!.state === 1)
2598
+ } else if (name === 'disabled') {
2599
+ node.view!.enabled = value !== true
2600
+ } else if (name === 'style') {
2601
+ applyStyle(node, value)
2602
+ } else if (name === 'className' || name === 'id') {
2603
+ continue
2604
+ } else {
2605
+ console.warn('[macos-host] ignored switch prop ' + name)
2606
+ }
2607
+
2608
+ break
2609
+ case 'slider':
2610
+ if (name === 'value') {
2611
+ node.view!.doubleValue = Number(value ?? 0)
2612
+ } else if (name === 'minValue') {
2613
+ node.view!.minValue = Number(value ?? 0)
2614
+ } else if (name === 'maxValue') {
2615
+ node.view!.maxValue = Number(value ?? 1)
2616
+ } else if (name === 'onValueChange') {
2617
+ setControlAction(node, value, () => Number(node.view!.doubleValue))
2618
+ } else if (name === 'disabled') {
2619
+ node.view!.enabled = value !== true
2620
+ } else if (name === 'style') {
2621
+ applyStyle(node, value)
2622
+ } else if (name === 'className' || name === 'id') {
2623
+ continue
2624
+ } else {
2625
+ console.warn('[macos-host] ignored slider prop ' + name)
2626
+ }
2627
+
2628
+ break
2629
+ case 'webview':
2630
+ if (name === 'style') {
2631
+ applyStyle(node, value)
2632
+ } else if (name === 'className') {
2633
+ applyClassName(node, value)
2634
+ } else if (name === 'accessibilityLabel') {
2635
+ node.view!.accessibilityLabel = String(value ?? '')
2636
+ } else if (name === 'accessible') {
2637
+ node.view!.accessibilityElement = value !== false
2638
+ } else if (name === 'accessibilityHint') {
2639
+ node.view!.accessibilityHelp = String(value ?? '')
2640
+ }
2641
+
2642
+ break
2643
+ case 'image':
2644
+ if (name === 'src') {
2645
+ node.view!.image = loadImage(value)
2646
+ } else if (name === 'style') {
2647
+ applyStyle(node, value)
2648
+ } else if (name === 'className' || name === 'id' || name === 'alt') {
2649
+ continue
2650
+ } else if (name === 'accessibilityLabel') {
2651
+ node.view!.accessibilityLabel = String(value ?? '')
2652
+ node.view!.accessibilityElement = !!value
2653
+ } else {
2654
+ console.warn('[macos-host] ignored image prop ' + name)
2655
+ }
2656
+
2657
+ break
2658
+ }
2659
+ }
2660
+
2661
+ if (node.type === 'webview') {
2662
+ updateWebView(node, props, (height) => {
2663
+ node.props.style = { ...node.props.style, height }
2664
+ setSizeConstraint(node, 'height', height)
2665
+ })
2666
+ }
2667
+
2668
+ if (node.type === 'textfield') {
2669
+ setTextFieldPlaceholder(node.view as NSTextField, node.props, nodeScheme(node))
2670
+ }
2671
+
2672
+ if (node.type === 'label') {
2673
+ const style = node.props.style ?? {}
2674
+ if (style.lineHeight == null && style.height == null) {
2675
+ const size = Number(node.view!.font?.pointSize ?? 16)
2676
+ const height =
2677
+ style.fontSize == null && node.headingDefaultHeight != null
2678
+ ? node.headingDefaultHeight
2679
+ : (node.classLineHeight ?? Math.ceil(size * DEFAULT_TEXT_LINE_HEIGHT_RATIO))
2680
+
2681
+ setSizeConstraint(node, 'height', height)
2682
+ }
2683
+ }
2684
+
2685
+ if (node.type === 'textview') {
2686
+ const style = node.props.style ?? {}
2687
+ const rows = Number(node.props.rows)
2688
+ if (style.height == null && Number.isFinite(rows) && rows > 0) {
2689
+ const font = node.view!.font
2690
+ const lineHeight = Number(font?.ascender) - Number(font?.descender) + Number(font?.leading)
2691
+ const rowHeight = Math.round(
2692
+ Number.isFinite(lineHeight)
2693
+ ? lineHeight
2694
+ : Number(font?.pointSize ?? 14) * DEFAULT_TEXT_LINE_HEIGHT_RATIO,
2695
+ )
2696
+
2697
+ setSizeConstraint(node, 'height', Math.max(1, Math.floor(rows)) * rowHeight)
2698
+ }
2699
+ }
2700
+
2701
+ if (node.type === 'gridlayout') {
2702
+ layoutGridChildren(node)
2703
+ } else if (node.type === 'absolutelayout') {
2704
+ layoutAbsoluteChildren(node)
2705
+ } else if (node.parent?.type === 'gridlayout') {
2706
+ layoutGridChildren(node.parent)
2707
+ } else if (node.parent?.type === 'absolutelayout') {
2708
+ layoutAbsoluteChildren(node.parent)
2709
+ }
2710
+ }
2711
+
2712
+ function detach(container: RootContainer, node: ElementNode) {
2713
+ const previousParent = node.parent
2714
+ const siblings = previousParent?.children ?? container.children
2715
+ const index = siblings.indexOf(node)
2716
+ if (index >= 0) {
2717
+ siblings.splice(index, 1)
2718
+ }
2719
+
2720
+ if (node.crossAxisConstraint) {
2721
+ node.crossAxisConstraint.active = false
2722
+ }
2723
+
2724
+ node.crossAxisConstraint = null
2725
+ deactivateSizeConstraints(node)
2726
+ if (node.view) {
2727
+ const parentView = previousParent?.childHost ?? previousParent?.view
2728
+ if (parentView?.removeArrangedSubview) {
2729
+ parentView.removeArrangedSubview(arrangedView(node))
2730
+ }
2731
+
2732
+ arrangedView(node).removeFromSuperview()
2733
+ }
2734
+
2735
+ if (previousParent?.type === 'gridlayout') {
2736
+ layoutGridChildren(previousParent)
2737
+ }
2738
+
2739
+ if (previousParent?.type === 'absolutelayout') {
2740
+ layoutAbsoluteChildren(previousParent)
2741
+ }
2742
+
2743
+ if (previousParent) {
2744
+ updateStackDistribution(previousParent)
2745
+ }
2746
+
2747
+ syncText(previousParent)
2748
+ node.parent = null
2749
+ }
2750
+
2751
+ function insert(
2752
+ container: RootContainer,
2753
+ parentId: number | null,
2754
+ node: ElementNode,
2755
+ beforeId: number | null,
2756
+ ) {
2757
+ detach(container, node)
2758
+ const parent = (parentId === null ? null : container.nodes.get(parentId)) ?? null
2759
+ if (parentId !== null && !parent) {
2760
+ throw new Error('Unknown AppKit parent ' + parentId)
2761
+ }
2762
+
2763
+ if (
2764
+ parent &&
2765
+ node.view &&
2766
+ parent.type !== 'stack' &&
2767
+ parent.type !== 'flexboxlayout' &&
2768
+ parent.type !== 'scrollview' &&
2769
+ parent.type !== 'gridlayout' &&
2770
+ parent.type !== 'absolutelayout'
2771
+ ) {
2772
+ throw new Error('AppKit <' + parent.type + '> cannot contain child views')
2773
+ }
2774
+
2775
+ const siblings = parent ? parent.children : container.children
2776
+ const beforeIndex = beforeId === null ? -1 : siblings.findIndex((child) => child.id === beforeId)
2777
+ const index = beforeIndex < 0 ? siblings.length : beforeIndex
2778
+ siblings.splice(index, 0, node)
2779
+ node.parent = parent
2780
+ if (node.view) {
2781
+ const parentView = parent?.childHost ?? parent?.view ?? container.hostView
2782
+ if (!parentView) {
2783
+ throw new Error('AppKit host has no parent view for node ' + node.id)
2784
+ }
2785
+
2786
+ if (parent) {
2787
+ if (parent.type === 'gridlayout') {
2788
+ parentView.addSubview(node.view)
2789
+ node.view.translatesAutoresizingMaskIntoConstraints = false
2790
+ deactivateSizeConstraints(node)
2791
+ layoutGridChildren(parent)
2792
+ } else if (parent.type === 'absolutelayout') {
2793
+ parentView.addSubview(node.view)
2794
+ node.view.translatesAutoresizingMaskIntoConstraints = false
2795
+ deactivateSizeConstraints(node)
2796
+ layoutAbsoluteChildren(parent)
2797
+ } else {
2798
+ if (marginInsetsOf(node)) {
2799
+ makeMarginHost(node)
2800
+ }
2801
+
2802
+ // AppKit insertion indices are local to a gravity area. Honor the
2803
+ // renderer's before edge instead of appending moved keyed rows.
2804
+ const gravity = stackGravity(parent, node)
2805
+ const arrangedIndex = siblings
2806
+ .slice(0, index)
2807
+ .filter((child) => child.view && stackGravity(parent, child) === gravity).length
2808
+
2809
+ parentView.insertViewAtIndexInGravity(arrangedView(node), arrangedIndex, gravity)
2810
+ applySizeConstraints(node)
2811
+ setStackChildPriorities(parent, node)
2812
+ updateCrossAxisConstraints(parent)
2813
+ }
2814
+ } else {
2815
+ parentView.addSubview(node.view)
2816
+ node.view.leadingAnchor.constraintEqualToAnchor(parentView.leadingAnchor).active = true
2817
+ node.view.trailingAnchor.constraintEqualToAnchor(parentView.trailingAnchor).active = true
2818
+ node.view.topAnchor.constraintEqualToAnchor(parentView.topAnchor).active = true
2819
+ node.view.bottomAnchor.constraintEqualToAnchor(parentView.bottomAnchor).active = true
2820
+ applySizeConstraints(node)
2821
+ }
2822
+
2823
+ syncScheme(node)
2824
+ } else {
2825
+ syncText(parent)
2826
+ }
2827
+ }
2828
+
2829
+ function remove(container: RootContainer, parentId: number | null, node: ElementNode) {
2830
+ const expectedParent = (parentId === null ? null : container.nodes.get(parentId)) ?? null
2831
+ if (node.parent !== expectedParent) {
2832
+ return
2833
+ }
2834
+
2835
+ const siblings = expectedParent ? expectedParent.children : container.children
2836
+ const index = siblings.indexOf(node)
2837
+ if (index >= 0) {
2838
+ siblings.splice(index, 1)
2839
+ }
2840
+
2841
+ if (node.crossAxisConstraint) {
2842
+ node.crossAxisConstraint.active = false
2843
+ }
2844
+
2845
+ node.crossAxisConstraint = null
2846
+ deactivateSizeConstraints(node)
2847
+ if (node.view) {
2848
+ const parentView = expectedParent?.childHost ?? expectedParent?.view
2849
+ if (parentView?.removeArrangedSubview) {
2850
+ parentView.removeArrangedSubview(arrangedView(node))
2851
+ }
2852
+
2853
+ node.view.removeFromSuperview()
2854
+ node.marginHost?.removeFromSuperview()
2855
+ node.marginHost = null
2856
+ node.marginConstraints = null
2857
+ }
2858
+
2859
+ if (expectedParent?.type === 'gridlayout') {
2860
+ layoutGridChildren(expectedParent)
2861
+ }
2862
+
2863
+ if (expectedParent?.type === 'absolutelayout') {
2864
+ layoutAbsoluteChildren(expectedParent)
2865
+ }
2866
+
2867
+ if (expectedParent) {
2868
+ updateStackDistribution(expectedParent)
2869
+ }
2870
+
2871
+ node.parent = null
2872
+ syncText(expectedParent)
2873
+ }
2874
+
2875
+ function destroy(node: ElementNode) {
2876
+ if (node.type === 'webview') {
2877
+ disposeWebView(node.view!)
2878
+ }
2879
+
2880
+ if (node.type === 'scrollview' && node.scrollObserverInstalled) {
2881
+ const clipView = node.view!.contentView
2882
+ scrollHandlers.delete(clipView)
2883
+ NSNotificationCenter.defaultCenter.removeObserver(node.scrollObserver)
2884
+ node.scrollObserver = null
2885
+ node.scrollObserverInstalled = false
2886
+ }
2887
+
2888
+ if (node.layoutObserver) {
2889
+ NSNotificationCenter.defaultCenter.removeObserver(node.layoutObserver)
2890
+ node.layoutObserver = null
2891
+ }
2892
+
2893
+ if (node.actionId !== undefined) {
2894
+ actionHandlers.delete(node.actionId)
2895
+ accessibilityLabels.delete(node.actionId)
2896
+ accessibilityRoles.delete(node.actionId)
2897
+ }
2898
+ }
2899
+
2900
+ function applyCommand(container: RootContainer, command: UniversalHostCommand) {
2901
+ switch (command.op) {
2902
+ case 'create':
2903
+ container.nodes.set(command.id, makeNode(container, command.id, command.type, command.props))
2904
+ return
2905
+ case 'update': {
2906
+ const node = container.nodes.get(command.id)
2907
+ if (!node) {
2908
+ throw new Error('Unknown AppKit node ' + command.id)
2909
+ }
2910
+
2911
+ applyProps(node, command.props)
2912
+ return
2913
+ }
2914
+ case 'insert':
2915
+ case 'move': {
2916
+ const node = container.nodes.get(command.id)
2917
+ if (!node) {
2918
+ throw new Error('Unknown AppKit node ' + command.id)
2919
+ }
2920
+
2921
+ insert(container, command.parent as number | null, node, command.before)
2922
+ return
2923
+ }
2924
+ case 'remove': {
2925
+ const node = container.nodes.get(command.id)
2926
+ if (node) {
2927
+ remove(container, command.parent as number | null, node)
2928
+ }
2929
+
2930
+ return
2931
+ }
2932
+ case 'destroy': {
2933
+ const node = container.nodes.get(command.id)
2934
+ if (node) {
2935
+ destroy(node)
2936
+ }
2937
+
2938
+ container.nodes.delete(command.id)
2939
+ return
2940
+ }
2941
+ case 'visibility': {
2942
+ const node = container.nodes.get(command.id)
2943
+ if (node?.view) {
2944
+ node.view.hidden = command.state === 'hidden'
2945
+ }
2946
+
2947
+ return
2948
+ }
2949
+ case 'ensure-public-instance':
2950
+ return
2951
+ case 'recreate': {
2952
+ const node = container.nodes.get(command.id)
2953
+ if (!node) {
2954
+ throw new Error('Unknown AppKit node ' + command.id)
2955
+ }
2956
+
2957
+ const replacement = makeNode(container, command.id, command.type, command.props)
2958
+ const parent = node.parent
2959
+ const siblings = parent ? parent.children : container.children
2960
+ const index = siblings.indexOf(node)
2961
+ const beforeId = index < 0 ? null : (siblings[index + 1]?.id ?? null)
2962
+ remove(container, parent?.id ?? null, node)
2963
+ destroy(node)
2964
+ container.nodes.set(command.id, replacement)
2965
+ insert(container, parent?.id ?? null, replacement, beforeId)
2966
+ return
2967
+ }
2968
+ default:
2969
+ throw new Error('AppKit spike does not support host command ' + command.op)
2970
+ }
2971
+ }
2972
+
2973
+ const macOSDriver: UniversalHostDriver<RootContainer, any> = {
2974
+ id: 'macos',
2975
+ capabilities: { text: 'host' },
2976
+ prepareBatch(container, batch) {
2977
+ return {
2978
+ apply() {
2979
+ for (const command of batch.commands) {
2980
+ try {
2981
+ applyCommand(container, command)
2982
+ } catch (error) {
2983
+ // The universal root can swallow a mid-batch failure when it
2984
+ // retries or aborts the attempt — log it here so an
2985
+ // unsupported element cannot silently stall the mount.
2986
+ console.error(
2987
+ '[macos-host] command ' +
2988
+ command.op +
2989
+ ' failed for <' +
2990
+ ((command as { type?: string }).type ?? '?') +
2991
+ '> id=' +
2992
+ ('id' in command ? command.id : '?'),
2993
+ error,
2994
+ )
2995
+
2996
+ throw error
2997
+ }
2998
+ }
2999
+ },
3000
+ abort() {},
3001
+ }
3002
+ },
3003
+ getPublicInstance(container, id) {
3004
+ return container.nodes.get(id)?.view ?? null
3005
+ },
3006
+ }
3007
+
3008
+ const round = (value: number) => Math.round(value * 100) / 100
3009
+
3010
+ function nodeClasses(node: ElementNode) {
3011
+ return String(node.props?.className ?? '')
3012
+ .split(/\s+/)
3013
+ .filter(Boolean)
3014
+ }
3015
+
3016
+ function descendants(node: ElementNode, out: ElementNode[] = []) {
3017
+ for (const child of node.children) {
3018
+ out.push(child)
3019
+ descendants(child, out)
3020
+ }
3021
+
3022
+ return out
3023
+ }
3024
+
3025
+ function withDrawingAppearance<T>(appearance: any, read: () => T): T {
3026
+ let value!: T
3027
+ const readValue = () => {
3028
+ value = read()
3029
+ }
3030
+
3031
+ if (typeof appearance?.performAsCurrentDrawingAppearance === 'function') {
3032
+ appearance.performAsCurrentDrawingAppearance(readValue)
3033
+ } else {
3034
+ readValue()
3035
+ }
3036
+
3037
+ return value
3038
+ }
3039
+
3040
+ function colorValue(color: any, appearance?: any) {
3041
+ if (!color) {
3042
+ return undefined
3043
+ }
3044
+
3045
+ try {
3046
+ return withDrawingAppearance(appearance, () => {
3047
+ const rgb = color.colorUsingColorSpace?.(NSColorSpace.sRGBColorSpace) ?? color
3048
+ return (
3049
+ '#' +
3050
+ [rgb.redComponent, rgb.greenComponent, rgb.blueComponent]
3051
+ .map((component) =>
3052
+ Math.round(Number(component) * 255)
3053
+ .toString(16)
3054
+ .padStart(2, '0'),
3055
+ )
3056
+ .join('')
3057
+ )
3058
+ })
3059
+ } catch {
3060
+ return undefined
3061
+ }
3062
+ }
3063
+
3064
+ function stackDirection(view: any) {
3065
+ return view.orientation === NSUserInterfaceLayoutOrientation.Horizontal ? 'row' : 'column'
3066
+ }
3067
+
3068
+ function parityStyle(node: ElementNode, facets: readonly string[]) {
3069
+ const view = node.view
3070
+ const font = view?.font
3071
+ const supplied: PropBag = node.props?.style ?? {}
3072
+ const out: Record<string, any> = {}
3073
+ for (const facet of facets) {
3074
+ let value = supplied[facet]
3075
+ if (facet === 'paddingTop' && view?.edgeInsets?.top != null) {
3076
+ value = view.edgeInsets.top
3077
+ }
3078
+
3079
+ if (facet === 'paddingRight' && view?.edgeInsets?.right != null) {
3080
+ value = view.edgeInsets.right
3081
+ }
3082
+
3083
+ if (facet === 'paddingBottom' && view?.edgeInsets?.bottom != null) {
3084
+ value = view.edgeInsets.bottom
3085
+ }
3086
+
3087
+ if (facet === 'paddingLeft' && view?.edgeInsets?.left != null) {
3088
+ value = view.edgeInsets.left
3089
+ }
3090
+
3091
+ if (facet === 'fontSize' && font?.pointSize != null) {
3092
+ value = font.pointSize
3093
+ }
3094
+
3095
+ if (facet === 'fontFamily' && font?.familyName) {
3096
+ value = font.familyName
3097
+ }
3098
+
3099
+ if (facet === 'fontWeight' && font?.fontDescriptor?.symbolicTraits != null) {
3100
+ value =
3101
+ supplied.fontWeight ??
3102
+ node.appliedFontWeight ??
3103
+ (Number(font.fontDescriptor.symbolicTraits) & 2 ? '700' : '400')
3104
+ }
3105
+
3106
+ if (facet === 'lineHeight' && font) {
3107
+ value =
3108
+ supplied.lineHeight ??
3109
+ (node.type === 'label'
3110
+ ? round(Number(view.frame.size.height))
3111
+ : round(Number(font.ascender) - Number(font.descender) + Number(font.leading)))
3112
+ }
3113
+
3114
+ if (facet === 'color' && view?.textColor) {
3115
+ value = colorValue(view.textColor) ?? value
3116
+ }
3117
+
3118
+ if (facet === 'backgroundColor') {
3119
+ value =
3120
+ colorValue(
3121
+ view?.layer?.backgroundColor
3122
+ ? NSColor.colorWithCGColor(view.layer.backgroundColor)
3123
+ : null,
3124
+ ) ?? value
3125
+
3126
+ if (value == null && view?.drawsBackground === false) {
3127
+ value = 'rgba(0,0,0,0)'
3128
+ }
3129
+ }
3130
+
3131
+ if (facet === 'borderTopWidth' && view?.layer) {
3132
+ value = view.layer.borderWidth
3133
+ }
3134
+
3135
+ if (facet === 'borderTopColor' && view?.layer?.borderColor) {
3136
+ value = colorValue(NSColor.colorWithCGColor(view.layer.borderColor)) ?? value
3137
+ }
3138
+
3139
+ if (facet === 'borderTopLeftRadius' && view?.layer) {
3140
+ value = view.layer.cornerRadius
3141
+ }
3142
+
3143
+ if (facet === 'opacity' && view?.alphaValue != null) {
3144
+ value = view.alphaValue
3145
+ }
3146
+
3147
+ if (facet === 'flexDirection' && view?.orientation != null) {
3148
+ value = stackDirection(view)
3149
+ }
3150
+
3151
+ if (facet === 'alignItems' && view?.orientation != null) {
3152
+ value = stackAlignItems(node)
3153
+ }
3154
+
3155
+ if (facet === 'justifyContent' && view?.distribution != null) {
3156
+ value = stackJustifyContent(node)
3157
+ }
3158
+
3159
+ if (value !== undefined && value !== null && value !== '') {
3160
+ out[facet] = String(value)
3161
+ }
3162
+ }
3163
+
3164
+ if (font?.fontName) {
3165
+ out.fontPostScriptName = String(font.fontName)
3166
+ }
3167
+
3168
+ return out
3169
+ }
3170
+
3171
+ function measureTextLineAdvances(value: string, font: any) {
3172
+ if (!value || !font) {
3173
+ return undefined
3174
+ }
3175
+
3176
+ return String(value)
3177
+ .split(/\r\n|\r|\n/)
3178
+ .map((line) => {
3179
+ const attributed = NSAttributedString.alloc().initWithStringAttributes(line, {
3180
+ [NSFontAttributeName]: font,
3181
+ })
3182
+
3183
+ return round(Number(attributed.size().width))
3184
+ })
3185
+ }
3186
+
3187
+ function measureTextLineCount(node: ElementNode) {
3188
+ if (node.type !== 'label') {
3189
+ return undefined
3190
+ }
3191
+
3192
+ const view = node.view
3193
+ const cell = view?.cell
3194
+ if (typeof cell?.cellSizeForBounds !== 'function') {
3195
+ return undefined
3196
+ }
3197
+
3198
+ const bounds = view!.bounds
3199
+ const fit = cell.cellSizeForBounds({
3200
+ origin: { x: 0, y: 0 },
3201
+ size: { width: Number(bounds.size.width), height: 100000 },
3202
+ })
3203
+
3204
+ const height = Number(fit?.height)
3205
+ const styleLineHeight = Number(parityStyle(node, []).lineHeight)
3206
+ const font = view!.font
3207
+ const fontLineHeight = Number(font?.ascender) - Number(font?.descender) + Number(font?.leading)
3208
+ const lineHeight = styleLineHeight > 0 ? styleLineHeight : fontLineHeight
3209
+ if (!Number.isFinite(height) || height <= 0 || !Number.isFinite(lineHeight) || lineHeight <= 0) {
3210
+ return undefined
3211
+ }
3212
+
3213
+ return Math.max(1, Math.round(height / lineHeight))
3214
+ }
3215
+
3216
+ function parityBoxInHost(view: any, rect: any, boxView: any) {
3217
+ const converted = view.superview
3218
+ ? view.superview.convertRectToView(rect, boxView)
3219
+ : view.convertRectToView(view.bounds, boxView)
3220
+
3221
+ const boxHeight = Number(boxView.bounds.size.height)
3222
+ return {
3223
+ x: round(Number(converted.origin.x)),
3224
+ y: round(boxHeight - Number(converted.origin.y) - Number(converted.size.height)),
3225
+ w: round(Number(converted.size.width)),
3226
+ h: round(Number(converted.size.height)),
3227
+ }
3228
+ }
3229
+
3230
+ function parityLocalBoxInHost(view: any, rect: any, boxView: any) {
3231
+ const frameOrigin = view.frame.origin
3232
+ return parityBoxInHost(
3233
+ view,
3234
+ {
3235
+ origin: {
3236
+ x: Number(frameOrigin.x) + Number(rect.origin.x),
3237
+ y: Number(frameOrigin.y) + Number(rect.origin.y),
3238
+ },
3239
+ size: rect.size,
3240
+ },
3241
+ boxView,
3242
+ )
3243
+ }
3244
+
3245
+ function textContentBox(node: ElementNode, boxNode: ElementNode) {
3246
+ const view = node.view
3247
+ if (!view || !boxNode.view) {
3248
+ return null
3249
+ }
3250
+
3251
+ if (node.type === 'textfield' && typeof view.cell?.titleRectForBounds === 'function') {
3252
+ return parityLocalBoxInHost(view, view.cell.titleRectForBounds(view.bounds), boxNode.view)
3253
+ }
3254
+
3255
+ if (node.type === 'textview') {
3256
+ const inset = view.textContainerInset ?? { width: 0, height: 0 }
3257
+ const padding = Number(view.textContainer?.lineFragmentPadding ?? 0)
3258
+ const width = Number(view.bounds.size.width) - 2 * Number(inset.width) - 2 * padding
3259
+ const height = Number(view.bounds.size.height) - 2 * Number(inset.height)
3260
+ return parityLocalBoxInHost(
3261
+ view,
3262
+ {
3263
+ origin: { x: Number(inset.width) + padding, y: Number(inset.height) },
3264
+ size: { width: Math.max(0, width), height: Math.max(0, height) },
3265
+ },
3266
+ boxNode.view,
3267
+ )
3268
+ }
3269
+
3270
+ return null
3271
+ }
3272
+
3273
+ function parityNode(node: ElementNode, boxNode: ElementNode, facets: readonly string[]) {
3274
+ const view = node.view
3275
+ let box = null
3276
+ let frameBox = null
3277
+ if (view && boxNode.view) {
3278
+ try {
3279
+ frameBox = parityBoxInHost(view, view.frame, boxNode.view)
3280
+ const alignedRect = view.alignmentRectForFrame?.(view.frame)
3281
+ box = alignedRect ? parityBoxInHost(view, alignedRect, boxNode.view) : frameBox
3282
+ } catch {}
3283
+ }
3284
+
3285
+ let placeholderBox = null
3286
+ let placeholderFrameBox = null
3287
+ if (node.placeholderView && boxNode.view) {
3288
+ try {
3289
+ const placeholderView = node.placeholderView
3290
+ placeholderFrameBox = parityBoxInHost(placeholderView, placeholderView.frame, boxNode.view)
3291
+ const alignedRect = placeholderView.alignmentRectForFrame?.(placeholderView.frame)
3292
+ placeholderBox = alignedRect
3293
+ ? parityBoxInHost(placeholderView, alignedRect, boxNode.view)
3294
+ : placeholderFrameBox
3295
+ } catch {}
3296
+ }
3297
+
3298
+ let text
3299
+ let textLineAdvances
3300
+ if (node.type === 'label') {
3301
+ const value = String(view?.stringValue ?? '')
3302
+ text = value.trim()
3303
+ textLineAdvances = measureTextLineAdvances(value, view?.font)
3304
+ } else if (node.type === 'button') {
3305
+ const value = String(view?.title ?? '')
3306
+ text = value.trim()
3307
+ textLineAdvances = measureTextLineAdvances(value, view?.font)
3308
+ } else if (node.type === 'textfield') {
3309
+ const value = String(view?.stringValue ?? '')
3310
+ text = value.trim()
3311
+ textLineAdvances = measureTextLineAdvances(value, view?.font)
3312
+ } else if (node.type === 'textview') {
3313
+ const value = String(view?.string ?? '')
3314
+ text = value.trim()
3315
+ textLineAdvances = measureTextLineAdvances(value, view?.font)
3316
+ }
3317
+
3318
+ const placeholderAttributedString =
3319
+ node.type === 'textfield' ? view?.placeholderAttributedString : null
3320
+
3321
+ const placeholder =
3322
+ node.type === 'textfield'
3323
+ ? String(placeholderAttributedString?.string ?? view?.placeholderString ?? '').trim()
3324
+ : node.type === 'textview'
3325
+ ? String(node.placeholderView?.stringValue ?? '').trim()
3326
+ : ''
3327
+
3328
+ const placeholderColor =
3329
+ node.type === 'textfield'
3330
+ ? (placeholderAttributedString?.attributeAtIndexEffectiveRange?.(
3331
+ NSForegroundColorAttributeName,
3332
+ 0,
3333
+ null,
3334
+ ) ?? NSColor.placeholderTextColor)
3335
+ : node.type === 'textview'
3336
+ ? node.placeholderView?.textColor
3337
+ : null
3338
+
3339
+ const appearance = view?.effectiveAppearance ?? view?.window?.effectiveAppearance
3340
+ const placeholderStyle = placeholderColor
3341
+ ? {
3342
+ color: colorValue(placeholderColor, appearance),
3343
+ opacity: round(
3344
+ Number(withDrawingAppearance(appearance, () => placeholderColor.alphaComponent)),
3345
+ ),
3346
+ }
3347
+ : undefined
3348
+
3349
+ const contentBox = textContentBox(node, boxNode)
3350
+ const textLineCount = measureTextLineCount(node)
3351
+
3352
+ return {
3353
+ tag: String(node.type ?? 'view').toLowerCase(),
3354
+ id: node.props?.id || undefined,
3355
+ classes: nodeClasses(node),
3356
+ box,
3357
+ frameBox,
3358
+ placeholderBox,
3359
+ placeholderFrameBox,
3360
+ style: parityStyle(node, facets),
3361
+ text: text || undefined,
3362
+ textLineAdvances,
3363
+ placeholder: placeholder || undefined,
3364
+ placeholderStyle,
3365
+ contentBox,
3366
+ textLineCount,
3367
+ }
3368
+ }
3369
+
3370
+ function measureParity(container: RootContainer, facets: readonly string[]) {
3371
+ const stage = [...container.nodes.values()].find((node) => node.props?.id === 'parity-stage')
3372
+ if (!stage?.view) {
3373
+ return null
3374
+ }
3375
+
3376
+ container.hostView.window?.contentView?.layoutSubtreeIfNeeded?.()
3377
+ stage.view.layoutSubtreeIfNeeded?.()
3378
+
3379
+ const cells: Record<string, any> = {}
3380
+ for (const cell of stage.children.filter((node) => nodeClasses(node).includes('parity-cell'))) {
3381
+ const name = String(cell.props?.id ?? '').replace(/^cell-/, '')
3382
+ const box = descendants(cell).find((node) => nodeClasses(node).includes('parity-box'))
3383
+ if (!box?.view) {
3384
+ continue
3385
+ }
3386
+
3387
+ box.view.layoutSubtreeIfNeeded?.()
3388
+ for (const scroll of descendants(box).filter(
3389
+ (node) => node.type === 'scrollview' && node.view,
3390
+ )) {
3391
+ const clip = scroll.view!.contentView
3392
+ const contentDocument = scroll.view!.documentView
3393
+ const y = Math.max(
3394
+ 0,
3395
+ Number(contentDocument.bounds.size.height) - Number(clip.bounds.size.height),
3396
+ )
3397
+
3398
+ clip.scrollToPoint({ x: 0, y })
3399
+ scroll.view!.reflectScrolledClipView(clip)
3400
+ }
3401
+
3402
+ cells[name] = [box, ...descendants(box)]
3403
+ .filter((node) => node.view)
3404
+ .map((node) => parityNode(node, box, facets))
3405
+ }
3406
+
3407
+ return { target: 'macos', cells }
3408
+ }
3409
+
3410
+ // Pointer-intent plumbing: NSTrackingArea-backed hover observation and an
3411
+ // NSPopover surface for anchored hint layers (Tooltip/Hoverable). Reached by
3412
+ // packages/ui macOS leaves through the __xplatAppKit host global — ui never
3413
+ // links macos-node-api directly.
3414
+
3415
+ const hoverRecordsByArea = new Map<object, { enter?: () => void; exit?: () => void }>()
3416
+ const hoverRecordByView = new WeakMap<object, any>()
3417
+ const openPopups = new Set<any>()
3418
+
3419
+ const TRACKING_OPTS = typeof NSTrackingAreaOptions === 'undefined' ? {} : NSTrackingAreaOptions
3420
+ const trackingOpt = (value: number | undefined, fallback: number) =>
3421
+ typeof value === 'number' && Number.isFinite(value) ? value : fallback
3422
+
3423
+ const HOVER_TRACKING_OPTIONS =
3424
+ trackingOpt(TRACKING_OPTS.MouseEnteredAndExited, 0x01) |
3425
+ trackingOpt(TRACKING_OPTS.ActiveAlways, 0x80) |
3426
+ trackingOpt(TRACKING_OPTS.InVisibleRect, 0x200)
3427
+
3428
+ function dispatchHover(area: any, phase: 'enter' | 'exit') {
3429
+ const record = hoverRecordsByArea.get(area)
3430
+ const handler = phase === 'enter' ? record?.enter : record?.exit
3431
+ if (typeof handler !== 'function') {
3432
+ return
3433
+ }
3434
+
3435
+ try {
3436
+ handler()
3437
+ } catch (error) {
3438
+ console.error('[macos-hover] hover handler failed', error)
3439
+ }
3440
+ }
3441
+
3442
+ function observeHover(view: NSView, handlers: { enter?: () => void; exit?: () => void }) {
3443
+ if (
3444
+ !view ||
3445
+ typeof view.addTrackingArea !== 'function' ||
3446
+ typeof NSTrackingArea === 'undefined' ||
3447
+ !hoverTrackingTarget
3448
+ ) {
3449
+ return () => {}
3450
+ }
3451
+
3452
+ unobserveHover(view)
3453
+ const area = NSTrackingArea.alloc().initWithRectOptionsOwnerUserInfo(
3454
+ { origin: { x: 0, y: 0 }, size: { width: 0, height: 0 } },
3455
+ HOVER_TRACKING_OPTIONS,
3456
+ hoverTrackingTarget,
3457
+ null,
3458
+ )
3459
+
3460
+ if (!area) {
3461
+ return () => {}
3462
+ }
3463
+
3464
+ view.addTrackingArea(area)
3465
+ const record = { view, area, enter: handlers?.enter, exit: handlers?.exit }
3466
+ hoverRecordsByArea.set(area, record)
3467
+ hoverRecordByView.set(view, record)
3468
+ return () => unobserveHover(view)
3469
+ }
3470
+
3471
+ function unobserveHover(view: NSView) {
3472
+ const record = hoverRecordByView.get(view)
3473
+ if (!record) {
3474
+ return
3475
+ }
3476
+
3477
+ hoverRecordByView.delete(view)
3478
+ hoverRecordsByArea.delete(record.area)
3479
+ try {
3480
+ view.removeTrackingArea(record.area)
3481
+ } catch (error) {
3482
+ console.error('[macos-hover] failed to remove tracking area', error)
3483
+ }
3484
+ }
3485
+
3486
+ let hoverTrackingTarget: any = null
3487
+ try {
3488
+ class HoverTrackingTarget extends NSObject {
3489
+ static ObjCExposedMethods = {
3490
+ mouseEntered: { params: [NSEvent], returns: interop.types.void },
3491
+ mouseExited: { params: [NSEvent], returns: interop.types.void },
3492
+ }
3493
+
3494
+ static {
3495
+ NativeClass(this)
3496
+ }
3497
+
3498
+ mouseEntered(event: NSEvent) {
3499
+ dispatchHover(event?.trackingArea, 'enter')
3500
+ }
3501
+
3502
+ mouseExited(event: NSEvent) {
3503
+ dispatchHover(event?.trackingArea, 'exit')
3504
+ }
3505
+ }
3506
+
3507
+ hoverTrackingTarget = HoverTrackingTarget.new()
3508
+ } catch (error) {
3509
+ console.error(
3510
+ '[macos-hover] tracking target registration failed — hover affordances disabled',
3511
+ error,
3512
+ )
3513
+ }
3514
+
3515
+ // preferredEdge is the anchor edge the popover attaches to: 'top' places the
3516
+ // panel above the anchor (its MaxY edge). Numeric NSRectEdge values are the
3517
+ // fallback when the bridged enum object is absent.
3518
+ function popoverEdge(placement: string | undefined) {
3519
+ const edges = typeof NSRectEdge === 'object' && NSRectEdge ? NSRectEdge : {}
3520
+ switch (placement) {
3521
+ case 'bottom': {
3522
+ return edges.MinY ?? 1
3523
+ }
3524
+ case 'left': {
3525
+ return edges.MinX ?? 0
3526
+ }
3527
+ case 'right': {
3528
+ return edges.MaxX ?? 2
3529
+ }
3530
+ case 'top':
3531
+ default: {
3532
+ return edges.MaxY ?? 3
3533
+ }
3534
+ }
3535
+ }
3536
+
3537
+ function popupFittingSize(contentView: NSView) {
3538
+ try {
3539
+ const direct = contentView.fittingSize
3540
+ if (direct && direct.width > 0 && direct.height > 0) {
3541
+ return { width: direct.width, height: direct.height }
3542
+ }
3543
+
3544
+ const child = contentView.subviews?.objectAtIndex?.(0)?.fittingSize
3545
+ if (child && child.width > 0 && child.height > 0) {
3546
+ return { width: child.width, height: child.height }
3547
+ }
3548
+ } catch (error) {
3549
+ console.error('[macos-popup] fitting-size measurement failed', error)
3550
+ }
3551
+
3552
+ return null
3553
+ }
3554
+
3555
+ function showAnchoredPopup(options: PopupOptions) {
3556
+ let anchor = options?.anchor
3557
+ if (
3558
+ !anchor ||
3559
+ !anchor.window ||
3560
+ typeof NSPopover === 'undefined' ||
3561
+ typeof NSViewController === 'undefined'
3562
+ ) {
3563
+ return null
3564
+ }
3565
+
3566
+ // `at` anchors the popup to a window point in top-left coordinates instead
3567
+ // of the view's bounds — the fixed-position path for useLayer.
3568
+ const at = options?.at
3569
+ let relativeRect = anchor.bounds
3570
+ if (at && Number.isFinite(at.x) && Number.isFinite(at.y)) {
3571
+ const container = anchor.window?.contentView ?? anchor
3572
+ anchor = container
3573
+ // NSView coordinates are y-up; flip the top-left point.
3574
+ const height = Number(container.frame?.size?.height ?? 0)
3575
+ relativeRect = {
3576
+ origin: { x: Number(at.x), y: height - Number(at.y) },
3577
+ size: { width: 0, height: 0 },
3578
+ }
3579
+ }
3580
+
3581
+ const contentView = NSView.alloc().initWithFrame({
3582
+ origin: { x: 0, y: 0 },
3583
+ size: { width: 1, height: 1 },
3584
+ })
3585
+
3586
+ const root = createMacOSRoot(contentView, { fontFamily: fontFamilyForView(anchor) })
3587
+ const popup = {
3588
+ popover: null as any,
3589
+ contentView,
3590
+ closed: false,
3591
+ closeObserver: null as any,
3592
+ update(props: PropBag) {
3593
+ if (popup.closed) {
3594
+ return
3595
+ }
3596
+
3597
+ root.render(options.component, props)
3598
+ const size = popupFittingSize(contentView)
3599
+ if (size) {
3600
+ popup.popover!.contentSize = size
3601
+ }
3602
+ },
3603
+ close(dismissed = false) {
3604
+ if (popup.closed) {
3605
+ return
3606
+ }
3607
+
3608
+ popup.closed = true
3609
+ openPopups.delete(popup)
3610
+ if (popup.closeObserver) {
3611
+ try {
3612
+ NSNotificationCenter.defaultCenter.removeObserver(popup.closeObserver)
3613
+ } catch {}
3614
+
3615
+ popup.closeObserver = null
3616
+ }
3617
+
3618
+ try {
3619
+ popup.popover?.close()
3620
+ } catch (error) {
3621
+ console.error('[macos-popup] popover close failed', error)
3622
+ }
3623
+
3624
+ try {
3625
+ root.unmount()
3626
+ } catch (error) {
3627
+ console.error('[macos-popup] popup root unmount failed', error)
3628
+ }
3629
+
3630
+ if (dismissed) {
3631
+ try {
3632
+ options.onClose?.()
3633
+ } catch (error) {
3634
+ console.error('[macos-popup] onClose callback failed', error)
3635
+ }
3636
+ }
3637
+ },
3638
+ }
3639
+
3640
+ try {
3641
+ root.render(options.component, options.props ?? {})
3642
+ } catch (error) {
3643
+ console.error('[macos-popup] popup render failed', error)
3644
+ try {
3645
+ root.unmount()
3646
+ } catch {}
3647
+
3648
+ return null
3649
+ }
3650
+
3651
+ const controller = NSViewController.alloc().init()
3652
+ controller.view = contentView
3653
+ const fit = popupFittingSize(contentView)
3654
+ if (fit) {
3655
+ controller.preferredContentSize = fit
3656
+ }
3657
+
3658
+ const popover = NSPopover.alloc().init()
3659
+ popover.contentViewController = controller
3660
+ const behaviors = typeof NSPopoverBehavior === 'undefined' ? {} : NSPopoverBehavior
3661
+ // Transient closes on outside interaction — the AppKit-native feel for a
3662
+ // hint layer. An explicit lightDismiss:false keeps the layer open until
3663
+ // the leaf closes it. The leaf still owns close() for hover-intent
3664
+ // dismissal.
3665
+ popover.behavior =
3666
+ options?.lightDismiss === false
3667
+ ? (behaviors.ApplicationDefined ?? 2)
3668
+ : (behaviors.Transient ?? 1)
3669
+
3670
+ popover.animates = options.animates ?? true
3671
+ popup.popover = popover
3672
+ openPopups.add(popup)
3673
+ popup.closeObserver = NSNotificationCenter.defaultCenter.addObserverForNameObjectQueueUsingBlock(
3674
+ typeof NSPopoverDidCloseNotification === 'undefined'
3675
+ ? 'NSPopoverDidCloseNotification'
3676
+ : NSPopoverDidCloseNotification,
3677
+ popover,
3678
+ null,
3679
+ () => popup.close(true),
3680
+ )
3681
+
3682
+ try {
3683
+ popover.showRelativeToRectOfViewPreferredEdge(
3684
+ relativeRect,
3685
+ anchor,
3686
+ popoverEdge(options.placement),
3687
+ )
3688
+ } catch (error) {
3689
+ console.error('[macos-popup] popover presentation failed', error)
3690
+ popup.close()
3691
+ return null
3692
+ }
3693
+
3694
+ return popup
3695
+ }
3696
+
3697
+ // Leaf-native controls for the @octane-xplat leaf packages (context-menu,
3698
+ // date-picker, sheet). Same convention as the pointer-intent plumbing —
3699
+ // leaves reach AppKit through __xplatAppKit, never macos-node-api directly.
3700
+
3701
+ let menuActionTarget: any = null
3702
+ try {
3703
+ class MenuActionTarget extends NSObject {
3704
+ static ObjCExposedMethods = {
3705
+ menuItemSelected: { params: [NSMenuItem], returns: interop.types.void },
3706
+ }
3707
+
3708
+ static {
3709
+ NativeClass(this)
3710
+ }
3711
+
3712
+ menuItemSelected(sender: NSMenuItem) {
3713
+ invokeAction(sender.tag)
3714
+ }
3715
+ }
3716
+
3717
+ menuActionTarget = MenuActionTarget.new()
3718
+ } catch (error) {
3719
+ console.error('[macos-leaf] menu target registration failed — context menus disabled', error)
3720
+ }
3721
+
3722
+ /** Builds an NSMenu from serialized items and attaches it as `view.menu` —
3723
+ * AppKit presents it on right-click and drives enablement. Returns a
3724
+ * detach. */
3725
+ function attachContextMenu(
3726
+ view: NSView,
3727
+ options: ContextMenuOptions,
3728
+ onSelect: (id: string | undefined) => void,
3729
+ ) {
3730
+ if (
3731
+ !view ||
3732
+ !menuActionTarget ||
3733
+ typeof NSMenu !== 'function' ||
3734
+ typeof NSMenuItem !== 'function'
3735
+ ) {
3736
+ return () => {}
3737
+ }
3738
+
3739
+ const menu = NSMenu.alloc().init()
3740
+ menu.autoenablesItems = false
3741
+ const created: number[] = []
3742
+ for (const item of options?.items ?? []) {
3743
+ if (item?.divider) {
3744
+ menu.addItem(NSMenuItem.separatorItem())
3745
+ continue
3746
+ }
3747
+
3748
+ const menuItem = NSMenuItem.alloc().initWithTitleActionKeyEquivalent(
3749
+ String(item?.title ?? ''),
3750
+ 'menuItemSelected',
3751
+ '',
3752
+ )
3753
+
3754
+ menuItem.target = menuActionTarget
3755
+ if (item?.disabled) {
3756
+ menuItem.enabled = false
3757
+ }
3758
+
3759
+ const actionId = nextActionId++
3760
+ menuItem.tag = actionId
3761
+ const id = item?.id
3762
+ actionHandlers.set(actionId, () => {
3763
+ try {
3764
+ onSelect?.(id)
3765
+ } catch (error) {
3766
+ console.error('[macos-leaf] context-menu selection failed', error)
3767
+ }
3768
+ })
3769
+
3770
+ created.push(actionId)
3771
+ menu.addItem(menuItem)
3772
+ }
3773
+
3774
+ // NSView.menu only answers right-clicks that hit *that* view — assign
3775
+ // the menu across the subtree so trigger children don't swallow them.
3776
+ // Children mounted after attach aren't covered (re-attach on items
3777
+ // change re-walks).
3778
+ const tagged: any[] = []
3779
+ const assign = (hostView: any) => {
3780
+ if (!hostView || typeof hostView !== 'object') {
3781
+ return
3782
+ }
3783
+
3784
+ try {
3785
+ hostView.menu = menu
3786
+ tagged.push(hostView)
3787
+ } catch {}
3788
+
3789
+ const subviews = hostView.subviews
3790
+ const count = typeof subviews?.count === 'function' ? subviews.count() : (subviews?.count ?? 0)
3791
+
3792
+ for (let i = 0; i < count; i++) {
3793
+ assign(subviews.objectAtIndex(i))
3794
+ }
3795
+ }
3796
+
3797
+ assign(view)
3798
+
3799
+ return () => {
3800
+ for (const hostView of tagged) {
3801
+ try {
3802
+ if (hostView.menu === menu) {
3803
+ hostView.menu = null
3804
+ }
3805
+ } catch {}
3806
+ }
3807
+
3808
+ for (const actionId of created) {
3809
+ actionHandlers.delete(actionId)
3810
+ }
3811
+ }
3812
+ }
3813
+
3814
+ const DATE_PICKER_STYLES =
3815
+ typeof NSDatePickerStyle === 'object' && NSDatePickerStyle ? NSDatePickerStyle : {}
3816
+
3817
+ const DATE_PICKER_ELEMENTS =
3818
+ typeof NSDatePickerElementFlags === 'object' && NSDatePickerElementFlags
3819
+ ? NSDatePickerElementFlags
3820
+ : {}
3821
+
3822
+ function dateFromMillis(millis: number) {
3823
+ if (typeof NSDate !== 'function' || !Number.isFinite(millis)) {
3824
+ return null
3825
+ }
3826
+
3827
+ return NSDate.dateWithTimeIntervalSince1970(millis / 1000)
3828
+ }
3829
+
3830
+ /** Embeds a real NSDatePicker inside the leaf's backing view, pinned to its
3831
+ * edges — the leaf gives the host element an explicit size. mode 'date'
3832
+ * uses YearMonthDay (text field by default, `style: 'graphical'` for the
3833
+ * inline calendar); 'time' uses HourMinute with the clock-and-calendar
3834
+ * field. Returns a handle with update/detach. */
3835
+ function attachDatePicker(view: NSView, options: PropBag, onChange: (event: any) => void) {
3836
+ if (
3837
+ !view ||
3838
+ typeof view.addSubview !== 'function' ||
3839
+ typeof NSDatePicker !== 'function' ||
3840
+ !buttonActionTarget
3841
+ ) {
3842
+ return null
3843
+ }
3844
+
3845
+ const isTime = options?.mode === 'time'
3846
+ const picker = NSDatePicker.alloc().init()
3847
+ picker.datePickerElements = isTime
3848
+ ? (DATE_PICKER_ELEMENTS.HourMinute ?? 0x000c)
3849
+ : (DATE_PICKER_ELEMENTS.YearMonthDay ?? 0x00e0)
3850
+
3851
+ picker.datePickerStyle =
3852
+ !isTime && options?.style === 'graphical'
3853
+ ? (DATE_PICKER_STYLES.Graphical ?? 2)
3854
+ : isTime
3855
+ ? (DATE_PICKER_STYLES.ClockAndCalendar ?? 1)
3856
+ : (DATE_PICKER_STYLES.TextField ?? 0)
3857
+
3858
+ const applyRange = (opts: PropBag | undefined) => {
3859
+ const min = dateFromMillis(opts?.minimumMillis)
3860
+ const max = dateFromMillis(opts?.maximumMillis)
3861
+ picker.minDate = min
3862
+ picker.maxDate = max
3863
+ }
3864
+
3865
+ applyRange(options)
3866
+ picker.enabled = options?.enabled !== false
3867
+
3868
+ const initial = dateFromMillis(options?.selectionMillis)
3869
+ if (initial) {
3870
+ picker.dateValue = initial
3871
+ }
3872
+
3873
+ picker.translatesAutoresizingMaskIntoConstraints = false
3874
+ view.addSubview(picker)
3875
+ picker.leadingAnchor.constraintEqualToAnchor(view.leadingAnchor).active = true
3876
+ picker.trailingAnchor.constraintEqualToAnchor(view.trailingAnchor).active = true
3877
+ picker.topAnchor.constraintEqualToAnchor(view.topAnchor).active = true
3878
+ picker.bottomAnchor.constraintEqualToAnchor(view.bottomAnchor).active = true
3879
+
3880
+ const actionId = nextActionId++
3881
+ picker.tag = actionId
3882
+ picker.target = buttonActionTarget
3883
+ picker.action = 'controlChanged'
3884
+ actionHandlers.set(actionId, () => {
3885
+ const date = picker.dateValue
3886
+ const millis =
3887
+ typeof date?.timeIntervalSince1970 === 'number' ? date.timeIntervalSince1970 * 1000 : null
3888
+
3889
+ if (millis === null) {
3890
+ return
3891
+ }
3892
+
3893
+ try {
3894
+ onChange?.(millis)
3895
+ } catch (error) {
3896
+ console.error('[macos-leaf] date-picker change failed', error)
3897
+ }
3898
+ })
3899
+
3900
+ return {
3901
+ update(next: PropBag) {
3902
+ const date = dateFromMillis(next?.selectionMillis)
3903
+ if (date && picker.dateValue?.timeIntervalSince1970 !== date.timeIntervalSince1970) {
3904
+ picker.dateValue = date
3905
+ }
3906
+
3907
+ applyRange(next)
3908
+ picker.enabled = next?.enabled !== false
3909
+ },
3910
+ detach() {
3911
+ picker.target = null
3912
+ picker.action = null
3913
+ actionHandlers.delete(actionId)
3914
+ picker.removeFromSuperview()
3915
+ },
3916
+ }
3917
+ }
3918
+
3919
+ const openSheets = new Set<any>()
3920
+
3921
+ let sheetDelegateTarget: any = null
3922
+ try {
3923
+ class SheetDelegateTarget extends NSObject {
3924
+ static ObjCProtocols = [NSWindowDelegate]
3925
+
3926
+ static ObjCExposedMethods = {
3927
+ windowWillClose: { params: [NSNotification], returns: interop.types.void },
3928
+ windowDidEndSheet: { params: [NSNotification], returns: interop.types.void },
3929
+ }
3930
+
3931
+ static {
3932
+ NativeClass(this)
3933
+ }
3934
+
3935
+ windowWillClose(notification: NSNotification) {
3936
+ finishSheetDismissal(notification?.object)
3937
+ }
3938
+
3939
+ windowDidEndSheet(notification: NSNotification) {
3940
+ finishSheetDismissal(notification?.object)
3941
+ }
3942
+ }
3943
+
3944
+ sheetDelegateTarget = SheetDelegateTarget.new()
3945
+ } catch (error) {
3946
+ console.error(
3947
+ '[macos-leaf] sheet delegate registration failed — window close reporting disabled',
3948
+ error,
3949
+ )
3950
+ }
3951
+
3952
+ function finishSheetDismissal(sheetWindow: any) {
3953
+ for (const entry of openSheets) {
3954
+ if (entry.sheetWindow !== sheetWindow) {
3955
+ continue
3956
+ }
3957
+
3958
+ openSheets.delete(entry)
3959
+ const wasClosed = entry.closed
3960
+ entry.closed = true
3961
+ try {
3962
+ entry.root.unmount()
3963
+ } catch (error) {
3964
+ console.error('[macos-leaf] sheet root unmount failed', error)
3965
+ }
3966
+
3967
+ if (!wasClosed) {
3968
+ try {
3969
+ entry.onDismissed?.()
3970
+ } catch (error) {
3971
+ console.error('[macos-leaf] sheet dismissal failed', error)
3972
+ }
3973
+ }
3974
+ }
3975
+ }
3976
+
3977
+ /** Presents an octane subtree as a window sheet (beginSheet on the leaf's
3978
+ * window) — a plain floating window when no parent window is attached.
3979
+ * Returns { close, update }. */
3980
+ function presentSheet(view: NSView, options: PropBag) {
3981
+ if (
3982
+ typeof (globalThis as any).NSWindow?.alloc !== 'function' ||
3983
+ typeof (globalThis as any).NSViewController?.alloc !== 'function' ||
3984
+ typeof options?.component !== 'function'
3985
+ ) {
3986
+ return null
3987
+ }
3988
+
3989
+ const parentWindow = view?.window ?? null
3990
+ const contentView = NSView.alloc().initWithFrame({
3991
+ origin: { x: 0, y: 0 },
3992
+ size: { width: 480, height: 1 },
3993
+ })
3994
+
3995
+ const root = createMacOSRoot(contentView, { fontFamily: fontFamilyForView(view) })
3996
+ const entry = {
3997
+ root,
3998
+ parentWindow,
3999
+ sheetWindow: null as any,
4000
+ closed: false,
4001
+ onDismissed: options?.onDismissed,
4002
+ }
4003
+
4004
+ const close = () => {
4005
+ if (entry.closed) {
4006
+ return
4007
+ }
4008
+
4009
+ entry.closed = true
4010
+ openSheets.delete(entry)
4011
+ try {
4012
+ if (parentWindow && entry.sheetWindow) {
4013
+ parentWindow.endSheet(entry.sheetWindow)
4014
+ } else if (entry.sheetWindow) {
4015
+ entry.sheetWindow.orderOut(null)
4016
+ }
4017
+ } catch (error) {
4018
+ console.error('[macos-leaf] sheet close failed', error)
4019
+ }
4020
+
4021
+ try {
4022
+ root.unmount()
4023
+ } catch (error) {
4024
+ console.error('[macos-leaf] sheet root unmount failed', error)
4025
+ }
4026
+ }
4027
+
4028
+ try {
4029
+ root.render(options.component, options.props ?? {})
4030
+ } catch (error) {
4031
+ console.error('[macos-leaf] sheet render failed', error)
4032
+ try {
4033
+ root.unmount()
4034
+ } catch {}
4035
+
4036
+ return null
4037
+ }
4038
+
4039
+ const controller = NSViewController.alloc().init()
4040
+ controller.view = contentView
4041
+ const fit = popupFittingSize(contentView)
4042
+ const width = Math.max(fit?.width ?? 0, options?.minWidth ?? 360)
4043
+ const height = Math.max(fit?.height ?? 0, options?.minHeight ?? 160)
4044
+
4045
+ const SM = typeof NSWindowStyleMask === 'object' && NSWindowStyleMask ? NSWindowStyleMask : {}
4046
+ const BT = typeof NSBackingStoreType === 'object' && NSBackingStoreType ? NSBackingStoreType : {}
4047
+ const sheet = NSWindow.alloc().initWithContentRectStyleMaskBackingDefer(
4048
+ { origin: { x: 0, y: 0 }, size: { width, height } },
4049
+ (SM.Titled ?? 1) | (SM.Closable ?? 2),
4050
+ BT.Buffered ?? 2,
4051
+ false,
4052
+ )
4053
+
4054
+ sheet.contentViewController = controller
4055
+ sheet.releasedWhenClosed = false
4056
+ if (sheetDelegateTarget) {
4057
+ sheet.delegate = sheetDelegateTarget
4058
+ }
4059
+
4060
+ entry.sheetWindow = sheet
4061
+ openSheets.add(entry)
4062
+
4063
+ try {
4064
+ if (parentWindow && typeof parentWindow.beginSheetCompletionHandler === 'function') {
4065
+ parentWindow.beginSheetCompletionHandler(sheet, () => finishSheetDismissal(sheet))
4066
+ } else {
4067
+ sheet.center?.()
4068
+ sheet.makeKeyAndOrderFront(null)
4069
+ }
4070
+ } catch (error) {
4071
+ console.error('[macos-leaf] sheet presentation failed', error)
4072
+ close()
4073
+ return null
4074
+ }
4075
+
4076
+ return {
4077
+ close,
4078
+ update(nextProps: PropBag) {
4079
+ try {
4080
+ root.render(options.component, nextProps ?? {})
4081
+ } catch (error) {
4082
+ console.error('[macos-leaf] sheet update failed', error)
4083
+ }
4084
+ },
4085
+ }
4086
+ }
4087
+
4088
+ const appKitBridge = (globalThis.__xplatAppKit ??= {})
4089
+ appKitBridge.observeHover = observeHover
4090
+ appKitBridge.showAnchoredPopup = showAnchoredPopup
4091
+ appKitBridge.showLayer = (options: PropBag) =>
4092
+ showWindowLayer(options, {
4093
+ createRoot: (view: NSView, anchor: NSView) =>
4094
+ createMacOSRoot(view, { fontFamily: fontFamilyForView(anchor) }),
4095
+ fittingSize: popupFittingSize,
4096
+ })
4097
+
4098
+ appKitBridge.attachContextMenu = attachContextMenu
4099
+ appKitBridge.attachDatePicker = attachDatePicker
4100
+ appKitBridge.presentSheet = presentSheet
4101
+ installPresentationBridge(appKitBridge, createMacOSRoot, fontFamilyForView)
4102
+
4103
+ const rootFontFamilies = new WeakMap<object, string | undefined>()
4104
+
4105
+ function fontFamilyForView(view: NSView | undefined): string | undefined {
4106
+ for (let current = view; current; current = current.superview) {
4107
+ if (rootFontFamilies.has(current)) {
4108
+ return rootFontFamilies.get(current)
4109
+ }
4110
+ }
4111
+
4112
+ return undefined
4113
+ }
4114
+
4115
+ export interface MacOSRootOptions {
4116
+ /** Default font family for this root and its renderer-hosted overlays. */
4117
+ fontFamily?: string
4118
+ }
4119
+
4120
+ export function createMacOSRoot(hostView: NSView, { fontFamily }: MacOSRootOptions = {}) {
4121
+ rootFontFamilies.set(hostView, fontFamily)
4122
+ const container: RootContainer = {
4123
+ hostView,
4124
+ fontFamily,
4125
+ nodes: new Map(),
4126
+ children: [],
4127
+ root: null,
4128
+ }
4129
+
4130
+ const root = createUniversalRoot(container, macOSDriver, {
4131
+ scheduleMicrotask: (callback) => queueMicrotask(callback),
4132
+ onUncaughtError: (error: any) =>
4133
+ console.error(
4134
+ '[macos-runtime] uncaught render error name=' +
4135
+ (error && error.name) +
4136
+ ' message=' +
4137
+ (error && error.message) +
4138
+ ' value=' +
4139
+ (typeof error === 'object' ? JSON.stringify(error) : String(error)) +
4140
+ ' stack=' +
4141
+ (error && error.stack),
4142
+ ),
4143
+ })
4144
+
4145
+ container.root = root
4146
+ if (process.env.OCTANE_MACOS_AUTOMATION === '1') {
4147
+ const debug = {
4148
+ findId(id: string) {
4149
+ return [...container.nodes.values()].find((node) => node.props.id === id)?.view ?? null
4150
+ },
4151
+ measureParity(facets: readonly string[]) {
4152
+ return measureParity(container, facets)
4153
+ },
4154
+ metrics() {
4155
+ const nodeTypes: Record<string, number> = {}
4156
+ let nativeViewCount = 0
4157
+ let mountedRowCount = 0
4158
+ let mappedRowCount = 0
4159
+ let parentedRowCount = 0
4160
+ let nativeAttachedRowCount = 0
4161
+ let firstMountedRow = Number.POSITIVE_INFINITY
4162
+ let lastMountedRow = -1
4163
+ let firstMappedRow = Number.POSITIVE_INFINITY
4164
+ let lastMappedRow = -1
4165
+ const mountedRows: { index: number; height: number }[] = []
4166
+ const scrollViews: PropBag[] = []
4167
+ for (const node of container.nodes.values()) {
4168
+ nodeTypes[node.type] = (nodeTypes[node.type] ?? 0) + 1
4169
+ if (node.view) {
4170
+ nativeViewCount += 1
4171
+ }
4172
+
4173
+ if (node.type === 'scrollview' && node.view) {
4174
+ const bounds = node.view.contentView.bounds
4175
+ scrollViews.push({
4176
+ id: node.props.id ?? null,
4177
+ verticalOffset: Number(bounds.origin.y ?? 0),
4178
+ viewportHeight: Number(bounds.size.height ?? 0),
4179
+ contentHeight: Number(node.view.documentView?.frame?.size?.height ?? 0),
4180
+ })
4181
+ }
4182
+
4183
+ const match = /^(?:row-r|bench-row-)(\d+)$/.exec(String(node.props.id ?? ''))
4184
+ if (!match) {
4185
+ continue
4186
+ }
4187
+
4188
+ mappedRowCount += 1
4189
+ const index = Number(match[1])
4190
+ firstMappedRow = Math.min(firstMappedRow, index)
4191
+ lastMappedRow = Math.max(lastMappedRow, index)
4192
+ if (node.parent !== null) {
4193
+ parentedRowCount += 1
4194
+ }
4195
+
4196
+ if (node.view?.superview != null) {
4197
+ nativeAttachedRowCount += 1
4198
+ }
4199
+
4200
+ if (node.parent !== null && node.view?.superview != null) {
4201
+ mountedRowCount += 1
4202
+ firstMountedRow = Math.min(firstMountedRow, index)
4203
+ lastMountedRow = Math.max(lastMountedRow, index)
4204
+ mountedRows.push({
4205
+ index,
4206
+ height: Number(node.view.frame?.size?.height ?? 0),
4207
+ })
4208
+ }
4209
+ }
4210
+
4211
+ return {
4212
+ nodeCount: container.nodes.size,
4213
+ nativeViewCount,
4214
+ mountedRowCount,
4215
+ mappedRowCount,
4216
+ parentedRowCount,
4217
+ nativeAttachedRowCount,
4218
+ firstMountedRow: Number.isFinite(firstMountedRow) ? firstMountedRow : null,
4219
+ lastMountedRow: lastMountedRow >= 0 ? lastMountedRow : null,
4220
+ firstMappedRow: Number.isFinite(firstMappedRow) ? firstMappedRow : null,
4221
+ lastMappedRow: lastMappedRow >= 0 ? lastMappedRow : null,
4222
+ mountedRows,
4223
+ scrollViews,
4224
+ nodeTypes,
4225
+ }
4226
+ },
4227
+ scrollToId(id: string, offset: number) {
4228
+ const node = [...container.nodes.values()].find(
4229
+ (candidate) => candidate.type === 'scrollview' && candidate.props.id === id,
4230
+ )
4231
+
4232
+ if (!node) {
4233
+ throw new Error('No AppKit ScrollView with id ' + id)
4234
+ }
4235
+
4236
+ const clipView = node.view!.contentView
4237
+ clipView.scrollToPoint({ x: 0, y: Math.max(0, Number(offset) || 0) })
4238
+ node.view!.reflectScrolledClipView(clipView)
4239
+ return Number(clipView.bounds.origin.y ?? 0)
4240
+ },
4241
+ /** Web-style scroll offset: `top` is the distance from the
4242
+ * document's visual top (scrollTop semantics). Non-flipped
4243
+ * AppKit documents count clip origin y up from the bottom, so
4244
+ * the conversion inverts through the doc/clip heights. */
4245
+ scrollToTop(id: string, top: number) {
4246
+ const node = [...container.nodes.values()].find(
4247
+ (candidate) => candidate.type === 'scrollview' && candidate.props.id === id,
4248
+ )
4249
+
4250
+ if (!node) {
4251
+ throw new Error('No AppKit ScrollView with id ' + id)
4252
+ }
4253
+
4254
+ const clipView = node.view!.contentView
4255
+ const doc = node.view!.documentView
4256
+ const docH = Number(doc.frame.size.height)
4257
+ const clipH = Number(clipView.bounds.size.height)
4258
+ const flip = typeof doc.isFlipped === 'function' ? doc.isFlipped() : doc.isFlipped
4259
+ const flipped = flip === true || Number(flip) === 1
4260
+ const t = Math.min(Math.max(Number(top) || 0, 0), Math.max(0, docH - clipH))
4261
+ const y = flipped ? t : docH - clipH - t
4262
+ clipView.scrollToPoint({ x: 0, y })
4263
+ node.view!.reflectScrolledClipView(clipView)
4264
+ return flipped
4265
+ ? Number(clipView.bounds.origin.y)
4266
+ : docH - clipH - Number(clipView.bounds.origin.y)
4267
+ },
4268
+ /** VirtualListBenchSnapshot for the scrollview with `id`: scrollTop-
4269
+ * style offset plus each mounted vlist-bench-row-* row's top-down
4270
+ * box relative to the viewport. Consumed by the app-side
4271
+ * platform/virtual-list-benchmark.macos adapter. */
4272
+ listSnapshot(id: string) {
4273
+ const node = [...container.nodes.values()].find(
4274
+ (candidate) => candidate.type === 'scrollview' && candidate.props.id === id,
4275
+ )
4276
+
4277
+ if (!node) {
4278
+ throw new Error('No AppKit ScrollView with id ' + id)
4279
+ }
4280
+
4281
+ const clipView = node.view!.contentView
4282
+ const doc = node.view!.documentView
4283
+ const clipH = Number(clipView.bounds.size.height)
4284
+ const docH = Number(doc?.frame?.size?.height ?? 0)
4285
+ const flip = typeof doc?.isFlipped === 'function' ? doc.isFlipped() : doc?.isFlipped
4286
+ const flipped = flip === true || Number(flip) === 1
4287
+ const originY = Number(clipView.bounds.origin.y ?? 0)
4288
+ // The VirtualList leaf publishes a content-space offset (anchored
4289
+ // to its top spacer, not the document frame) — prefer it since
4290
+ // docH-derived offsets drift every time estimates rebuild.
4291
+ const published = globalThis.__xplatVlistOffsets?.[id]
4292
+ const offset =
4293
+ typeof published === 'number' ? published : flipped ? originY : docH - clipH - originY
4294
+
4295
+ const rows = descendants(node).flatMap((child) => {
4296
+ const match = /^vlist-bench-row-(\d+)$/.exec(String(child.props?.id ?? ''))
4297
+ if (!match || !child.view) {
4298
+ return []
4299
+ }
4300
+
4301
+ try {
4302
+ // Row box relative to the clip's top edge, computed in doc
4303
+ // coordinates so it stays docH-independent like `offset`.
4304
+ const inDoc = child.view.superview.convertRectToView(child.view.frame, doc)
4305
+ const h = Number(inDoc.size.height)
4306
+ const y = Number(inDoc.origin.y)
4307
+ const top = flipped ? y - originY : originY + clipH - y - h
4308
+ return [{ index: Number(match[1]), top, bottom: top + h }]
4309
+ } catch {
4310
+ return []
4311
+ }
4312
+ })
4313
+
4314
+ return {
4315
+ offset,
4316
+ viewportHeight: clipH,
4317
+ rows,
4318
+ mountedIndices: rows.map((row) => row.index),
4319
+ }
4320
+ },
4321
+ /** Node frame in window base coordinates (points, y-up from the
4322
+ * window's bottom edge) — screenshot cropping consumes it. */
4323
+ frameInWindow(id: string) {
4324
+ if (id === ':host') {
4325
+ const winFrame = container.hostView.window?.frame
4326
+ return {
4327
+ x: 0,
4328
+ y: 0,
4329
+ w: Number(container.hostView.frame.size.width),
4330
+ h: Number(container.hostView.frame.size.height),
4331
+ isContentView: container.hostView.window?.contentView === container.hostView,
4332
+ autoresizingMask: Number(container.hostView.autoresizingMask ?? -1),
4333
+ translatesMask: Boolean(container.hostView.translatesAutoresizingMaskIntoConstraints),
4334
+ subviews: Number(container.hostView.subviews?.length ?? 0),
4335
+ window: winFrame
4336
+ ? {
4337
+ w: Number(winFrame.size.width),
4338
+ h: Number(winFrame.size.height),
4339
+ }
4340
+ : null,
4341
+ }
4342
+ }
4343
+
4344
+ const node = [...container.nodes.values()].find(
4345
+ (candidate) => candidate.props.id === id && candidate.view,
4346
+ )
4347
+
4348
+ if (!node) {
4349
+ throw new Error('No AppKit view with id ' + id)
4350
+ }
4351
+
4352
+ const rect = node.view!.convertRectToView(node.view!.bounds, null)
4353
+ return {
4354
+ x: Number(rect.origin.x),
4355
+ y: Number(rect.origin.y),
4356
+ w: Number(rect.size.width),
4357
+ h: Number(rect.size.height),
4358
+ }
4359
+ },
4360
+ /** Apply a single style prop on a mounted node — margin/layout probes. */
4361
+ setStyle(id: string, name: string, value: any) {
4362
+ const node = [...container.nodes.values()].find((candidate) => candidate.props.id === id)
4363
+
4364
+ if (!node) {
4365
+ throw new Error('No AppKit node with id ' + id)
4366
+ }
4367
+
4368
+ applyStyle(node, { [name]: value })
4369
+ container.hostView.layoutSubtreeIfNeeded?.()
4370
+ return this.frameInWindow(id)
4371
+ },
4372
+ /** Ancestor chain for a node id with frames — layout forensics. */
4373
+ ancestors(id: string) {
4374
+ const node = [...container.nodes.values()].find((candidate) => candidate.props.id === id)
4375
+
4376
+ if (!node) {
4377
+ throw new Error('No AppKit node with id ' + id)
4378
+ }
4379
+
4380
+ const chain: PropBag[] = []
4381
+ for (let cur: ElementNode | null = node; cur; cur = cur.parent) {
4382
+ chain.push({
4383
+ id: cur.props.id ?? null,
4384
+ type: cur.type,
4385
+ classes: nodeClasses(cur),
4386
+ frame: cur.view
4387
+ ? {
4388
+ x: Number(cur.view.frame.origin.x),
4389
+ y: Number(cur.view.frame.origin.y),
4390
+ w: Number(cur.view.frame.size.width),
4391
+ h: Number(cur.view.frame.size.height),
4392
+ }
4393
+ : null,
4394
+ })
4395
+ }
4396
+
4397
+ return chain
4398
+ },
4399
+ /** Per-cell geometry for the parity stage inside its ScrollView:
4400
+ * top-down document offsets plus the window-space rect at the
4401
+ * current scroll position, so a screenshot pass can choose
4402
+ * offsets and crop without re-querying the host. */
4403
+ parityCellFrames(scrollId: string) {
4404
+ const scroll = [...container.nodes.values()].find(
4405
+ (candidate) => candidate.type === 'scrollview' && candidate.props.id === scrollId,
4406
+ )
4407
+
4408
+ if (!scroll) {
4409
+ throw new Error('No AppKit ScrollView with id ' + scrollId)
4410
+ }
4411
+
4412
+ const stage = [...container.nodes.values()].find(
4413
+ (candidate) => candidate.props.id === 'parity-stage',
4414
+ )
4415
+
4416
+ if (!stage) {
4417
+ throw new Error('No parity stage mounted')
4418
+ }
4419
+
4420
+ container.hostView.window?.contentView?.layoutSubtreeIfNeeded?.()
4421
+ scroll.view!.layoutSubtreeIfNeeded?.()
4422
+ const doc = scroll.view!.documentView
4423
+ const clip = scroll.view!.contentView
4424
+ const docH = Number(doc.frame.size.height)
4425
+ const clipH = Number(clip.bounds.size.height)
4426
+ const flip = typeof doc.isFlipped === 'function' ? doc.isFlipped() : doc.isFlipped
4427
+ const flipped = flip === true || Number(flip) === 1
4428
+ const cells = []
4429
+ for (const cell of stage.children) {
4430
+ if (!cell.view || !nodeClasses(cell).includes('parity-cell')) {
4431
+ continue
4432
+ }
4433
+
4434
+ const inDoc = cell.view.convertRectToView(cell.view.bounds, doc)
4435
+ const inWindow = cell.view.convertRectToView(cell.view.bounds, null)
4436
+ cells.push({
4437
+ name: String(cell.props.id ?? '').replace(/^cell-/, ''),
4438
+ top: flipped
4439
+ ? Number(inDoc.origin.y)
4440
+ : docH - Number(inDoc.origin.y) - Number(inDoc.size.height),
4441
+ height: Number(inDoc.size.height),
4442
+ width: Number(inDoc.size.width),
4443
+ x: Number(inDoc.origin.x),
4444
+ window: {
4445
+ x: Number(inWindow.origin.x),
4446
+ y: Number(inWindow.origin.y),
4447
+ w: Number(inWindow.size.width),
4448
+ h: Number(inWindow.size.height),
4449
+ },
4450
+ })
4451
+ }
4452
+
4453
+ const scrollInWindow = scroll.view!.convertRectToView(scroll.view!.bounds, null)
4454
+ return {
4455
+ docHeight: docH,
4456
+ viewportHeight: clipH,
4457
+ docFlipped: flipped,
4458
+ scrollTop: flipped
4459
+ ? Number(clip.bounds.origin.y)
4460
+ : docH - clipH - Number(clip.bounds.origin.y),
4461
+ scrollWindow: {
4462
+ x: Number(scrollInWindow.origin.x),
4463
+ y: Number(scrollInWindow.origin.y),
4464
+ w: Number(scrollInWindow.size.width),
4465
+ h: Number(scrollInWindow.size.height),
4466
+ },
4467
+ cells,
4468
+ }
4469
+ },
4470
+ scrollStats(id: string) {
4471
+ const node = [...container.nodes.values()].find(
4472
+ (candidate) => candidate.type === 'scrollview' && candidate.props.id === id,
4473
+ )
4474
+
4475
+ if (!node) {
4476
+ throw new Error('No AppKit ScrollView with id ' + id)
4477
+ }
4478
+
4479
+ return { ...(node.scrollMetrics ?? { events: [] }) }
4480
+ },
4481
+ inspect(id: string) {
4482
+ const node = id.startsWith('type:')
4483
+ ? [...container.nodes.values()].find(
4484
+ (candidate) => candidate.type === id.slice(5) && candidate.view,
4485
+ )
4486
+ : [...container.nodes.values()].find((candidate) => candidate.props?.id === id)
4487
+
4488
+ if (!node?.view) {
4489
+ throw new Error('No AppKit view with id ' + id)
4490
+ }
4491
+
4492
+ const view = node.view
4493
+ const appearance = view.effectiveAppearance ?? view.window?.effectiveAppearance
4494
+ const nativeWindow = view.window
4495
+ return {
4496
+ type: node.type,
4497
+ classes: nodeClasses(node),
4498
+ scheme: node.scheme ?? 'light',
4499
+ color: view.textColor ? (colorValue(view.textColor, appearance) ?? null) : null,
4500
+ backgroundColor: view.layer?.backgroundColor
4501
+ ? (colorValue(NSColor.colorWithCGColor(view.layer.backgroundColor), appearance) ?? null)
4502
+ : null,
4503
+ frame: view.frame
4504
+ ? {
4505
+ x: round(Number(view.frame.origin.x)),
4506
+ y: round(Number(view.frame.origin.y)),
4507
+ w: round(Number(view.frame.size.width)),
4508
+ h: round(Number(view.frame.size.height)),
4509
+ }
4510
+ : null,
4511
+ window: nativeWindow
4512
+ ? {
4513
+ frame: {
4514
+ x: round(Number(nativeWindow.frame.origin.x)),
4515
+ y: round(Number(nativeWindow.frame.origin.y)),
4516
+ w: round(Number(nativeWindow.frame.size.width)),
4517
+ h: round(Number(nativeWindow.frame.size.height)),
4518
+ },
4519
+ contentSize: nativeWindow.contentView?.frame?.size
4520
+ ? {
4521
+ w: round(Number(nativeWindow.contentView.frame.size.width)),
4522
+ h: round(Number(nativeWindow.contentView.frame.size.height)),
4523
+ }
4524
+ : null,
4525
+ fittingSize: nativeWindow.contentView?.fittingSize
4526
+ ? {
4527
+ w: round(Number(nativeWindow.contentView.fittingSize.width)),
4528
+ h: round(Number(nativeWindow.contentView.fittingSize.height)),
4529
+ }
4530
+ : null,
4531
+ styleMask: Number(nativeWindow.styleMask ?? 0),
4532
+ colorScheme: globalThis.__xplatAppKit?.getColorScheme?.() ?? null,
4533
+ }
4534
+ : null,
4535
+ }
4536
+ },
4537
+ snapshot() {
4538
+ return {
4539
+ labels: [...container.nodes.values()]
4540
+ .filter((node) => node.type === 'label' && node.view)
4541
+ .map((node) => node.view!.stringValue),
4542
+ buttons: [...container.nodes.values()]
4543
+ .filter((node) => node.type === 'button' && node.view)
4544
+ .map((node) => node.view!.title),
4545
+ pressables: [...container.nodes.values()]
4546
+ .filter(
4547
+ (node) =>
4548
+ node.type === 'flexboxlayout' &&
4549
+ node.actionId !== undefined &&
4550
+ accessibilityRoles.get(node.actionId!) === 'button',
4551
+ )
4552
+ .map((node) => accessibilityLabels.get(node.actionId!)),
4553
+ }
4554
+ },
4555
+ /** Dispatch through the same event scope as the AppKit pan recognizer. */
4556
+ panView(view: any, event: PanEvent) {
4557
+ const handler = panHandlersByView.get(view)
4558
+ if (!handler) {
4559
+ throw new Error('No AppKit pan handler attached')
4560
+ }
4561
+
4562
+ handler({ ...event, view })
4563
+ },
4564
+ pressId(id: string) {
4565
+ const node = [...container.nodes.values()].find((candidate) => candidate.props.id === id)
4566
+ if (!node || node.actionId === undefined) {
4567
+ throw new Error('No AppKit pressable with id ' + id)
4568
+ }
4569
+
4570
+ invokeAction(node.actionId)
4571
+ },
4572
+ setText(idOrPlaceholder: string, value: any) {
4573
+ const node = [...container.nodes.values()].find(
4574
+ (candidate) =>
4575
+ (candidate.type === 'textfield' || candidate.type === 'textview') &&
4576
+ (candidate.props.id === idOrPlaceholder ||
4577
+ candidate.props.placeholder === idOrPlaceholder),
4578
+ )
4579
+
4580
+ if (!node || node.actionId === undefined) {
4581
+ throw new Error('No AppKit text input with id or placeholder ' + idOrPlaceholder)
4582
+ }
4583
+
4584
+ if (node.type === 'textview') {
4585
+ node.view!.string = String(value)
4586
+ buttonActionTarget.textDidChange({ object: node.view } as NSNotification)
4587
+ } else {
4588
+ node.view!.stringValue = String(value)
4589
+ buttonActionTarget.controlChanged(node.view!)
4590
+ }
4591
+ },
4592
+ pressAccessibilityLabel(label: string) {
4593
+ const node = [...container.nodes.values()].find(
4594
+ (candidate) =>
4595
+ candidate.type === 'flexboxlayout' &&
4596
+ candidate.actionId !== undefined &&
4597
+ accessibilityLabels.get(candidate.actionId) === label,
4598
+ )
4599
+
4600
+ if (!node) {
4601
+ throw new Error('No AppKit pressable labeled ' + label)
4602
+ }
4603
+
4604
+ if (!node.view!.accessibilityPerformPress()) {
4605
+ throw new Error('AppKit pressable has no action for ' + label)
4606
+ }
4607
+ },
4608
+ pressButton(title: string) {
4609
+ const node = [...container.nodes.values()].find(
4610
+ (candidate) => candidate.type === 'button' && candidate.view!.title === title,
4611
+ )
4612
+
4613
+ if (!node) {
4614
+ throw new Error('No AppKit button titled ' + title)
4615
+ }
4616
+
4617
+ node.view!.performClick(null)
4618
+ },
4619
+ hover(id: string, phase: 'enter' | 'exit') {
4620
+ const node = [...container.nodes.values()].find(
4621
+ (candidate) => candidate.props?.id === id && candidate.view,
4622
+ )
4623
+
4624
+ if (!node) {
4625
+ throw new Error('No AppKit view with id ' + id)
4626
+ }
4627
+
4628
+ const record = hoverRecordByView.get(node.view!)
4629
+ if (!record) {
4630
+ throw new Error('No hover observer on ' + id)
4631
+ }
4632
+
4633
+ const handler = phase === 'exit' ? record.exit : record.enter
4634
+ if (typeof handler !== 'function') {
4635
+ throw new Error('No ' + phase + ' handler on ' + id)
4636
+ }
4637
+
4638
+ handler()
4639
+ },
4640
+ openPopupCount() {
4641
+ return openPopups.size
4642
+ },
4643
+ }
4644
+
4645
+ Object.defineProperty(root, '__macosDebug', { value: debug })
4646
+ Object.defineProperty(globalThis, '__xplatMacOSDebug', { value: debug, configurable: true })
4647
+ }
4648
+
4649
+ return root
4650
+ }
4651
+
4652
+ export * from 'octane/universal/native'