@octane-xplat/macos-renderer 0.0.1 → 0.11.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,4654 @@
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
+ // NSImageScaling: 0 ProportionallyDown, 1 AxesIndependently,
1856
+ // 2 None (centered, unscaled), 3 ProportionallyUpOrDown.
1857
+ // AppKit has no cover mode — 'cover' degrades to scale-down.
1858
+ node.view!.imageScaling = { contain: 3, 'scale-down': 0, fill: 1, none: 2 }[value] ?? 0
1859
+ } else if (name === 'fontSize' && ['label', 'textfield', 'textview'].includes(node.type)) {
1860
+ const weight = style.fontWeight ?? node.appliedFontWeight ?? 400
1861
+ node.appliedFontWeight = String(weight)
1862
+ node.view!.font = fontForFamilyStyle(
1863
+ value,
1864
+ weight,
1865
+ style.fontFamily ?? node.appliedFontFamily,
1866
+ )
1867
+ } else if (name === 'fontFamily' && ['label', 'textfield', 'textview'].includes(node.type)) {
1868
+ node.appliedFontFamily = String(value)
1869
+ const size = style.fontSize ?? node.view!.font.pointSize
1870
+ const weight = style.fontWeight ?? node.appliedFontWeight ?? 400
1871
+ node.view!.font = fontForFamilyStyle(size, weight, value)
1872
+ } else if (name === 'color' && ['label', 'textfield', 'textview'].includes(node.type)) {
1873
+ node.styleColor = String(value)
1874
+ node.view!.textColor = nativeColor(value)
1875
+ } else if (name === 'lineHeight' && node.type === 'label') {
1876
+ // syncText applies this to each paragraph; it is not the label's fixed height.
1877
+ continue
1878
+ } else if (name === 'padding' && node.type === 'flexboxlayout') {
1879
+ node.view!.edgeInsets = parseEdgeInsets(value)
1880
+ } else if (EDGE_INSET_PROPS.has(name) && node.type === 'flexboxlayout') {
1881
+ const insets = { ...node.view!.edgeInsets }
1882
+ for (const edge of EDGE_INSET_PROPS.get(name)!) {
1883
+ insets[edge] = Number(value) || 0
1884
+ }
1885
+
1886
+ node.view!.edgeInsets = insets
1887
+ } else if (name === 'backgroundColor' && node.view) {
1888
+ node.styleBg = String(value)
1889
+ node.view.wantsLayer = true
1890
+ node.view.layer.backgroundColor = nativeColor(value).CGColor
1891
+ } else if (name === 'borderRadius' && node.view) {
1892
+ node.view.wantsLayer = true
1893
+ node.view.layer.cornerRadius = Number(value)
1894
+ node.view.layer.masksToBounds = true
1895
+ } else if ((name === 'width' || name === 'height') && node.view) {
1896
+ setSizeConstraint(node, name, value)
1897
+ } else if (name === 'left' && node.view) {
1898
+ if (node.parent?.type === 'gridlayout') {
1899
+ layoutGridChildren(node.parent)
1900
+ }
1901
+
1902
+ if (node.parent?.type === 'absolutelayout') {
1903
+ layoutAbsoluteChildren(node.parent)
1904
+ }
1905
+ } else if ((name === 'translateX' || name === 'translateY' || name === 'zIndex') && node.view) {
1906
+ node.view.wantsLayer = true
1907
+ const layer = node.view.layer
1908
+ if (name === 'zIndex') {
1909
+ layer.zPosition = Number(value) || 0
1910
+ } else {
1911
+ // Core Animation layers use y-up coordinates; public drag deltas use y-down.
1912
+ layer.setValueForKeyPath(
1913
+ (Number(value) || 0) * (name === 'translateY' ? -1 : 1),
1914
+ name === 'translateY' ? 'transform.translation.y' : 'transform.translation.x',
1915
+ )
1916
+ }
1917
+ } else if (name === 'opacity' && node.view) {
1918
+ node.view.alphaValue = Number(value)
1919
+ } else if (name === 'fontWeight' && ['label', 'textfield', 'textview'].includes(node.type)) {
1920
+ node.appliedFontWeight = String(value)
1921
+ node.view!.font = fontForFamilyStyle(
1922
+ node.view!.font.pointSize,
1923
+ value,
1924
+ style.fontFamily ?? node.appliedFontFamily,
1925
+ )
1926
+ } else if (name === 'textAlign' && node.type === 'label') {
1927
+ node.view!.alignment =
1928
+ value === 'left'
1929
+ ? NSTextAlignment.Left
1930
+ : value === 'right'
1931
+ ? NSTextAlignment.Right
1932
+ : NSTextAlignment.Center
1933
+ } else if (name === 'borderWidth' && node.view) {
1934
+ node.view.wantsLayer = true
1935
+ node.view.layer.borderWidth = Number(value)
1936
+ } else if (name === 'borderColor' && node.view) {
1937
+ node.view.wantsLayer = true
1938
+ node.view.layer.borderColor = nativeColor(value).CGColor
1939
+ } else if (MARGIN_SIDES[name] || name === 'margin') {
1940
+ setMarginStyle(node, name, value)
1941
+ } else {
1942
+ console.warn('[macos-style] ignored unsupported style.' + name + ' on <' + node.type + '>')
1943
+ }
1944
+ }
1945
+ }
1946
+
1947
+ function applyClassName(node: ElementNode, value: any) {
1948
+ const classes = String(value ?? '')
1949
+ .split(/\s+/)
1950
+ .filter(Boolean)
1951
+
1952
+ if (node.type === 'label') {
1953
+ if (classes.includes('vx-sheet-grabber')) {
1954
+ node.classLineHeight = 24
1955
+ setSizeConstraint(node, 'height', 24)
1956
+ node.view!.alignment = NSTextAlignment.Center
1957
+ }
1958
+
1959
+ const sizes: Record<string, number> = {
1960
+ 'text-xs': 12,
1961
+ 'text-sm': 13,
1962
+ 'text-base': 16,
1963
+ 'text-lg': 18,
1964
+ 'text-xl': 20,
1965
+ 'text-2xl': 28,
1966
+ }
1967
+
1968
+ const lineHeights: Record<string, number> = {
1969
+ 'text-sm': 20,
1970
+ 'text-lg': 28,
1971
+ 'text-xl': 28,
1972
+ 'text-2xl': 36,
1973
+ }
1974
+
1975
+ const headingMetrics: Record<string, { size: number; height: number }> = {
1976
+ 'vx-h1': { size: 32, height: 41 },
1977
+ 'vx-h2': { size: 24, height: 31 },
1978
+ 'vx-h3': { size: 18.72, height: 25 },
1979
+ 'vx-h4': { size: 16, height: 21 },
1980
+ 'vx-h5': { size: 13.28, height: 17 },
1981
+ 'vx-h6': { size: 10.72, height: 14 },
1982
+ }
1983
+
1984
+ node.headingDefaultHeight = undefined
1985
+ node.classLineHeight = undefined
1986
+ for (const name of classes) {
1987
+ const heading = headingMetrics[name]
1988
+ if (heading) {
1989
+ node.appliedFontWeight = '700'
1990
+ node.headingDefaultHeight = heading.height
1991
+ node.view!.font = fontForFamilyStyle(heading.size, 700, node.appliedFontFamily)
1992
+ }
1993
+
1994
+ if (sizes[name]) {
1995
+ node.headingDefaultHeight = undefined
1996
+ node.view!.font = fontForFamilyStyle(
1997
+ sizes[name],
1998
+ node.appliedFontWeight ?? 400,
1999
+ node.appliedFontFamily,
2000
+ )
2001
+ }
2002
+
2003
+ if (lineHeights[name] != null) {
2004
+ node.classLineHeight = lineHeights[name]
2005
+ }
2006
+
2007
+ if (name === 'font-semibold') {
2008
+ node.appliedFontWeight = '600'
2009
+ node.view!.font = fontForFamilyStyle(node.view!.font.pointSize, 600, node.appliedFontFamily)
2010
+ }
2011
+
2012
+ if (name === 'font-bold') {
2013
+ node.appliedFontWeight = '700'
2014
+ node.view!.font = fontForFamilyStyle(node.view!.font.pointSize, 700, node.appliedFontFamily)
2015
+ }
2016
+
2017
+ if (name === 'text-muted') {
2018
+ node.colorSlot = 'muted'
2019
+ }
2020
+
2021
+ if (name === 'text-onprimary') {
2022
+ node.colorSlot = 'onprimary'
2023
+ }
2024
+ }
2025
+ }
2026
+
2027
+ if (
2028
+ node.type === 'flexboxlayout' ||
2029
+ node.type === 'stack' ||
2030
+ node.type === 'scrollview' ||
2031
+ node.type === 'gridlayout'
2032
+ ) {
2033
+ const gaps: Record<string, number> = {
2034
+ 'gap-1': 4,
2035
+ 'gap-2': 8,
2036
+ 'gap-3': 12,
2037
+ 'gap-4': 16,
2038
+ 'gap-6': 24,
2039
+ }
2040
+
2041
+ for (const name of classes) {
2042
+ if (gaps[name] !== undefined) {
2043
+ node.view!.spacing = gaps[name]
2044
+ }
2045
+
2046
+ if (name === 'flex-row') {
2047
+ node.view!.orientation = NSUserInterfaceLayoutOrientation.Horizontal
2048
+ }
2049
+
2050
+ if (name === 'flex-col') {
2051
+ node.view!.orientation = NSUserInterfaceLayoutOrientation.Vertical
2052
+ }
2053
+
2054
+ if (name === 'items-center' || name === 'items-start' || name === 'items-end') {
2055
+ node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
2056
+ }
2057
+
2058
+ if (name === 'vx-toast' && node.type === 'flexboxlayout') {
2059
+ ;(node.view as NSStackView).edgeInsets = { top: 12, left: 12, bottom: 12, right: 12 }
2060
+ }
2061
+
2062
+ if (name === 'vx-toast-viewport') {
2063
+ ;(node.view as NSStackView).spacing = 8
2064
+ }
2065
+
2066
+ if (name === 'vx-button') {
2067
+ node.view!.orientation = NSUserInterfaceLayoutOrientation.Horizontal
2068
+ node.view!.alignment = NSLayoutAttribute.CenterY
2069
+ node.view!.distribution = NSStackViewDistribution.GravityAreas
2070
+ }
2071
+
2072
+ if (name === 'justify-between') {
2073
+ node.view!.distribution = NSStackViewDistribution.EqualSpacing
2074
+ }
2075
+
2076
+ if (name === 'flex-1') {
2077
+ node.view!.setContentHuggingPriorityForOrientation(
2078
+ 1,
2079
+ NSUserInterfaceLayoutOrientation.Vertical,
2080
+ )
2081
+
2082
+ node.view!.setContentCompressionResistancePriorityForOrientation(
2083
+ 1,
2084
+ NSUserInterfaceLayoutOrientation.Vertical,
2085
+ )
2086
+ }
2087
+
2088
+ if (name === 'shrink-0') {
2089
+ node.view!.setContentHuggingPriorityForOrientation(
2090
+ 750,
2091
+ NSUserInterfaceLayoutOrientation.Vertical,
2092
+ )
2093
+ }
2094
+
2095
+ if (name === 'rounded-full' || name.startsWith('rounded-')) {
2096
+ node.view!.wantsLayer = true
2097
+ node.view!.layer.cornerRadius = name === 'rounded-full' ? 12 : 8
2098
+ node.view!.layer.masksToBounds = true
2099
+ }
2100
+
2101
+ if (name === 'bg-primary' || name === 'btn') {
2102
+ node.bgSlot = 'primary'
2103
+ node.view!.wantsLayer = true
2104
+ node.view!.layer.backgroundColor = nativeColor(
2105
+ SCHEME_COLORS[nodeScheme(node)].primary,
2106
+ ).CGColor
2107
+
2108
+ node.view!.edgeInsets = { top: 6, left: 10, bottom: 6, right: 10 }
2109
+ }
2110
+
2111
+ if (name === 'bg-danger') {
2112
+ node.bgSlot = 'danger'
2113
+ node.view!.wantsLayer = true
2114
+ node.view!.layer.backgroundColor = nativeColor(
2115
+ SCHEME_COLORS[nodeScheme(node)].danger,
2116
+ ).CGColor
2117
+ }
2118
+
2119
+ if (name === 'btn-secondary' || name === 'chip' || name === 'chip-off') {
2120
+ node.bgSlot = 'secondary'
2121
+ node.view!.wantsLayer = true
2122
+ node.view!.layer.backgroundColor = nativeColor(
2123
+ SCHEME_COLORS[nodeScheme(node)].secondary,
2124
+ ).CGColor
2125
+
2126
+ node.view!.edgeInsets = { top: 4, left: 8, bottom: 4, right: 8 }
2127
+ }
2128
+
2129
+ // Panels that paint the web body's --color-surface. On this host the
2130
+ // root view fills the window, so the surface slot re-themes the whole
2131
+ // background the way `body { background }` does in a browser.
2132
+ if (
2133
+ name === 'vx-app' ||
2134
+ name === 'sheet-panel' ||
2135
+ name === 'overlay-panel' ||
2136
+ name === 'modal-panel'
2137
+ ) {
2138
+ node.bgSlot = 'surface'
2139
+ node.view!.wantsLayer = true
2140
+ node.view!.layer.backgroundColor = nativeColor(
2141
+ SCHEME_COLORS[nodeScheme(node)].surface,
2142
+ ).CGColor
2143
+ }
2144
+ }
2145
+ }
2146
+
2147
+ if (node.type === 'textfield' || node.type === 'textview') {
2148
+ if (classes.includes('vx-input') || classes.includes('vx-textarea')) {
2149
+ node.view!.font = fontForFamilyStyle(
2150
+ 14,
2151
+ node.appliedFontWeight ?? 400,
2152
+ node.appliedFontFamily,
2153
+ )
2154
+
2155
+ node.colorSlot = 'text'
2156
+ node.view!.drawsBackground = false
2157
+ if (node.type === 'textfield') {
2158
+ node.view!.bezeled = false
2159
+ }
2160
+
2161
+ if (node.type === 'textview') {
2162
+ node.view!.textContainerInset = { width: 0, height: 0 }
2163
+ }
2164
+ }
2165
+ }
2166
+
2167
+ applyThemeColors(node)
2168
+ // `dark`/`ns-dark` re-themes descendants too (the ns-dark cascade), so a
2169
+ // scheme flip on this node re-resolves the whole subtree.
2170
+ const dark = classes.includes('dark') || classes.includes('ns-dark')
2171
+ if (dark !== Boolean(node.hasDarkClass)) {
2172
+ node.hasDarkClass = dark
2173
+ for (const child of descendants(node)) {
2174
+ applyThemeColors(child)
2175
+ }
2176
+ }
2177
+
2178
+ if (node.parent) {
2179
+ setStackChildPriorities(node.parent, node)
2180
+ }
2181
+
2182
+ updateStackDistribution(node)
2183
+ }
2184
+
2185
+ function textContent(node: ElementNode): string {
2186
+ if (node.type === '#text') {
2187
+ return node.text
2188
+ }
2189
+
2190
+ if (node.type === 'label') {
2191
+ return node.view!.stringValue
2192
+ }
2193
+
2194
+ return node.children.map(textContent).join('')
2195
+ }
2196
+
2197
+ function setLabelText(node: ElementNode, text: any) {
2198
+ const lineHeight = Number(node.props?.style?.lineHeight)
2199
+ if (!Number.isFinite(lineHeight) || lineHeight <= 0) {
2200
+ node.view!.stringValue = text
2201
+ return
2202
+ }
2203
+
2204
+ const paragraphStyle = NSMutableParagraphStyle.alloc().init()
2205
+ paragraphStyle.minimumLineHeight = lineHeight
2206
+ paragraphStyle.maximumLineHeight = lineHeight
2207
+ node.view!.attributedStringValue = NSAttributedString.alloc().initWithStringAttributes(text, {
2208
+ [NSParagraphStyleAttributeName]: paragraphStyle,
2209
+ [NSFontAttributeName]: node.view!.font,
2210
+ [NSForegroundColorAttributeName]: node.view!.textColor,
2211
+ })
2212
+ }
2213
+
2214
+ function syncText(parent: ElementNode | null | undefined) {
2215
+ if (parent?.type !== 'label') {
2216
+ return
2217
+ }
2218
+
2219
+ const text = parent.children.length
2220
+ ? parent.children.map(textContent).join('')
2221
+ : String(parent.props?.text ?? '')
2222
+
2223
+ setLabelText(parent, text)
2224
+ }
2225
+
2226
+ function syncTextViewPlaceholder(node: ElementNode | undefined) {
2227
+ if (node?.placeholderView) {
2228
+ node.placeholderView.hidden = String(node.view!.string ?? '').length > 0
2229
+ }
2230
+ }
2231
+
2232
+ function setAction(node: ElementNode, value: any) {
2233
+ if (node.actionId === undefined) {
2234
+ node.actionId = nextActionId++
2235
+ actionIdsByView.set(node.view!, node.actionId)
2236
+ actionHandlers.set(node.actionId, null)
2237
+ node.view!.addGestureRecognizer(
2238
+ NSClickGestureRecognizer.alloc().initWithTargetAction(buttonActionTarget, 'viewPressed'),
2239
+ )
2240
+ }
2241
+
2242
+ actionHandlers.set(
2243
+ node.actionId,
2244
+ typeof value === 'function'
2245
+ ? () => {
2246
+ try {
2247
+ node.container.root!.eventScope('discrete', value)
2248
+ } catch (error) {
2249
+ console.error('[macos-event] press handler failed', error)
2250
+ }
2251
+ }
2252
+ : null,
2253
+ )
2254
+ }
2255
+
2256
+ function setPanAction(node: ElementNode, value: any) {
2257
+ if (typeof value === 'function' && !node.panGestureRecognizer) {
2258
+ node.panGestureRecognizer = NSPanGestureRecognizer.alloc().initWithTargetAction(
2259
+ buttonActionTarget,
2260
+ 'viewPanned',
2261
+ )
2262
+
2263
+ node.view!.addGestureRecognizer(node.panGestureRecognizer)
2264
+ }
2265
+
2266
+ panHandlersByView.set(
2267
+ node.view!,
2268
+ typeof value === 'function'
2269
+ ? (event: PanEvent) => {
2270
+ try {
2271
+ node.container.root!.eventScope('discrete', () => value(event))
2272
+ } catch (error) {
2273
+ console.error('[macos-event] pan handler failed', error)
2274
+ }
2275
+ }
2276
+ : null,
2277
+ )
2278
+ }
2279
+
2280
+ function setControlAction(node: ElementNode, value: any, readValue: () => any) {
2281
+ if (node.actionId === undefined) {
2282
+ return
2283
+ }
2284
+
2285
+ actionHandlers.set(
2286
+ node.actionId,
2287
+ typeof value === 'function'
2288
+ ? () => {
2289
+ try {
2290
+ node.container.root!.eventScope('discrete', () => value(readValue()))
2291
+ } catch (error) {
2292
+ console.error('[macos-event] control handler failed', error)
2293
+ }
2294
+ }
2295
+ : null,
2296
+ )
2297
+ }
2298
+
2299
+ function applyAccessibility(node: ElementNode, name: string, value: any) {
2300
+ if (node.type === 'flexboxlayout' && node.actionId !== undefined) {
2301
+ const props = stackAccessibilityPropsByView.get(node.view!) ?? {}
2302
+ stackAccessibilityPropsByView.set(node.view!, { ...props, [name]: value })
2303
+ if (name === 'accessibilityLabel') {
2304
+ accessibilityLabels.set(node.actionId!, String(value ?? ''))
2305
+ }
2306
+
2307
+ if (name === 'accessibilityRole') {
2308
+ accessibilityRoles.set(node.actionId!, String(value ?? ''))
2309
+ }
2310
+ }
2311
+
2312
+ if (node.type === 'label' && name === 'accessibilityLabel') {
2313
+ node.view!.setAccessibilityLabel?.(String(value ?? ''))
2314
+ }
2315
+ }
2316
+
2317
+ function applyProps(node: ElementNode, props: PropBag) {
2318
+ node.props = { ...node.props, ...props }
2319
+ if (node.type === '#text') {
2320
+ if ('value' in props) {
2321
+ node.text = String(props.value ?? '')
2322
+ }
2323
+
2324
+ syncText(node.parent)
2325
+ return
2326
+ }
2327
+
2328
+ if (node.type === 'span') {
2329
+ return
2330
+ }
2331
+
2332
+ for (const [name, value] of Object.entries(props)) {
2333
+ if (name === 'onLayoutChanged') {
2334
+ setLayoutAction(node, value)
2335
+ continue
2336
+ }
2337
+
2338
+ switch (node.type) {
2339
+ case 'stack':
2340
+ if (name === 'spacing') {
2341
+ node.view!.spacing = Number(value ?? 0)
2342
+ } else if (name === 'style') {
2343
+ applyStyle(node, value)
2344
+ } else if (name === 'className') {
2345
+ applyClassName(node, value)
2346
+ } else if (name === 'id') {
2347
+ continue
2348
+ } else {
2349
+ console.warn('[macos-host] ignored stack prop ' + name)
2350
+ }
2351
+
2352
+ break
2353
+ case 'flexboxlayout':
2354
+ if (name === 'gap') {
2355
+ node.view!.spacing = Number(value ?? 0)
2356
+ } else if (name === 'spacing') {
2357
+ node.view!.spacing = Number(value ?? 0)
2358
+ } else if (name === 'flexDirection') {
2359
+ node.view!.orientation =
2360
+ value === 'row'
2361
+ ? NSUserInterfaceLayoutOrientation.Horizontal
2362
+ : NSUserInterfaceLayoutOrientation.Vertical
2363
+
2364
+ node.view!.distribution = NSStackViewDistribution.GravityAreas
2365
+ node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
2366
+ moveStackChildren(node)
2367
+ updateCrossAxisConstraints(node)
2368
+ } else if (name === 'alignItems') {
2369
+ node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
2370
+ updateCrossAxisConstraints(node)
2371
+ } else if (name === 'justifyContent') {
2372
+ node.view!.distribution = NSStackViewDistribution.GravityAreas
2373
+ moveStackChildren(node)
2374
+ } else if (name === 'style') {
2375
+ applyStyle(node, value)
2376
+ } else if (name === 'className') {
2377
+ applyClassName(node, value)
2378
+ moveStackChildren(node)
2379
+ node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
2380
+ updateCrossAxisConstraints(node)
2381
+ } else if (name === 'id') {
2382
+ continue
2383
+ } else if (name === 'onTap') {
2384
+ setAction(node, value)
2385
+ } else if (name === 'onPan') {
2386
+ setPanAction(node, value)
2387
+ } else if (name === 'onTouch') {
2388
+ continue
2389
+ } else if (name === 'onSwipe') {
2390
+ if (typeof value === 'function') {
2391
+ console.warn('[macos-host] ' + name + ' is unsupported by the AppKit renderer')
2392
+ }
2393
+ } else if (name.startsWith('on') && value == null) {
2394
+ continue
2395
+ } else if (name === 'accessible' || name.startsWith('accessibility')) {
2396
+ applyAccessibility(node, name, value)
2397
+ } else if (
2398
+ [
2399
+ 'alignItems',
2400
+ 'justifyContent',
2401
+ 'flexWrap',
2402
+ 'row',
2403
+ 'col',
2404
+ 'rowSpan',
2405
+ 'colSpan',
2406
+ 'left',
2407
+ 'top',
2408
+ 'flexGrow',
2409
+ 'flexShrink',
2410
+ 'alignSelf',
2411
+ 'order',
2412
+ 'horizontalAlignment',
2413
+ 'verticalAlignment',
2414
+ ].includes(name)
2415
+ ) {
2416
+ continue
2417
+ } else {
2418
+ console.warn('[macos-host] ignored flexboxlayout prop ' + name)
2419
+ }
2420
+
2421
+ break
2422
+ case 'gridlayout':
2423
+ if (name === 'style') {
2424
+ applyStyle(node, value)
2425
+ } else if (name === 'className' || name === 'id') {
2426
+ continue
2427
+ } else if (name === 'overlayChildren') {
2428
+ continue
2429
+ } else if (name === 'disabled') {
2430
+ continue
2431
+ } else if (name === 'onPan') {
2432
+ setPanAction(node, value)
2433
+ } else if (name === 'onAccessibilityIncrement' || name === 'onAccessibilityDecrement') {
2434
+ continue
2435
+ } else if (name === 'onSwipe') {
2436
+ if (typeof value === 'function') {
2437
+ console.warn('[macos-host] onSwipe is unsupported by the AppKit renderer')
2438
+ }
2439
+ } else if (name.startsWith('on') && value == null) {
2440
+ continue
2441
+ } else if (name === 'accessible' || name.startsWith('accessibility')) {
2442
+ applyAccessibility(node, name, value)
2443
+ } else if (['rows', 'columns'].includes(name)) {
2444
+ layoutGridChildren(node)
2445
+ } else {
2446
+ console.warn('[macos-host] ignored gridlayout prop ' + name)
2447
+ }
2448
+
2449
+ break
2450
+ case 'absolutelayout':
2451
+ if (name === 'style') {
2452
+ applyStyle(node, value)
2453
+ } else if (name === 'className' || name === 'id') {
2454
+ continue
2455
+ } else if (name === 'onPan') {
2456
+ setPanAction(node, value)
2457
+ } else if (name.startsWith('on') && value == null) {
2458
+ continue
2459
+ } else if (name === 'accessible' || name.startsWith('accessibility')) {
2460
+ applyAccessibility(node, name, value)
2461
+ } else {
2462
+ console.warn('[macos-host] ignored absolutelayout prop ' + name)
2463
+ }
2464
+
2465
+ break
2466
+ case 'label':
2467
+ if (name === 'text') {
2468
+ setLabelText(node, String(value ?? ''))
2469
+ } else if (name === 'fontSize') {
2470
+ node.view!.font = fontForFamilyStyle(
2471
+ Number(value ?? 16),
2472
+ node.appliedFontWeight ?? 400,
2473
+ node.appliedFontFamily,
2474
+ )
2475
+ } else if (name === 'style') {
2476
+ applyStyle(node, value)
2477
+ syncText(node)
2478
+ } else if (name === 'className') {
2479
+ applyClassName(node, value)
2480
+ } else if (name === 'id') {
2481
+ continue
2482
+ } else if (['maxLines', 'whiteSpace', 'textOverflow', 'accessible'].includes(name)) {
2483
+ continue
2484
+ } else if (name.startsWith('accessibility')) {
2485
+ applyAccessibility(node, name, value)
2486
+ } else if (name.startsWith('on') && value == null) {
2487
+ continue
2488
+ } else {
2489
+ console.warn('[macos-host] ignored label prop ' + name)
2490
+ }
2491
+
2492
+ break
2493
+ case 'button':
2494
+ if (name === 'title') {
2495
+ node.view!.title = String(value ?? '')
2496
+ } else if (name === 'enabled') {
2497
+ node.view!.enabled = value !== false
2498
+ } else if (name === 'onPress') {
2499
+ actionHandlers.set(
2500
+ node.actionId!,
2501
+ typeof value === 'function'
2502
+ ? () => {
2503
+ try {
2504
+ node.container.root!.eventScope('discrete', value)
2505
+ } catch (error) {
2506
+ console.error('[macos-event] button handler failed', error)
2507
+ }
2508
+ }
2509
+ : null,
2510
+ )
2511
+ } else if (name === 'style') {
2512
+ applyStyle(node, value)
2513
+ } else if (name === 'className' || name === 'id') {
2514
+ continue
2515
+ } else {
2516
+ console.warn('[macos-host] ignored button prop ' + name)
2517
+ }
2518
+
2519
+ break
2520
+ case 'scrollview':
2521
+ if (name === 'style') {
2522
+ applyStyle(node, value)
2523
+ } else if (name === 'className') {
2524
+ applyClassName(node, value)
2525
+ } else if (name === 'accessibilityLabel') {
2526
+ node.view!.setAccessibilityLabel?.(String(value ?? ''))
2527
+ } else if (
2528
+ name === 'id' ||
2529
+ name === 'horizontal' ||
2530
+ name === 'showsVerticalScrollIndicator'
2531
+ ) {
2532
+ continue
2533
+ } else if (name === 'onScroll') {
2534
+ setScrollAction(node, value)
2535
+ } else if (name.startsWith('on')) {
2536
+ if (typeof value === 'function') {
2537
+ console.warn('[macos-host] ' + name + ' is unsupported on ScrollView')
2538
+ }
2539
+ } else {
2540
+ console.warn('[macos-host] ignored scrollview prop ' + name)
2541
+ }
2542
+
2543
+ break
2544
+ case 'textfield':
2545
+ case 'textview':
2546
+ if (name === 'value') {
2547
+ if (node.type === 'textview') {
2548
+ node.view!.string = String(value ?? '')
2549
+ syncTextViewPlaceholder(node)
2550
+ } else {
2551
+ node.view!.stringValue = String(value ?? '')
2552
+ }
2553
+ } else if (name === 'placeholder') {
2554
+ if (node.type === 'textfield') {
2555
+ continue
2556
+ } else if (node.placeholderView) {
2557
+ node.placeholderView.stringValue = String(value ?? '')
2558
+ syncTextViewPlaceholder(node)
2559
+ }
2560
+ } else if (name === 'placeholderTextColor' && node.type === 'textfield') {
2561
+ continue
2562
+ } else if (name === 'onTextChange') {
2563
+ setControlAction(node, value, () =>
2564
+ String(node.type === 'textview' ? node.view!.string : (node.view!.stringValue ?? '')),
2565
+ )
2566
+ } else if (name === 'style') {
2567
+ applyStyle(node, value)
2568
+ } else if (name === 'className') {
2569
+ applyClassName(node, value)
2570
+ } else if (name === 'id') {
2571
+ continue
2572
+ } else if (name === 'rows' && node.type === 'textview') {
2573
+ continue
2574
+ } else if (name.startsWith('accessibility')) {
2575
+ applyAccessibility(node, name, value)
2576
+ } else if (name.startsWith('on') && value == null) {
2577
+ continue
2578
+ } else if (
2579
+ [
2580
+ 'editable',
2581
+ 'enabled',
2582
+ 'secure',
2583
+ 'keyboardType',
2584
+ 'returnKeyType',
2585
+ 'autoGrow',
2586
+ 'maxRows',
2587
+ ].includes(name)
2588
+ ) {
2589
+ continue
2590
+ } else {
2591
+ console.warn('[macos-host] ignored text control prop ' + name)
2592
+ }
2593
+
2594
+ break
2595
+ case 'switch':
2596
+ if (name === 'checked') {
2597
+ node.view!.state = value ? 1 : 0
2598
+ } else if (name === 'onCheckedChange') {
2599
+ setControlAction(node, value, () => node.view!.state === 1)
2600
+ } else if (name === 'disabled') {
2601
+ node.view!.enabled = value !== true
2602
+ } else if (name === 'style') {
2603
+ applyStyle(node, value)
2604
+ } else if (name === 'className' || name === 'id') {
2605
+ continue
2606
+ } else {
2607
+ console.warn('[macos-host] ignored switch prop ' + name)
2608
+ }
2609
+
2610
+ break
2611
+ case 'slider':
2612
+ if (name === 'value') {
2613
+ node.view!.doubleValue = Number(value ?? 0)
2614
+ } else if (name === 'minValue') {
2615
+ node.view!.minValue = Number(value ?? 0)
2616
+ } else if (name === 'maxValue') {
2617
+ node.view!.maxValue = Number(value ?? 1)
2618
+ } else if (name === 'onValueChange') {
2619
+ setControlAction(node, value, () => Number(node.view!.doubleValue))
2620
+ } else if (name === 'disabled') {
2621
+ node.view!.enabled = value !== true
2622
+ } else if (name === 'style') {
2623
+ applyStyle(node, value)
2624
+ } else if (name === 'className' || name === 'id') {
2625
+ continue
2626
+ } else {
2627
+ console.warn('[macos-host] ignored slider prop ' + name)
2628
+ }
2629
+
2630
+ break
2631
+ case 'webview':
2632
+ if (name === 'style') {
2633
+ applyStyle(node, value)
2634
+ } else if (name === 'className') {
2635
+ applyClassName(node, value)
2636
+ } else if (name === 'accessibilityLabel') {
2637
+ node.view!.accessibilityLabel = String(value ?? '')
2638
+ } else if (name === 'accessible') {
2639
+ node.view!.accessibilityElement = value !== false
2640
+ } else if (name === 'accessibilityHint') {
2641
+ node.view!.accessibilityHelp = String(value ?? '')
2642
+ }
2643
+
2644
+ break
2645
+ case 'image':
2646
+ if (name === 'src') {
2647
+ node.view!.image = loadImage(value)
2648
+ } else if (name === 'style') {
2649
+ applyStyle(node, value)
2650
+ } else if (name === 'className' || name === 'id' || name === 'alt') {
2651
+ continue
2652
+ } else if (name === 'accessibilityLabel') {
2653
+ node.view!.accessibilityLabel = String(value ?? '')
2654
+ node.view!.accessibilityElement = !!value
2655
+ } else {
2656
+ console.warn('[macos-host] ignored image prop ' + name)
2657
+ }
2658
+
2659
+ break
2660
+ }
2661
+ }
2662
+
2663
+ if (node.type === 'webview') {
2664
+ updateWebView(node, props, (height) => {
2665
+ node.props.style = { ...node.props.style, height }
2666
+ setSizeConstraint(node, 'height', height)
2667
+ })
2668
+ }
2669
+
2670
+ if (node.type === 'textfield') {
2671
+ setTextFieldPlaceholder(node.view as NSTextField, node.props, nodeScheme(node))
2672
+ }
2673
+
2674
+ if (node.type === 'label') {
2675
+ const style = node.props.style ?? {}
2676
+ if (style.lineHeight == null && style.height == null) {
2677
+ const size = Number(node.view!.font?.pointSize ?? 16)
2678
+ const height =
2679
+ style.fontSize == null && node.headingDefaultHeight != null
2680
+ ? node.headingDefaultHeight
2681
+ : (node.classLineHeight ?? Math.ceil(size * DEFAULT_TEXT_LINE_HEIGHT_RATIO))
2682
+
2683
+ setSizeConstraint(node, 'height', height)
2684
+ }
2685
+ }
2686
+
2687
+ if (node.type === 'textview') {
2688
+ const style = node.props.style ?? {}
2689
+ const rows = Number(node.props.rows)
2690
+ if (style.height == null && Number.isFinite(rows) && rows > 0) {
2691
+ const font = node.view!.font
2692
+ const lineHeight = Number(font?.ascender) - Number(font?.descender) + Number(font?.leading)
2693
+ const rowHeight = Math.round(
2694
+ Number.isFinite(lineHeight)
2695
+ ? lineHeight
2696
+ : Number(font?.pointSize ?? 14) * DEFAULT_TEXT_LINE_HEIGHT_RATIO,
2697
+ )
2698
+
2699
+ setSizeConstraint(node, 'height', Math.max(1, Math.floor(rows)) * rowHeight)
2700
+ }
2701
+ }
2702
+
2703
+ if (node.type === 'gridlayout') {
2704
+ layoutGridChildren(node)
2705
+ } else if (node.type === 'absolutelayout') {
2706
+ layoutAbsoluteChildren(node)
2707
+ } else if (node.parent?.type === 'gridlayout') {
2708
+ layoutGridChildren(node.parent)
2709
+ } else if (node.parent?.type === 'absolutelayout') {
2710
+ layoutAbsoluteChildren(node.parent)
2711
+ }
2712
+ }
2713
+
2714
+ function detach(container: RootContainer, node: ElementNode) {
2715
+ const previousParent = node.parent
2716
+ const siblings = previousParent?.children ?? container.children
2717
+ const index = siblings.indexOf(node)
2718
+ if (index >= 0) {
2719
+ siblings.splice(index, 1)
2720
+ }
2721
+
2722
+ if (node.crossAxisConstraint) {
2723
+ node.crossAxisConstraint.active = false
2724
+ }
2725
+
2726
+ node.crossAxisConstraint = null
2727
+ deactivateSizeConstraints(node)
2728
+ if (node.view) {
2729
+ const parentView = previousParent?.childHost ?? previousParent?.view
2730
+ if (parentView?.removeArrangedSubview) {
2731
+ parentView.removeArrangedSubview(arrangedView(node))
2732
+ }
2733
+
2734
+ arrangedView(node).removeFromSuperview()
2735
+ }
2736
+
2737
+ if (previousParent?.type === 'gridlayout') {
2738
+ layoutGridChildren(previousParent)
2739
+ }
2740
+
2741
+ if (previousParent?.type === 'absolutelayout') {
2742
+ layoutAbsoluteChildren(previousParent)
2743
+ }
2744
+
2745
+ if (previousParent) {
2746
+ updateStackDistribution(previousParent)
2747
+ }
2748
+
2749
+ syncText(previousParent)
2750
+ node.parent = null
2751
+ }
2752
+
2753
+ function insert(
2754
+ container: RootContainer,
2755
+ parentId: number | null,
2756
+ node: ElementNode,
2757
+ beforeId: number | null,
2758
+ ) {
2759
+ detach(container, node)
2760
+ const parent = (parentId === null ? null : container.nodes.get(parentId)) ?? null
2761
+ if (parentId !== null && !parent) {
2762
+ throw new Error('Unknown AppKit parent ' + parentId)
2763
+ }
2764
+
2765
+ if (
2766
+ parent &&
2767
+ node.view &&
2768
+ parent.type !== 'stack' &&
2769
+ parent.type !== 'flexboxlayout' &&
2770
+ parent.type !== 'scrollview' &&
2771
+ parent.type !== 'gridlayout' &&
2772
+ parent.type !== 'absolutelayout'
2773
+ ) {
2774
+ throw new Error('AppKit <' + parent.type + '> cannot contain child views')
2775
+ }
2776
+
2777
+ const siblings = parent ? parent.children : container.children
2778
+ const beforeIndex = beforeId === null ? -1 : siblings.findIndex((child) => child.id === beforeId)
2779
+ const index = beforeIndex < 0 ? siblings.length : beforeIndex
2780
+ siblings.splice(index, 0, node)
2781
+ node.parent = parent
2782
+ if (node.view) {
2783
+ const parentView = parent?.childHost ?? parent?.view ?? container.hostView
2784
+ if (!parentView) {
2785
+ throw new Error('AppKit host has no parent view for node ' + node.id)
2786
+ }
2787
+
2788
+ if (parent) {
2789
+ if (parent.type === 'gridlayout') {
2790
+ parentView.addSubview(node.view)
2791
+ node.view.translatesAutoresizingMaskIntoConstraints = false
2792
+ deactivateSizeConstraints(node)
2793
+ layoutGridChildren(parent)
2794
+ } else if (parent.type === 'absolutelayout') {
2795
+ parentView.addSubview(node.view)
2796
+ node.view.translatesAutoresizingMaskIntoConstraints = false
2797
+ deactivateSizeConstraints(node)
2798
+ layoutAbsoluteChildren(parent)
2799
+ } else {
2800
+ if (marginInsetsOf(node)) {
2801
+ makeMarginHost(node)
2802
+ }
2803
+
2804
+ // AppKit insertion indices are local to a gravity area. Honor the
2805
+ // renderer's before edge instead of appending moved keyed rows.
2806
+ const gravity = stackGravity(parent, node)
2807
+ const arrangedIndex = siblings
2808
+ .slice(0, index)
2809
+ .filter((child) => child.view && stackGravity(parent, child) === gravity).length
2810
+
2811
+ parentView.insertViewAtIndexInGravity(arrangedView(node), arrangedIndex, gravity)
2812
+ applySizeConstraints(node)
2813
+ setStackChildPriorities(parent, node)
2814
+ updateCrossAxisConstraints(parent)
2815
+ }
2816
+ } else {
2817
+ parentView.addSubview(node.view)
2818
+ node.view.leadingAnchor.constraintEqualToAnchor(parentView.leadingAnchor).active = true
2819
+ node.view.trailingAnchor.constraintEqualToAnchor(parentView.trailingAnchor).active = true
2820
+ node.view.topAnchor.constraintEqualToAnchor(parentView.topAnchor).active = true
2821
+ node.view.bottomAnchor.constraintEqualToAnchor(parentView.bottomAnchor).active = true
2822
+ applySizeConstraints(node)
2823
+ }
2824
+
2825
+ syncScheme(node)
2826
+ } else {
2827
+ syncText(parent)
2828
+ }
2829
+ }
2830
+
2831
+ function remove(container: RootContainer, parentId: number | null, node: ElementNode) {
2832
+ const expectedParent = (parentId === null ? null : container.nodes.get(parentId)) ?? null
2833
+ if (node.parent !== expectedParent) {
2834
+ return
2835
+ }
2836
+
2837
+ const siblings = expectedParent ? expectedParent.children : container.children
2838
+ const index = siblings.indexOf(node)
2839
+ if (index >= 0) {
2840
+ siblings.splice(index, 1)
2841
+ }
2842
+
2843
+ if (node.crossAxisConstraint) {
2844
+ node.crossAxisConstraint.active = false
2845
+ }
2846
+
2847
+ node.crossAxisConstraint = null
2848
+ deactivateSizeConstraints(node)
2849
+ if (node.view) {
2850
+ const parentView = expectedParent?.childHost ?? expectedParent?.view
2851
+ if (parentView?.removeArrangedSubview) {
2852
+ parentView.removeArrangedSubview(arrangedView(node))
2853
+ }
2854
+
2855
+ node.view.removeFromSuperview()
2856
+ node.marginHost?.removeFromSuperview()
2857
+ node.marginHost = null
2858
+ node.marginConstraints = null
2859
+ }
2860
+
2861
+ if (expectedParent?.type === 'gridlayout') {
2862
+ layoutGridChildren(expectedParent)
2863
+ }
2864
+
2865
+ if (expectedParent?.type === 'absolutelayout') {
2866
+ layoutAbsoluteChildren(expectedParent)
2867
+ }
2868
+
2869
+ if (expectedParent) {
2870
+ updateStackDistribution(expectedParent)
2871
+ }
2872
+
2873
+ node.parent = null
2874
+ syncText(expectedParent)
2875
+ }
2876
+
2877
+ function destroy(node: ElementNode) {
2878
+ if (node.type === 'webview') {
2879
+ disposeWebView(node.view!)
2880
+ }
2881
+
2882
+ if (node.type === 'scrollview' && node.scrollObserverInstalled) {
2883
+ const clipView = node.view!.contentView
2884
+ scrollHandlers.delete(clipView)
2885
+ NSNotificationCenter.defaultCenter.removeObserver(node.scrollObserver)
2886
+ node.scrollObserver = null
2887
+ node.scrollObserverInstalled = false
2888
+ }
2889
+
2890
+ if (node.layoutObserver) {
2891
+ NSNotificationCenter.defaultCenter.removeObserver(node.layoutObserver)
2892
+ node.layoutObserver = null
2893
+ }
2894
+
2895
+ if (node.actionId !== undefined) {
2896
+ actionHandlers.delete(node.actionId)
2897
+ accessibilityLabels.delete(node.actionId)
2898
+ accessibilityRoles.delete(node.actionId)
2899
+ }
2900
+ }
2901
+
2902
+ function applyCommand(container: RootContainer, command: UniversalHostCommand) {
2903
+ switch (command.op) {
2904
+ case 'create':
2905
+ container.nodes.set(command.id, makeNode(container, command.id, command.type, command.props))
2906
+ return
2907
+ case 'update': {
2908
+ const node = container.nodes.get(command.id)
2909
+ if (!node) {
2910
+ throw new Error('Unknown AppKit node ' + command.id)
2911
+ }
2912
+
2913
+ applyProps(node, command.props)
2914
+ return
2915
+ }
2916
+ case 'insert':
2917
+ case 'move': {
2918
+ const node = container.nodes.get(command.id)
2919
+ if (!node) {
2920
+ throw new Error('Unknown AppKit node ' + command.id)
2921
+ }
2922
+
2923
+ insert(container, command.parent as number | null, node, command.before)
2924
+ return
2925
+ }
2926
+ case 'remove': {
2927
+ const node = container.nodes.get(command.id)
2928
+ if (node) {
2929
+ remove(container, command.parent as number | null, node)
2930
+ }
2931
+
2932
+ return
2933
+ }
2934
+ case 'destroy': {
2935
+ const node = container.nodes.get(command.id)
2936
+ if (node) {
2937
+ destroy(node)
2938
+ }
2939
+
2940
+ container.nodes.delete(command.id)
2941
+ return
2942
+ }
2943
+ case 'visibility': {
2944
+ const node = container.nodes.get(command.id)
2945
+ if (node?.view) {
2946
+ node.view.hidden = command.state === 'hidden'
2947
+ }
2948
+
2949
+ return
2950
+ }
2951
+ case 'ensure-public-instance':
2952
+ return
2953
+ case 'recreate': {
2954
+ const node = container.nodes.get(command.id)
2955
+ if (!node) {
2956
+ throw new Error('Unknown AppKit node ' + command.id)
2957
+ }
2958
+
2959
+ const replacement = makeNode(container, command.id, command.type, command.props)
2960
+ const parent = node.parent
2961
+ const siblings = parent ? parent.children : container.children
2962
+ const index = siblings.indexOf(node)
2963
+ const beforeId = index < 0 ? null : (siblings[index + 1]?.id ?? null)
2964
+ remove(container, parent?.id ?? null, node)
2965
+ destroy(node)
2966
+ container.nodes.set(command.id, replacement)
2967
+ insert(container, parent?.id ?? null, replacement, beforeId)
2968
+ return
2969
+ }
2970
+ default:
2971
+ throw new Error('AppKit spike does not support host command ' + command.op)
2972
+ }
2973
+ }
2974
+
2975
+ const macOSDriver: UniversalHostDriver<RootContainer, any> = {
2976
+ id: 'macos',
2977
+ capabilities: { text: 'host' },
2978
+ prepareBatch(container, batch) {
2979
+ return {
2980
+ apply() {
2981
+ for (const command of batch.commands) {
2982
+ try {
2983
+ applyCommand(container, command)
2984
+ } catch (error) {
2985
+ // The universal root can swallow a mid-batch failure when it
2986
+ // retries or aborts the attempt — log it here so an
2987
+ // unsupported element cannot silently stall the mount.
2988
+ console.error(
2989
+ '[macos-host] command ' +
2990
+ command.op +
2991
+ ' failed for <' +
2992
+ ((command as { type?: string }).type ?? '?') +
2993
+ '> id=' +
2994
+ ('id' in command ? command.id : '?'),
2995
+ error,
2996
+ )
2997
+
2998
+ throw error
2999
+ }
3000
+ }
3001
+ },
3002
+ abort() {},
3003
+ }
3004
+ },
3005
+ getPublicInstance(container, id) {
3006
+ return container.nodes.get(id)?.view ?? null
3007
+ },
3008
+ }
3009
+
3010
+ const round = (value: number) => Math.round(value * 100) / 100
3011
+
3012
+ function nodeClasses(node: ElementNode) {
3013
+ return String(node.props?.className ?? '')
3014
+ .split(/\s+/)
3015
+ .filter(Boolean)
3016
+ }
3017
+
3018
+ function descendants(node: ElementNode, out: ElementNode[] = []) {
3019
+ for (const child of node.children) {
3020
+ out.push(child)
3021
+ descendants(child, out)
3022
+ }
3023
+
3024
+ return out
3025
+ }
3026
+
3027
+ function withDrawingAppearance<T>(appearance: any, read: () => T): T {
3028
+ let value!: T
3029
+ const readValue = () => {
3030
+ value = read()
3031
+ }
3032
+
3033
+ if (typeof appearance?.performAsCurrentDrawingAppearance === 'function') {
3034
+ appearance.performAsCurrentDrawingAppearance(readValue)
3035
+ } else {
3036
+ readValue()
3037
+ }
3038
+
3039
+ return value
3040
+ }
3041
+
3042
+ function colorValue(color: any, appearance?: any) {
3043
+ if (!color) {
3044
+ return undefined
3045
+ }
3046
+
3047
+ try {
3048
+ return withDrawingAppearance(appearance, () => {
3049
+ const rgb = color.colorUsingColorSpace?.(NSColorSpace.sRGBColorSpace) ?? color
3050
+ return (
3051
+ '#' +
3052
+ [rgb.redComponent, rgb.greenComponent, rgb.blueComponent]
3053
+ .map((component) =>
3054
+ Math.round(Number(component) * 255)
3055
+ .toString(16)
3056
+ .padStart(2, '0'),
3057
+ )
3058
+ .join('')
3059
+ )
3060
+ })
3061
+ } catch {
3062
+ return undefined
3063
+ }
3064
+ }
3065
+
3066
+ function stackDirection(view: any) {
3067
+ return view.orientation === NSUserInterfaceLayoutOrientation.Horizontal ? 'row' : 'column'
3068
+ }
3069
+
3070
+ function parityStyle(node: ElementNode, facets: readonly string[]) {
3071
+ const view = node.view
3072
+ const font = view?.font
3073
+ const supplied: PropBag = node.props?.style ?? {}
3074
+ const out: Record<string, any> = {}
3075
+ for (const facet of facets) {
3076
+ let value = supplied[facet]
3077
+ if (facet === 'paddingTop' && view?.edgeInsets?.top != null) {
3078
+ value = view.edgeInsets.top
3079
+ }
3080
+
3081
+ if (facet === 'paddingRight' && view?.edgeInsets?.right != null) {
3082
+ value = view.edgeInsets.right
3083
+ }
3084
+
3085
+ if (facet === 'paddingBottom' && view?.edgeInsets?.bottom != null) {
3086
+ value = view.edgeInsets.bottom
3087
+ }
3088
+
3089
+ if (facet === 'paddingLeft' && view?.edgeInsets?.left != null) {
3090
+ value = view.edgeInsets.left
3091
+ }
3092
+
3093
+ if (facet === 'fontSize' && font?.pointSize != null) {
3094
+ value = font.pointSize
3095
+ }
3096
+
3097
+ if (facet === 'fontFamily' && font?.familyName) {
3098
+ value = font.familyName
3099
+ }
3100
+
3101
+ if (facet === 'fontWeight' && font?.fontDescriptor?.symbolicTraits != null) {
3102
+ value =
3103
+ supplied.fontWeight ??
3104
+ node.appliedFontWeight ??
3105
+ (Number(font.fontDescriptor.symbolicTraits) & 2 ? '700' : '400')
3106
+ }
3107
+
3108
+ if (facet === 'lineHeight' && font) {
3109
+ value =
3110
+ supplied.lineHeight ??
3111
+ (node.type === 'label'
3112
+ ? round(Number(view.frame.size.height))
3113
+ : round(Number(font.ascender) - Number(font.descender) + Number(font.leading)))
3114
+ }
3115
+
3116
+ if (facet === 'color' && view?.textColor) {
3117
+ value = colorValue(view.textColor) ?? value
3118
+ }
3119
+
3120
+ if (facet === 'backgroundColor') {
3121
+ value =
3122
+ colorValue(
3123
+ view?.layer?.backgroundColor
3124
+ ? NSColor.colorWithCGColor(view.layer.backgroundColor)
3125
+ : null,
3126
+ ) ?? value
3127
+
3128
+ if (value == null && view?.drawsBackground === false) {
3129
+ value = 'rgba(0,0,0,0)'
3130
+ }
3131
+ }
3132
+
3133
+ if (facet === 'borderTopWidth' && view?.layer) {
3134
+ value = view.layer.borderWidth
3135
+ }
3136
+
3137
+ if (facet === 'borderTopColor' && view?.layer?.borderColor) {
3138
+ value = colorValue(NSColor.colorWithCGColor(view.layer.borderColor)) ?? value
3139
+ }
3140
+
3141
+ if (facet === 'borderTopLeftRadius' && view?.layer) {
3142
+ value = view.layer.cornerRadius
3143
+ }
3144
+
3145
+ if (facet === 'opacity' && view?.alphaValue != null) {
3146
+ value = view.alphaValue
3147
+ }
3148
+
3149
+ if (facet === 'flexDirection' && view?.orientation != null) {
3150
+ value = stackDirection(view)
3151
+ }
3152
+
3153
+ if (facet === 'alignItems' && view?.orientation != null) {
3154
+ value = stackAlignItems(node)
3155
+ }
3156
+
3157
+ if (facet === 'justifyContent' && view?.distribution != null) {
3158
+ value = stackJustifyContent(node)
3159
+ }
3160
+
3161
+ if (value !== undefined && value !== null && value !== '') {
3162
+ out[facet] = String(value)
3163
+ }
3164
+ }
3165
+
3166
+ if (font?.fontName) {
3167
+ out.fontPostScriptName = String(font.fontName)
3168
+ }
3169
+
3170
+ return out
3171
+ }
3172
+
3173
+ function measureTextLineAdvances(value: string, font: any) {
3174
+ if (!value || !font) {
3175
+ return undefined
3176
+ }
3177
+
3178
+ return String(value)
3179
+ .split(/\r\n|\r|\n/)
3180
+ .map((line) => {
3181
+ const attributed = NSAttributedString.alloc().initWithStringAttributes(line, {
3182
+ [NSFontAttributeName]: font,
3183
+ })
3184
+
3185
+ return round(Number(attributed.size().width))
3186
+ })
3187
+ }
3188
+
3189
+ function measureTextLineCount(node: ElementNode) {
3190
+ if (node.type !== 'label') {
3191
+ return undefined
3192
+ }
3193
+
3194
+ const view = node.view
3195
+ const cell = view?.cell
3196
+ if (typeof cell?.cellSizeForBounds !== 'function') {
3197
+ return undefined
3198
+ }
3199
+
3200
+ const bounds = view!.bounds
3201
+ const fit = cell.cellSizeForBounds({
3202
+ origin: { x: 0, y: 0 },
3203
+ size: { width: Number(bounds.size.width), height: 100000 },
3204
+ })
3205
+
3206
+ const height = Number(fit?.height)
3207
+ const styleLineHeight = Number(parityStyle(node, []).lineHeight)
3208
+ const font = view!.font
3209
+ const fontLineHeight = Number(font?.ascender) - Number(font?.descender) + Number(font?.leading)
3210
+ const lineHeight = styleLineHeight > 0 ? styleLineHeight : fontLineHeight
3211
+ if (!Number.isFinite(height) || height <= 0 || !Number.isFinite(lineHeight) || lineHeight <= 0) {
3212
+ return undefined
3213
+ }
3214
+
3215
+ return Math.max(1, Math.round(height / lineHeight))
3216
+ }
3217
+
3218
+ function parityBoxInHost(view: any, rect: any, boxView: any) {
3219
+ const converted = view.superview
3220
+ ? view.superview.convertRectToView(rect, boxView)
3221
+ : view.convertRectToView(view.bounds, boxView)
3222
+
3223
+ const boxHeight = Number(boxView.bounds.size.height)
3224
+ return {
3225
+ x: round(Number(converted.origin.x)),
3226
+ y: round(boxHeight - Number(converted.origin.y) - Number(converted.size.height)),
3227
+ w: round(Number(converted.size.width)),
3228
+ h: round(Number(converted.size.height)),
3229
+ }
3230
+ }
3231
+
3232
+ function parityLocalBoxInHost(view: any, rect: any, boxView: any) {
3233
+ const frameOrigin = view.frame.origin
3234
+ return parityBoxInHost(
3235
+ view,
3236
+ {
3237
+ origin: {
3238
+ x: Number(frameOrigin.x) + Number(rect.origin.x),
3239
+ y: Number(frameOrigin.y) + Number(rect.origin.y),
3240
+ },
3241
+ size: rect.size,
3242
+ },
3243
+ boxView,
3244
+ )
3245
+ }
3246
+
3247
+ function textContentBox(node: ElementNode, boxNode: ElementNode) {
3248
+ const view = node.view
3249
+ if (!view || !boxNode.view) {
3250
+ return null
3251
+ }
3252
+
3253
+ if (node.type === 'textfield' && typeof view.cell?.titleRectForBounds === 'function') {
3254
+ return parityLocalBoxInHost(view, view.cell.titleRectForBounds(view.bounds), boxNode.view)
3255
+ }
3256
+
3257
+ if (node.type === 'textview') {
3258
+ const inset = view.textContainerInset ?? { width: 0, height: 0 }
3259
+ const padding = Number(view.textContainer?.lineFragmentPadding ?? 0)
3260
+ const width = Number(view.bounds.size.width) - 2 * Number(inset.width) - 2 * padding
3261
+ const height = Number(view.bounds.size.height) - 2 * Number(inset.height)
3262
+ return parityLocalBoxInHost(
3263
+ view,
3264
+ {
3265
+ origin: { x: Number(inset.width) + padding, y: Number(inset.height) },
3266
+ size: { width: Math.max(0, width), height: Math.max(0, height) },
3267
+ },
3268
+ boxNode.view,
3269
+ )
3270
+ }
3271
+
3272
+ return null
3273
+ }
3274
+
3275
+ function parityNode(node: ElementNode, boxNode: ElementNode, facets: readonly string[]) {
3276
+ const view = node.view
3277
+ let box = null
3278
+ let frameBox = null
3279
+ if (view && boxNode.view) {
3280
+ try {
3281
+ frameBox = parityBoxInHost(view, view.frame, boxNode.view)
3282
+ const alignedRect = view.alignmentRectForFrame?.(view.frame)
3283
+ box = alignedRect ? parityBoxInHost(view, alignedRect, boxNode.view) : frameBox
3284
+ } catch {}
3285
+ }
3286
+
3287
+ let placeholderBox = null
3288
+ let placeholderFrameBox = null
3289
+ if (node.placeholderView && boxNode.view) {
3290
+ try {
3291
+ const placeholderView = node.placeholderView
3292
+ placeholderFrameBox = parityBoxInHost(placeholderView, placeholderView.frame, boxNode.view)
3293
+ const alignedRect = placeholderView.alignmentRectForFrame?.(placeholderView.frame)
3294
+ placeholderBox = alignedRect
3295
+ ? parityBoxInHost(placeholderView, alignedRect, boxNode.view)
3296
+ : placeholderFrameBox
3297
+ } catch {}
3298
+ }
3299
+
3300
+ let text
3301
+ let textLineAdvances
3302
+ if (node.type === 'label') {
3303
+ const value = String(view?.stringValue ?? '')
3304
+ text = value.trim()
3305
+ textLineAdvances = measureTextLineAdvances(value, view?.font)
3306
+ } else if (node.type === 'button') {
3307
+ const value = String(view?.title ?? '')
3308
+ text = value.trim()
3309
+ textLineAdvances = measureTextLineAdvances(value, view?.font)
3310
+ } else if (node.type === 'textfield') {
3311
+ const value = String(view?.stringValue ?? '')
3312
+ text = value.trim()
3313
+ textLineAdvances = measureTextLineAdvances(value, view?.font)
3314
+ } else if (node.type === 'textview') {
3315
+ const value = String(view?.string ?? '')
3316
+ text = value.trim()
3317
+ textLineAdvances = measureTextLineAdvances(value, view?.font)
3318
+ }
3319
+
3320
+ const placeholderAttributedString =
3321
+ node.type === 'textfield' ? view?.placeholderAttributedString : null
3322
+
3323
+ const placeholder =
3324
+ node.type === 'textfield'
3325
+ ? String(placeholderAttributedString?.string ?? view?.placeholderString ?? '').trim()
3326
+ : node.type === 'textview'
3327
+ ? String(node.placeholderView?.stringValue ?? '').trim()
3328
+ : ''
3329
+
3330
+ const placeholderColor =
3331
+ node.type === 'textfield'
3332
+ ? (placeholderAttributedString?.attributeAtIndexEffectiveRange?.(
3333
+ NSForegroundColorAttributeName,
3334
+ 0,
3335
+ null,
3336
+ ) ?? NSColor.placeholderTextColor)
3337
+ : node.type === 'textview'
3338
+ ? node.placeholderView?.textColor
3339
+ : null
3340
+
3341
+ const appearance = view?.effectiveAppearance ?? view?.window?.effectiveAppearance
3342
+ const placeholderStyle = placeholderColor
3343
+ ? {
3344
+ color: colorValue(placeholderColor, appearance),
3345
+ opacity: round(
3346
+ Number(withDrawingAppearance(appearance, () => placeholderColor.alphaComponent)),
3347
+ ),
3348
+ }
3349
+ : undefined
3350
+
3351
+ const contentBox = textContentBox(node, boxNode)
3352
+ const textLineCount = measureTextLineCount(node)
3353
+
3354
+ return {
3355
+ tag: String(node.type ?? 'view').toLowerCase(),
3356
+ id: node.props?.id || undefined,
3357
+ classes: nodeClasses(node),
3358
+ box,
3359
+ frameBox,
3360
+ placeholderBox,
3361
+ placeholderFrameBox,
3362
+ style: parityStyle(node, facets),
3363
+ text: text || undefined,
3364
+ textLineAdvances,
3365
+ placeholder: placeholder || undefined,
3366
+ placeholderStyle,
3367
+ contentBox,
3368
+ textLineCount,
3369
+ }
3370
+ }
3371
+
3372
+ function measureParity(container: RootContainer, facets: readonly string[]) {
3373
+ const stage = [...container.nodes.values()].find((node) => node.props?.id === 'parity-stage')
3374
+ if (!stage?.view) {
3375
+ return null
3376
+ }
3377
+
3378
+ container.hostView.window?.contentView?.layoutSubtreeIfNeeded?.()
3379
+ stage.view.layoutSubtreeIfNeeded?.()
3380
+
3381
+ const cells: Record<string, any> = {}
3382
+ for (const cell of stage.children.filter((node) => nodeClasses(node).includes('parity-cell'))) {
3383
+ const name = String(cell.props?.id ?? '').replace(/^cell-/, '')
3384
+ const box = descendants(cell).find((node) => nodeClasses(node).includes('parity-box'))
3385
+ if (!box?.view) {
3386
+ continue
3387
+ }
3388
+
3389
+ box.view.layoutSubtreeIfNeeded?.()
3390
+ for (const scroll of descendants(box).filter(
3391
+ (node) => node.type === 'scrollview' && node.view,
3392
+ )) {
3393
+ const clip = scroll.view!.contentView
3394
+ const contentDocument = scroll.view!.documentView
3395
+ const y = Math.max(
3396
+ 0,
3397
+ Number(contentDocument.bounds.size.height) - Number(clip.bounds.size.height),
3398
+ )
3399
+
3400
+ clip.scrollToPoint({ x: 0, y })
3401
+ scroll.view!.reflectScrolledClipView(clip)
3402
+ }
3403
+
3404
+ cells[name] = [box, ...descendants(box)]
3405
+ .filter((node) => node.view)
3406
+ .map((node) => parityNode(node, box, facets))
3407
+ }
3408
+
3409
+ return { target: 'macos', cells }
3410
+ }
3411
+
3412
+ // Pointer-intent plumbing: NSTrackingArea-backed hover observation and an
3413
+ // NSPopover surface for anchored hint layers (Tooltip/Hoverable). Reached by
3414
+ // packages/ui macOS leaves through the __xplatAppKit host global — ui never
3415
+ // links macos-node-api directly.
3416
+
3417
+ const hoverRecordsByArea = new Map<object, { enter?: () => void; exit?: () => void }>()
3418
+ const hoverRecordByView = new WeakMap<object, any>()
3419
+ const openPopups = new Set<any>()
3420
+
3421
+ const TRACKING_OPTS = typeof NSTrackingAreaOptions === 'undefined' ? {} : NSTrackingAreaOptions
3422
+ const trackingOpt = (value: number | undefined, fallback: number) =>
3423
+ typeof value === 'number' && Number.isFinite(value) ? value : fallback
3424
+
3425
+ const HOVER_TRACKING_OPTIONS =
3426
+ trackingOpt(TRACKING_OPTS.MouseEnteredAndExited, 0x01) |
3427
+ trackingOpt(TRACKING_OPTS.ActiveAlways, 0x80) |
3428
+ trackingOpt(TRACKING_OPTS.InVisibleRect, 0x200)
3429
+
3430
+ function dispatchHover(area: any, phase: 'enter' | 'exit') {
3431
+ const record = hoverRecordsByArea.get(area)
3432
+ const handler = phase === 'enter' ? record?.enter : record?.exit
3433
+ if (typeof handler !== 'function') {
3434
+ return
3435
+ }
3436
+
3437
+ try {
3438
+ handler()
3439
+ } catch (error) {
3440
+ console.error('[macos-hover] hover handler failed', error)
3441
+ }
3442
+ }
3443
+
3444
+ function observeHover(view: NSView, handlers: { enter?: () => void; exit?: () => void }) {
3445
+ if (
3446
+ !view ||
3447
+ typeof view.addTrackingArea !== 'function' ||
3448
+ typeof NSTrackingArea === 'undefined' ||
3449
+ !hoverTrackingTarget
3450
+ ) {
3451
+ return () => {}
3452
+ }
3453
+
3454
+ unobserveHover(view)
3455
+ const area = NSTrackingArea.alloc().initWithRectOptionsOwnerUserInfo(
3456
+ { origin: { x: 0, y: 0 }, size: { width: 0, height: 0 } },
3457
+ HOVER_TRACKING_OPTIONS,
3458
+ hoverTrackingTarget,
3459
+ null,
3460
+ )
3461
+
3462
+ if (!area) {
3463
+ return () => {}
3464
+ }
3465
+
3466
+ view.addTrackingArea(area)
3467
+ const record = { view, area, enter: handlers?.enter, exit: handlers?.exit }
3468
+ hoverRecordsByArea.set(area, record)
3469
+ hoverRecordByView.set(view, record)
3470
+ return () => unobserveHover(view)
3471
+ }
3472
+
3473
+ function unobserveHover(view: NSView) {
3474
+ const record = hoverRecordByView.get(view)
3475
+ if (!record) {
3476
+ return
3477
+ }
3478
+
3479
+ hoverRecordByView.delete(view)
3480
+ hoverRecordsByArea.delete(record.area)
3481
+ try {
3482
+ view.removeTrackingArea(record.area)
3483
+ } catch (error) {
3484
+ console.error('[macos-hover] failed to remove tracking area', error)
3485
+ }
3486
+ }
3487
+
3488
+ let hoverTrackingTarget: any = null
3489
+ try {
3490
+ class HoverTrackingTarget extends NSObject {
3491
+ static ObjCExposedMethods = {
3492
+ mouseEntered: { params: [NSEvent], returns: interop.types.void },
3493
+ mouseExited: { params: [NSEvent], returns: interop.types.void },
3494
+ }
3495
+
3496
+ static {
3497
+ NativeClass(this)
3498
+ }
3499
+
3500
+ mouseEntered(event: NSEvent) {
3501
+ dispatchHover(event?.trackingArea, 'enter')
3502
+ }
3503
+
3504
+ mouseExited(event: NSEvent) {
3505
+ dispatchHover(event?.trackingArea, 'exit')
3506
+ }
3507
+ }
3508
+
3509
+ hoverTrackingTarget = HoverTrackingTarget.new()
3510
+ } catch (error) {
3511
+ console.error(
3512
+ '[macos-hover] tracking target registration failed — hover affordances disabled',
3513
+ error,
3514
+ )
3515
+ }
3516
+
3517
+ // preferredEdge is the anchor edge the popover attaches to: 'top' places the
3518
+ // panel above the anchor (its MaxY edge). Numeric NSRectEdge values are the
3519
+ // fallback when the bridged enum object is absent.
3520
+ function popoverEdge(placement: string | undefined) {
3521
+ const edges = typeof NSRectEdge === 'object' && NSRectEdge ? NSRectEdge : {}
3522
+ switch (placement) {
3523
+ case 'bottom': {
3524
+ return edges.MinY ?? 1
3525
+ }
3526
+ case 'left': {
3527
+ return edges.MinX ?? 0
3528
+ }
3529
+ case 'right': {
3530
+ return edges.MaxX ?? 2
3531
+ }
3532
+ case 'top':
3533
+ default: {
3534
+ return edges.MaxY ?? 3
3535
+ }
3536
+ }
3537
+ }
3538
+
3539
+ function popupFittingSize(contentView: NSView) {
3540
+ try {
3541
+ const direct = contentView.fittingSize
3542
+ if (direct && direct.width > 0 && direct.height > 0) {
3543
+ return { width: direct.width, height: direct.height }
3544
+ }
3545
+
3546
+ const child = contentView.subviews?.objectAtIndex?.(0)?.fittingSize
3547
+ if (child && child.width > 0 && child.height > 0) {
3548
+ return { width: child.width, height: child.height }
3549
+ }
3550
+ } catch (error) {
3551
+ console.error('[macos-popup] fitting-size measurement failed', error)
3552
+ }
3553
+
3554
+ return null
3555
+ }
3556
+
3557
+ function showAnchoredPopup(options: PopupOptions) {
3558
+ let anchor = options?.anchor
3559
+ if (
3560
+ !anchor ||
3561
+ !anchor.window ||
3562
+ typeof NSPopover === 'undefined' ||
3563
+ typeof NSViewController === 'undefined'
3564
+ ) {
3565
+ return null
3566
+ }
3567
+
3568
+ // `at` anchors the popup to a window point in top-left coordinates instead
3569
+ // of the view's bounds — the fixed-position path for useLayer.
3570
+ const at = options?.at
3571
+ let relativeRect = anchor.bounds
3572
+ if (at && Number.isFinite(at.x) && Number.isFinite(at.y)) {
3573
+ const container = anchor.window?.contentView ?? anchor
3574
+ anchor = container
3575
+ // NSView coordinates are y-up; flip the top-left point.
3576
+ const height = Number(container.frame?.size?.height ?? 0)
3577
+ relativeRect = {
3578
+ origin: { x: Number(at.x), y: height - Number(at.y) },
3579
+ size: { width: 0, height: 0 },
3580
+ }
3581
+ }
3582
+
3583
+ const contentView = NSView.alloc().initWithFrame({
3584
+ origin: { x: 0, y: 0 },
3585
+ size: { width: 1, height: 1 },
3586
+ })
3587
+
3588
+ const root = createMacOSRoot(contentView, { fontFamily: fontFamilyForView(anchor) })
3589
+ const popup = {
3590
+ popover: null as any,
3591
+ contentView,
3592
+ closed: false,
3593
+ closeObserver: null as any,
3594
+ update(props: PropBag) {
3595
+ if (popup.closed) {
3596
+ return
3597
+ }
3598
+
3599
+ root.render(options.component, props)
3600
+ const size = popupFittingSize(contentView)
3601
+ if (size) {
3602
+ popup.popover!.contentSize = size
3603
+ }
3604
+ },
3605
+ close(dismissed = false) {
3606
+ if (popup.closed) {
3607
+ return
3608
+ }
3609
+
3610
+ popup.closed = true
3611
+ openPopups.delete(popup)
3612
+ if (popup.closeObserver) {
3613
+ try {
3614
+ NSNotificationCenter.defaultCenter.removeObserver(popup.closeObserver)
3615
+ } catch {}
3616
+
3617
+ popup.closeObserver = null
3618
+ }
3619
+
3620
+ try {
3621
+ popup.popover?.close()
3622
+ } catch (error) {
3623
+ console.error('[macos-popup] popover close failed', error)
3624
+ }
3625
+
3626
+ try {
3627
+ root.unmount()
3628
+ } catch (error) {
3629
+ console.error('[macos-popup] popup root unmount failed', error)
3630
+ }
3631
+
3632
+ if (dismissed) {
3633
+ try {
3634
+ options.onClose?.()
3635
+ } catch (error) {
3636
+ console.error('[macos-popup] onClose callback failed', error)
3637
+ }
3638
+ }
3639
+ },
3640
+ }
3641
+
3642
+ try {
3643
+ root.render(options.component, options.props ?? {})
3644
+ } catch (error) {
3645
+ console.error('[macos-popup] popup render failed', error)
3646
+ try {
3647
+ root.unmount()
3648
+ } catch {}
3649
+
3650
+ return null
3651
+ }
3652
+
3653
+ const controller = NSViewController.alloc().init()
3654
+ controller.view = contentView
3655
+ const fit = popupFittingSize(contentView)
3656
+ if (fit) {
3657
+ controller.preferredContentSize = fit
3658
+ }
3659
+
3660
+ const popover = NSPopover.alloc().init()
3661
+ popover.contentViewController = controller
3662
+ const behaviors = typeof NSPopoverBehavior === 'undefined' ? {} : NSPopoverBehavior
3663
+ // Transient closes on outside interaction — the AppKit-native feel for a
3664
+ // hint layer. An explicit lightDismiss:false keeps the layer open until
3665
+ // the leaf closes it. The leaf still owns close() for hover-intent
3666
+ // dismissal.
3667
+ popover.behavior =
3668
+ options?.lightDismiss === false
3669
+ ? (behaviors.ApplicationDefined ?? 2)
3670
+ : (behaviors.Transient ?? 1)
3671
+
3672
+ popover.animates = options.animates ?? true
3673
+ popup.popover = popover
3674
+ openPopups.add(popup)
3675
+ popup.closeObserver = NSNotificationCenter.defaultCenter.addObserverForNameObjectQueueUsingBlock(
3676
+ typeof NSPopoverDidCloseNotification === 'undefined'
3677
+ ? 'NSPopoverDidCloseNotification'
3678
+ : NSPopoverDidCloseNotification,
3679
+ popover,
3680
+ null,
3681
+ () => popup.close(true),
3682
+ )
3683
+
3684
+ try {
3685
+ popover.showRelativeToRectOfViewPreferredEdge(
3686
+ relativeRect,
3687
+ anchor,
3688
+ popoverEdge(options.placement),
3689
+ )
3690
+ } catch (error) {
3691
+ console.error('[macos-popup] popover presentation failed', error)
3692
+ popup.close()
3693
+ return null
3694
+ }
3695
+
3696
+ return popup
3697
+ }
3698
+
3699
+ // Leaf-native controls for the @octane-xplat leaf packages (context-menu,
3700
+ // date-picker, sheet). Same convention as the pointer-intent plumbing —
3701
+ // leaves reach AppKit through __xplatAppKit, never macos-node-api directly.
3702
+
3703
+ let menuActionTarget: any = null
3704
+ try {
3705
+ class MenuActionTarget extends NSObject {
3706
+ static ObjCExposedMethods = {
3707
+ menuItemSelected: { params: [NSMenuItem], returns: interop.types.void },
3708
+ }
3709
+
3710
+ static {
3711
+ NativeClass(this)
3712
+ }
3713
+
3714
+ menuItemSelected(sender: NSMenuItem) {
3715
+ invokeAction(sender.tag)
3716
+ }
3717
+ }
3718
+
3719
+ menuActionTarget = MenuActionTarget.new()
3720
+ } catch (error) {
3721
+ console.error('[macos-leaf] menu target registration failed — context menus disabled', error)
3722
+ }
3723
+
3724
+ /** Builds an NSMenu from serialized items and attaches it as `view.menu` —
3725
+ * AppKit presents it on right-click and drives enablement. Returns a
3726
+ * detach. */
3727
+ function attachContextMenu(
3728
+ view: NSView,
3729
+ options: ContextMenuOptions,
3730
+ onSelect: (id: string | undefined) => void,
3731
+ ) {
3732
+ if (
3733
+ !view ||
3734
+ !menuActionTarget ||
3735
+ typeof NSMenu !== 'function' ||
3736
+ typeof NSMenuItem !== 'function'
3737
+ ) {
3738
+ return () => {}
3739
+ }
3740
+
3741
+ const menu = NSMenu.alloc().init()
3742
+ menu.autoenablesItems = false
3743
+ const created: number[] = []
3744
+ for (const item of options?.items ?? []) {
3745
+ if (item?.divider) {
3746
+ menu.addItem(NSMenuItem.separatorItem())
3747
+ continue
3748
+ }
3749
+
3750
+ const menuItem = NSMenuItem.alloc().initWithTitleActionKeyEquivalent(
3751
+ String(item?.title ?? ''),
3752
+ 'menuItemSelected',
3753
+ '',
3754
+ )
3755
+
3756
+ menuItem.target = menuActionTarget
3757
+ if (item?.disabled) {
3758
+ menuItem.enabled = false
3759
+ }
3760
+
3761
+ const actionId = nextActionId++
3762
+ menuItem.tag = actionId
3763
+ const id = item?.id
3764
+ actionHandlers.set(actionId, () => {
3765
+ try {
3766
+ onSelect?.(id)
3767
+ } catch (error) {
3768
+ console.error('[macos-leaf] context-menu selection failed', error)
3769
+ }
3770
+ })
3771
+
3772
+ created.push(actionId)
3773
+ menu.addItem(menuItem)
3774
+ }
3775
+
3776
+ // NSView.menu only answers right-clicks that hit *that* view — assign
3777
+ // the menu across the subtree so trigger children don't swallow them.
3778
+ // Children mounted after attach aren't covered (re-attach on items
3779
+ // change re-walks).
3780
+ const tagged: any[] = []
3781
+ const assign = (hostView: any) => {
3782
+ if (!hostView || typeof hostView !== 'object') {
3783
+ return
3784
+ }
3785
+
3786
+ try {
3787
+ hostView.menu = menu
3788
+ tagged.push(hostView)
3789
+ } catch {}
3790
+
3791
+ const subviews = hostView.subviews
3792
+ const count = typeof subviews?.count === 'function' ? subviews.count() : (subviews?.count ?? 0)
3793
+
3794
+ for (let i = 0; i < count; i++) {
3795
+ assign(subviews.objectAtIndex(i))
3796
+ }
3797
+ }
3798
+
3799
+ assign(view)
3800
+
3801
+ return () => {
3802
+ for (const hostView of tagged) {
3803
+ try {
3804
+ if (hostView.menu === menu) {
3805
+ hostView.menu = null
3806
+ }
3807
+ } catch {}
3808
+ }
3809
+
3810
+ for (const actionId of created) {
3811
+ actionHandlers.delete(actionId)
3812
+ }
3813
+ }
3814
+ }
3815
+
3816
+ const DATE_PICKER_STYLES =
3817
+ typeof NSDatePickerStyle === 'object' && NSDatePickerStyle ? NSDatePickerStyle : {}
3818
+
3819
+ const DATE_PICKER_ELEMENTS =
3820
+ typeof NSDatePickerElementFlags === 'object' && NSDatePickerElementFlags
3821
+ ? NSDatePickerElementFlags
3822
+ : {}
3823
+
3824
+ function dateFromMillis(millis: number) {
3825
+ if (typeof NSDate !== 'function' || !Number.isFinite(millis)) {
3826
+ return null
3827
+ }
3828
+
3829
+ return NSDate.dateWithTimeIntervalSince1970(millis / 1000)
3830
+ }
3831
+
3832
+ /** Embeds a real NSDatePicker inside the leaf's backing view, pinned to its
3833
+ * edges — the leaf gives the host element an explicit size. mode 'date'
3834
+ * uses YearMonthDay (text field by default, `style: 'graphical'` for the
3835
+ * inline calendar); 'time' uses HourMinute with the clock-and-calendar
3836
+ * field. Returns a handle with update/detach. */
3837
+ function attachDatePicker(view: NSView, options: PropBag, onChange: (event: any) => void) {
3838
+ if (
3839
+ !view ||
3840
+ typeof view.addSubview !== 'function' ||
3841
+ typeof NSDatePicker !== 'function' ||
3842
+ !buttonActionTarget
3843
+ ) {
3844
+ return null
3845
+ }
3846
+
3847
+ const isTime = options?.mode === 'time'
3848
+ const picker = NSDatePicker.alloc().init()
3849
+ picker.datePickerElements = isTime
3850
+ ? (DATE_PICKER_ELEMENTS.HourMinute ?? 0x000c)
3851
+ : (DATE_PICKER_ELEMENTS.YearMonthDay ?? 0x00e0)
3852
+
3853
+ picker.datePickerStyle =
3854
+ !isTime && options?.style === 'graphical'
3855
+ ? (DATE_PICKER_STYLES.Graphical ?? 2)
3856
+ : isTime
3857
+ ? (DATE_PICKER_STYLES.ClockAndCalendar ?? 1)
3858
+ : (DATE_PICKER_STYLES.TextField ?? 0)
3859
+
3860
+ const applyRange = (opts: PropBag | undefined) => {
3861
+ const min = dateFromMillis(opts?.minimumMillis)
3862
+ const max = dateFromMillis(opts?.maximumMillis)
3863
+ picker.minDate = min
3864
+ picker.maxDate = max
3865
+ }
3866
+
3867
+ applyRange(options)
3868
+ picker.enabled = options?.enabled !== false
3869
+
3870
+ const initial = dateFromMillis(options?.selectionMillis)
3871
+ if (initial) {
3872
+ picker.dateValue = initial
3873
+ }
3874
+
3875
+ picker.translatesAutoresizingMaskIntoConstraints = false
3876
+ view.addSubview(picker)
3877
+ picker.leadingAnchor.constraintEqualToAnchor(view.leadingAnchor).active = true
3878
+ picker.trailingAnchor.constraintEqualToAnchor(view.trailingAnchor).active = true
3879
+ picker.topAnchor.constraintEqualToAnchor(view.topAnchor).active = true
3880
+ picker.bottomAnchor.constraintEqualToAnchor(view.bottomAnchor).active = true
3881
+
3882
+ const actionId = nextActionId++
3883
+ picker.tag = actionId
3884
+ picker.target = buttonActionTarget
3885
+ picker.action = 'controlChanged'
3886
+ actionHandlers.set(actionId, () => {
3887
+ const date = picker.dateValue
3888
+ const millis =
3889
+ typeof date?.timeIntervalSince1970 === 'number' ? date.timeIntervalSince1970 * 1000 : null
3890
+
3891
+ if (millis === null) {
3892
+ return
3893
+ }
3894
+
3895
+ try {
3896
+ onChange?.(millis)
3897
+ } catch (error) {
3898
+ console.error('[macos-leaf] date-picker change failed', error)
3899
+ }
3900
+ })
3901
+
3902
+ return {
3903
+ update(next: PropBag) {
3904
+ const date = dateFromMillis(next?.selectionMillis)
3905
+ if (date && picker.dateValue?.timeIntervalSince1970 !== date.timeIntervalSince1970) {
3906
+ picker.dateValue = date
3907
+ }
3908
+
3909
+ applyRange(next)
3910
+ picker.enabled = next?.enabled !== false
3911
+ },
3912
+ detach() {
3913
+ picker.target = null
3914
+ picker.action = null
3915
+ actionHandlers.delete(actionId)
3916
+ picker.removeFromSuperview()
3917
+ },
3918
+ }
3919
+ }
3920
+
3921
+ const openSheets = new Set<any>()
3922
+
3923
+ let sheetDelegateTarget: any = null
3924
+ try {
3925
+ class SheetDelegateTarget extends NSObject {
3926
+ static ObjCProtocols = [NSWindowDelegate]
3927
+
3928
+ static ObjCExposedMethods = {
3929
+ windowWillClose: { params: [NSNotification], returns: interop.types.void },
3930
+ windowDidEndSheet: { params: [NSNotification], returns: interop.types.void },
3931
+ }
3932
+
3933
+ static {
3934
+ NativeClass(this)
3935
+ }
3936
+
3937
+ windowWillClose(notification: NSNotification) {
3938
+ finishSheetDismissal(notification?.object)
3939
+ }
3940
+
3941
+ windowDidEndSheet(notification: NSNotification) {
3942
+ finishSheetDismissal(notification?.object)
3943
+ }
3944
+ }
3945
+
3946
+ sheetDelegateTarget = SheetDelegateTarget.new()
3947
+ } catch (error) {
3948
+ console.error(
3949
+ '[macos-leaf] sheet delegate registration failed — window close reporting disabled',
3950
+ error,
3951
+ )
3952
+ }
3953
+
3954
+ function finishSheetDismissal(sheetWindow: any) {
3955
+ for (const entry of openSheets) {
3956
+ if (entry.sheetWindow !== sheetWindow) {
3957
+ continue
3958
+ }
3959
+
3960
+ openSheets.delete(entry)
3961
+ const wasClosed = entry.closed
3962
+ entry.closed = true
3963
+ try {
3964
+ entry.root.unmount()
3965
+ } catch (error) {
3966
+ console.error('[macos-leaf] sheet root unmount failed', error)
3967
+ }
3968
+
3969
+ if (!wasClosed) {
3970
+ try {
3971
+ entry.onDismissed?.()
3972
+ } catch (error) {
3973
+ console.error('[macos-leaf] sheet dismissal failed', error)
3974
+ }
3975
+ }
3976
+ }
3977
+ }
3978
+
3979
+ /** Presents an octane subtree as a window sheet (beginSheet on the leaf's
3980
+ * window) — a plain floating window when no parent window is attached.
3981
+ * Returns { close, update }. */
3982
+ function presentSheet(view: NSView, options: PropBag) {
3983
+ if (
3984
+ typeof (globalThis as any).NSWindow?.alloc !== 'function' ||
3985
+ typeof (globalThis as any).NSViewController?.alloc !== 'function' ||
3986
+ typeof options?.component !== 'function'
3987
+ ) {
3988
+ return null
3989
+ }
3990
+
3991
+ const parentWindow = view?.window ?? null
3992
+ const contentView = NSView.alloc().initWithFrame({
3993
+ origin: { x: 0, y: 0 },
3994
+ size: { width: 480, height: 1 },
3995
+ })
3996
+
3997
+ const root = createMacOSRoot(contentView, { fontFamily: fontFamilyForView(view) })
3998
+ const entry = {
3999
+ root,
4000
+ parentWindow,
4001
+ sheetWindow: null as any,
4002
+ closed: false,
4003
+ onDismissed: options?.onDismissed,
4004
+ }
4005
+
4006
+ const close = () => {
4007
+ if (entry.closed) {
4008
+ return
4009
+ }
4010
+
4011
+ entry.closed = true
4012
+ openSheets.delete(entry)
4013
+ try {
4014
+ if (parentWindow && entry.sheetWindow) {
4015
+ parentWindow.endSheet(entry.sheetWindow)
4016
+ } else if (entry.sheetWindow) {
4017
+ entry.sheetWindow.orderOut(null)
4018
+ }
4019
+ } catch (error) {
4020
+ console.error('[macos-leaf] sheet close failed', error)
4021
+ }
4022
+
4023
+ try {
4024
+ root.unmount()
4025
+ } catch (error) {
4026
+ console.error('[macos-leaf] sheet root unmount failed', error)
4027
+ }
4028
+ }
4029
+
4030
+ try {
4031
+ root.render(options.component, options.props ?? {})
4032
+ } catch (error) {
4033
+ console.error('[macos-leaf] sheet render failed', error)
4034
+ try {
4035
+ root.unmount()
4036
+ } catch {}
4037
+
4038
+ return null
4039
+ }
4040
+
4041
+ const controller = NSViewController.alloc().init()
4042
+ controller.view = contentView
4043
+ const fit = popupFittingSize(contentView)
4044
+ const width = Math.max(fit?.width ?? 0, options?.minWidth ?? 360)
4045
+ const height = Math.max(fit?.height ?? 0, options?.minHeight ?? 160)
4046
+
4047
+ const SM = typeof NSWindowStyleMask === 'object' && NSWindowStyleMask ? NSWindowStyleMask : {}
4048
+ const BT = typeof NSBackingStoreType === 'object' && NSBackingStoreType ? NSBackingStoreType : {}
4049
+ const sheet = NSWindow.alloc().initWithContentRectStyleMaskBackingDefer(
4050
+ { origin: { x: 0, y: 0 }, size: { width, height } },
4051
+ (SM.Titled ?? 1) | (SM.Closable ?? 2),
4052
+ BT.Buffered ?? 2,
4053
+ false,
4054
+ )
4055
+
4056
+ sheet.contentViewController = controller
4057
+ sheet.releasedWhenClosed = false
4058
+ if (sheetDelegateTarget) {
4059
+ sheet.delegate = sheetDelegateTarget
4060
+ }
4061
+
4062
+ entry.sheetWindow = sheet
4063
+ openSheets.add(entry)
4064
+
4065
+ try {
4066
+ if (parentWindow && typeof parentWindow.beginSheetCompletionHandler === 'function') {
4067
+ parentWindow.beginSheetCompletionHandler(sheet, () => finishSheetDismissal(sheet))
4068
+ } else {
4069
+ sheet.center?.()
4070
+ sheet.makeKeyAndOrderFront(null)
4071
+ }
4072
+ } catch (error) {
4073
+ console.error('[macos-leaf] sheet presentation failed', error)
4074
+ close()
4075
+ return null
4076
+ }
4077
+
4078
+ return {
4079
+ close,
4080
+ update(nextProps: PropBag) {
4081
+ try {
4082
+ root.render(options.component, nextProps ?? {})
4083
+ } catch (error) {
4084
+ console.error('[macos-leaf] sheet update failed', error)
4085
+ }
4086
+ },
4087
+ }
4088
+ }
4089
+
4090
+ const appKitBridge = (globalThis.__xplatAppKit ??= {})
4091
+ appKitBridge.observeHover = observeHover
4092
+ appKitBridge.showAnchoredPopup = showAnchoredPopup
4093
+ appKitBridge.showLayer = (options: PropBag) =>
4094
+ showWindowLayer(options, {
4095
+ createRoot: (view: NSView, anchor: NSView) =>
4096
+ createMacOSRoot(view, { fontFamily: fontFamilyForView(anchor) }),
4097
+ fittingSize: popupFittingSize,
4098
+ })
4099
+
4100
+ appKitBridge.attachContextMenu = attachContextMenu
4101
+ appKitBridge.attachDatePicker = attachDatePicker
4102
+ appKitBridge.presentSheet = presentSheet
4103
+ installPresentationBridge(appKitBridge, createMacOSRoot, fontFamilyForView)
4104
+
4105
+ const rootFontFamilies = new WeakMap<object, string | undefined>()
4106
+
4107
+ function fontFamilyForView(view: NSView | undefined): string | undefined {
4108
+ for (let current = view; current; current = current.superview) {
4109
+ if (rootFontFamilies.has(current)) {
4110
+ return rootFontFamilies.get(current)
4111
+ }
4112
+ }
4113
+
4114
+ return undefined
4115
+ }
4116
+
4117
+ export interface MacOSRootOptions {
4118
+ /** Default font family for this root and its renderer-hosted overlays. */
4119
+ fontFamily?: string
4120
+ }
4121
+
4122
+ export function createMacOSRoot(hostView: NSView, { fontFamily }: MacOSRootOptions = {}) {
4123
+ rootFontFamilies.set(hostView, fontFamily)
4124
+ const container: RootContainer = {
4125
+ hostView,
4126
+ fontFamily,
4127
+ nodes: new Map(),
4128
+ children: [],
4129
+ root: null,
4130
+ }
4131
+
4132
+ const root = createUniversalRoot(container, macOSDriver, {
4133
+ scheduleMicrotask: (callback) => queueMicrotask(callback),
4134
+ onUncaughtError: (error: any) =>
4135
+ console.error(
4136
+ '[macos-runtime] uncaught render error name=' +
4137
+ (error && error.name) +
4138
+ ' message=' +
4139
+ (error && error.message) +
4140
+ ' value=' +
4141
+ (typeof error === 'object' ? JSON.stringify(error) : String(error)) +
4142
+ ' stack=' +
4143
+ (error && error.stack),
4144
+ ),
4145
+ })
4146
+
4147
+ container.root = root
4148
+ if (process.env.OCTANE_MACOS_AUTOMATION === '1') {
4149
+ const debug = {
4150
+ findId(id: string) {
4151
+ return [...container.nodes.values()].find((node) => node.props.id === id)?.view ?? null
4152
+ },
4153
+ measureParity(facets: readonly string[]) {
4154
+ return measureParity(container, facets)
4155
+ },
4156
+ metrics() {
4157
+ const nodeTypes: Record<string, number> = {}
4158
+ let nativeViewCount = 0
4159
+ let mountedRowCount = 0
4160
+ let mappedRowCount = 0
4161
+ let parentedRowCount = 0
4162
+ let nativeAttachedRowCount = 0
4163
+ let firstMountedRow = Number.POSITIVE_INFINITY
4164
+ let lastMountedRow = -1
4165
+ let firstMappedRow = Number.POSITIVE_INFINITY
4166
+ let lastMappedRow = -1
4167
+ const mountedRows: { index: number; height: number }[] = []
4168
+ const scrollViews: PropBag[] = []
4169
+ for (const node of container.nodes.values()) {
4170
+ nodeTypes[node.type] = (nodeTypes[node.type] ?? 0) + 1
4171
+ if (node.view) {
4172
+ nativeViewCount += 1
4173
+ }
4174
+
4175
+ if (node.type === 'scrollview' && node.view) {
4176
+ const bounds = node.view.contentView.bounds
4177
+ scrollViews.push({
4178
+ id: node.props.id ?? null,
4179
+ verticalOffset: Number(bounds.origin.y ?? 0),
4180
+ viewportHeight: Number(bounds.size.height ?? 0),
4181
+ contentHeight: Number(node.view.documentView?.frame?.size?.height ?? 0),
4182
+ })
4183
+ }
4184
+
4185
+ const match = /^(?:row-r|bench-row-)(\d+)$/.exec(String(node.props.id ?? ''))
4186
+ if (!match) {
4187
+ continue
4188
+ }
4189
+
4190
+ mappedRowCount += 1
4191
+ const index = Number(match[1])
4192
+ firstMappedRow = Math.min(firstMappedRow, index)
4193
+ lastMappedRow = Math.max(lastMappedRow, index)
4194
+ if (node.parent !== null) {
4195
+ parentedRowCount += 1
4196
+ }
4197
+
4198
+ if (node.view?.superview != null) {
4199
+ nativeAttachedRowCount += 1
4200
+ }
4201
+
4202
+ if (node.parent !== null && node.view?.superview != null) {
4203
+ mountedRowCount += 1
4204
+ firstMountedRow = Math.min(firstMountedRow, index)
4205
+ lastMountedRow = Math.max(lastMountedRow, index)
4206
+ mountedRows.push({
4207
+ index,
4208
+ height: Number(node.view.frame?.size?.height ?? 0),
4209
+ })
4210
+ }
4211
+ }
4212
+
4213
+ return {
4214
+ nodeCount: container.nodes.size,
4215
+ nativeViewCount,
4216
+ mountedRowCount,
4217
+ mappedRowCount,
4218
+ parentedRowCount,
4219
+ nativeAttachedRowCount,
4220
+ firstMountedRow: Number.isFinite(firstMountedRow) ? firstMountedRow : null,
4221
+ lastMountedRow: lastMountedRow >= 0 ? lastMountedRow : null,
4222
+ firstMappedRow: Number.isFinite(firstMappedRow) ? firstMappedRow : null,
4223
+ lastMappedRow: lastMappedRow >= 0 ? lastMappedRow : null,
4224
+ mountedRows,
4225
+ scrollViews,
4226
+ nodeTypes,
4227
+ }
4228
+ },
4229
+ scrollToId(id: string, offset: number) {
4230
+ const node = [...container.nodes.values()].find(
4231
+ (candidate) => candidate.type === 'scrollview' && candidate.props.id === id,
4232
+ )
4233
+
4234
+ if (!node) {
4235
+ throw new Error('No AppKit ScrollView with id ' + id)
4236
+ }
4237
+
4238
+ const clipView = node.view!.contentView
4239
+ clipView.scrollToPoint({ x: 0, y: Math.max(0, Number(offset) || 0) })
4240
+ node.view!.reflectScrolledClipView(clipView)
4241
+ return Number(clipView.bounds.origin.y ?? 0)
4242
+ },
4243
+ /** Web-style scroll offset: `top` is the distance from the
4244
+ * document's visual top (scrollTop semantics). Non-flipped
4245
+ * AppKit documents count clip origin y up from the bottom, so
4246
+ * the conversion inverts through the doc/clip heights. */
4247
+ scrollToTop(id: string, top: number) {
4248
+ const node = [...container.nodes.values()].find(
4249
+ (candidate) => candidate.type === 'scrollview' && candidate.props.id === id,
4250
+ )
4251
+
4252
+ if (!node) {
4253
+ throw new Error('No AppKit ScrollView with id ' + id)
4254
+ }
4255
+
4256
+ const clipView = node.view!.contentView
4257
+ const doc = node.view!.documentView
4258
+ const docH = Number(doc.frame.size.height)
4259
+ const clipH = Number(clipView.bounds.size.height)
4260
+ const flip = typeof doc.isFlipped === 'function' ? doc.isFlipped() : doc.isFlipped
4261
+ const flipped = flip === true || Number(flip) === 1
4262
+ const t = Math.min(Math.max(Number(top) || 0, 0), Math.max(0, docH - clipH))
4263
+ const y = flipped ? t : docH - clipH - t
4264
+ clipView.scrollToPoint({ x: 0, y })
4265
+ node.view!.reflectScrolledClipView(clipView)
4266
+ return flipped
4267
+ ? Number(clipView.bounds.origin.y)
4268
+ : docH - clipH - Number(clipView.bounds.origin.y)
4269
+ },
4270
+ /** VirtualListBenchSnapshot for the scrollview with `id`: scrollTop-
4271
+ * style offset plus each mounted vlist-bench-row-* row's top-down
4272
+ * box relative to the viewport. Consumed by the app-side
4273
+ * platform/virtual-list-benchmark.macos adapter. */
4274
+ listSnapshot(id: string) {
4275
+ const node = [...container.nodes.values()].find(
4276
+ (candidate) => candidate.type === 'scrollview' && candidate.props.id === id,
4277
+ )
4278
+
4279
+ if (!node) {
4280
+ throw new Error('No AppKit ScrollView with id ' + id)
4281
+ }
4282
+
4283
+ const clipView = node.view!.contentView
4284
+ const doc = node.view!.documentView
4285
+ const clipH = Number(clipView.bounds.size.height)
4286
+ const docH = Number(doc?.frame?.size?.height ?? 0)
4287
+ const flip = typeof doc?.isFlipped === 'function' ? doc.isFlipped() : doc?.isFlipped
4288
+ const flipped = flip === true || Number(flip) === 1
4289
+ const originY = Number(clipView.bounds.origin.y ?? 0)
4290
+ // The VirtualList leaf publishes a content-space offset (anchored
4291
+ // to its top spacer, not the document frame) — prefer it since
4292
+ // docH-derived offsets drift every time estimates rebuild.
4293
+ const published = globalThis.__xplatVlistOffsets?.[id]
4294
+ const offset =
4295
+ typeof published === 'number' ? published : flipped ? originY : docH - clipH - originY
4296
+
4297
+ const rows = descendants(node).flatMap((child) => {
4298
+ const match = /^vlist-bench-row-(\d+)$/.exec(String(child.props?.id ?? ''))
4299
+ if (!match || !child.view) {
4300
+ return []
4301
+ }
4302
+
4303
+ try {
4304
+ // Row box relative to the clip's top edge, computed in doc
4305
+ // coordinates so it stays docH-independent like `offset`.
4306
+ const inDoc = child.view.superview.convertRectToView(child.view.frame, doc)
4307
+ const h = Number(inDoc.size.height)
4308
+ const y = Number(inDoc.origin.y)
4309
+ const top = flipped ? y - originY : originY + clipH - y - h
4310
+ return [{ index: Number(match[1]), top, bottom: top + h }]
4311
+ } catch {
4312
+ return []
4313
+ }
4314
+ })
4315
+
4316
+ return {
4317
+ offset,
4318
+ viewportHeight: clipH,
4319
+ rows,
4320
+ mountedIndices: rows.map((row) => row.index),
4321
+ }
4322
+ },
4323
+ /** Node frame in window base coordinates (points, y-up from the
4324
+ * window's bottom edge) — screenshot cropping consumes it. */
4325
+ frameInWindow(id: string) {
4326
+ if (id === ':host') {
4327
+ const winFrame = container.hostView.window?.frame
4328
+ return {
4329
+ x: 0,
4330
+ y: 0,
4331
+ w: Number(container.hostView.frame.size.width),
4332
+ h: Number(container.hostView.frame.size.height),
4333
+ isContentView: container.hostView.window?.contentView === container.hostView,
4334
+ autoresizingMask: Number(container.hostView.autoresizingMask ?? -1),
4335
+ translatesMask: Boolean(container.hostView.translatesAutoresizingMaskIntoConstraints),
4336
+ subviews: Number(container.hostView.subviews?.length ?? 0),
4337
+ window: winFrame
4338
+ ? {
4339
+ w: Number(winFrame.size.width),
4340
+ h: Number(winFrame.size.height),
4341
+ }
4342
+ : null,
4343
+ }
4344
+ }
4345
+
4346
+ const node = [...container.nodes.values()].find(
4347
+ (candidate) => candidate.props.id === id && candidate.view,
4348
+ )
4349
+
4350
+ if (!node) {
4351
+ throw new Error('No AppKit view with id ' + id)
4352
+ }
4353
+
4354
+ const rect = node.view!.convertRectToView(node.view!.bounds, null)
4355
+ return {
4356
+ x: Number(rect.origin.x),
4357
+ y: Number(rect.origin.y),
4358
+ w: Number(rect.size.width),
4359
+ h: Number(rect.size.height),
4360
+ }
4361
+ },
4362
+ /** Apply a single style prop on a mounted node — margin/layout probes. */
4363
+ setStyle(id: string, name: string, value: any) {
4364
+ const node = [...container.nodes.values()].find((candidate) => candidate.props.id === id)
4365
+
4366
+ if (!node) {
4367
+ throw new Error('No AppKit node with id ' + id)
4368
+ }
4369
+
4370
+ applyStyle(node, { [name]: value })
4371
+ container.hostView.layoutSubtreeIfNeeded?.()
4372
+ return this.frameInWindow(id)
4373
+ },
4374
+ /** Ancestor chain for a node id with frames — layout forensics. */
4375
+ ancestors(id: string) {
4376
+ const node = [...container.nodes.values()].find((candidate) => candidate.props.id === id)
4377
+
4378
+ if (!node) {
4379
+ throw new Error('No AppKit node with id ' + id)
4380
+ }
4381
+
4382
+ const chain: PropBag[] = []
4383
+ for (let cur: ElementNode | null = node; cur; cur = cur.parent) {
4384
+ chain.push({
4385
+ id: cur.props.id ?? null,
4386
+ type: cur.type,
4387
+ classes: nodeClasses(cur),
4388
+ frame: cur.view
4389
+ ? {
4390
+ x: Number(cur.view.frame.origin.x),
4391
+ y: Number(cur.view.frame.origin.y),
4392
+ w: Number(cur.view.frame.size.width),
4393
+ h: Number(cur.view.frame.size.height),
4394
+ }
4395
+ : null,
4396
+ })
4397
+ }
4398
+
4399
+ return chain
4400
+ },
4401
+ /** Per-cell geometry for the parity stage inside its ScrollView:
4402
+ * top-down document offsets plus the window-space rect at the
4403
+ * current scroll position, so a screenshot pass can choose
4404
+ * offsets and crop without re-querying the host. */
4405
+ parityCellFrames(scrollId: string) {
4406
+ const scroll = [...container.nodes.values()].find(
4407
+ (candidate) => candidate.type === 'scrollview' && candidate.props.id === scrollId,
4408
+ )
4409
+
4410
+ if (!scroll) {
4411
+ throw new Error('No AppKit ScrollView with id ' + scrollId)
4412
+ }
4413
+
4414
+ const stage = [...container.nodes.values()].find(
4415
+ (candidate) => candidate.props.id === 'parity-stage',
4416
+ )
4417
+
4418
+ if (!stage) {
4419
+ throw new Error('No parity stage mounted')
4420
+ }
4421
+
4422
+ container.hostView.window?.contentView?.layoutSubtreeIfNeeded?.()
4423
+ scroll.view!.layoutSubtreeIfNeeded?.()
4424
+ const doc = scroll.view!.documentView
4425
+ const clip = scroll.view!.contentView
4426
+ const docH = Number(doc.frame.size.height)
4427
+ const clipH = Number(clip.bounds.size.height)
4428
+ const flip = typeof doc.isFlipped === 'function' ? doc.isFlipped() : doc.isFlipped
4429
+ const flipped = flip === true || Number(flip) === 1
4430
+ const cells = []
4431
+ for (const cell of stage.children) {
4432
+ if (!cell.view || !nodeClasses(cell).includes('parity-cell')) {
4433
+ continue
4434
+ }
4435
+
4436
+ const inDoc = cell.view.convertRectToView(cell.view.bounds, doc)
4437
+ const inWindow = cell.view.convertRectToView(cell.view.bounds, null)
4438
+ cells.push({
4439
+ name: String(cell.props.id ?? '').replace(/^cell-/, ''),
4440
+ top: flipped
4441
+ ? Number(inDoc.origin.y)
4442
+ : docH - Number(inDoc.origin.y) - Number(inDoc.size.height),
4443
+ height: Number(inDoc.size.height),
4444
+ width: Number(inDoc.size.width),
4445
+ x: Number(inDoc.origin.x),
4446
+ window: {
4447
+ x: Number(inWindow.origin.x),
4448
+ y: Number(inWindow.origin.y),
4449
+ w: Number(inWindow.size.width),
4450
+ h: Number(inWindow.size.height),
4451
+ },
4452
+ })
4453
+ }
4454
+
4455
+ const scrollInWindow = scroll.view!.convertRectToView(scroll.view!.bounds, null)
4456
+ return {
4457
+ docHeight: docH,
4458
+ viewportHeight: clipH,
4459
+ docFlipped: flipped,
4460
+ scrollTop: flipped
4461
+ ? Number(clip.bounds.origin.y)
4462
+ : docH - clipH - Number(clip.bounds.origin.y),
4463
+ scrollWindow: {
4464
+ x: Number(scrollInWindow.origin.x),
4465
+ y: Number(scrollInWindow.origin.y),
4466
+ w: Number(scrollInWindow.size.width),
4467
+ h: Number(scrollInWindow.size.height),
4468
+ },
4469
+ cells,
4470
+ }
4471
+ },
4472
+ scrollStats(id: string) {
4473
+ const node = [...container.nodes.values()].find(
4474
+ (candidate) => candidate.type === 'scrollview' && candidate.props.id === id,
4475
+ )
4476
+
4477
+ if (!node) {
4478
+ throw new Error('No AppKit ScrollView with id ' + id)
4479
+ }
4480
+
4481
+ return { ...(node.scrollMetrics ?? { events: [] }) }
4482
+ },
4483
+ inspect(id: string) {
4484
+ const node = id.startsWith('type:')
4485
+ ? [...container.nodes.values()].find(
4486
+ (candidate) => candidate.type === id.slice(5) && candidate.view,
4487
+ )
4488
+ : [...container.nodes.values()].find((candidate) => candidate.props?.id === id)
4489
+
4490
+ if (!node?.view) {
4491
+ throw new Error('No AppKit view with id ' + id)
4492
+ }
4493
+
4494
+ const view = node.view
4495
+ const appearance = view.effectiveAppearance ?? view.window?.effectiveAppearance
4496
+ const nativeWindow = view.window
4497
+ return {
4498
+ type: node.type,
4499
+ classes: nodeClasses(node),
4500
+ scheme: node.scheme ?? 'light',
4501
+ color: view.textColor ? (colorValue(view.textColor, appearance) ?? null) : null,
4502
+ backgroundColor: view.layer?.backgroundColor
4503
+ ? (colorValue(NSColor.colorWithCGColor(view.layer.backgroundColor), appearance) ?? null)
4504
+ : null,
4505
+ frame: view.frame
4506
+ ? {
4507
+ x: round(Number(view.frame.origin.x)),
4508
+ y: round(Number(view.frame.origin.y)),
4509
+ w: round(Number(view.frame.size.width)),
4510
+ h: round(Number(view.frame.size.height)),
4511
+ }
4512
+ : null,
4513
+ window: nativeWindow
4514
+ ? {
4515
+ frame: {
4516
+ x: round(Number(nativeWindow.frame.origin.x)),
4517
+ y: round(Number(nativeWindow.frame.origin.y)),
4518
+ w: round(Number(nativeWindow.frame.size.width)),
4519
+ h: round(Number(nativeWindow.frame.size.height)),
4520
+ },
4521
+ contentSize: nativeWindow.contentView?.frame?.size
4522
+ ? {
4523
+ w: round(Number(nativeWindow.contentView.frame.size.width)),
4524
+ h: round(Number(nativeWindow.contentView.frame.size.height)),
4525
+ }
4526
+ : null,
4527
+ fittingSize: nativeWindow.contentView?.fittingSize
4528
+ ? {
4529
+ w: round(Number(nativeWindow.contentView.fittingSize.width)),
4530
+ h: round(Number(nativeWindow.contentView.fittingSize.height)),
4531
+ }
4532
+ : null,
4533
+ styleMask: Number(nativeWindow.styleMask ?? 0),
4534
+ colorScheme: globalThis.__xplatAppKit?.getColorScheme?.() ?? null,
4535
+ }
4536
+ : null,
4537
+ }
4538
+ },
4539
+ snapshot() {
4540
+ return {
4541
+ labels: [...container.nodes.values()]
4542
+ .filter((node) => node.type === 'label' && node.view)
4543
+ .map((node) => node.view!.stringValue),
4544
+ buttons: [...container.nodes.values()]
4545
+ .filter((node) => node.type === 'button' && node.view)
4546
+ .map((node) => node.view!.title),
4547
+ pressables: [...container.nodes.values()]
4548
+ .filter(
4549
+ (node) =>
4550
+ node.type === 'flexboxlayout' &&
4551
+ node.actionId !== undefined &&
4552
+ accessibilityRoles.get(node.actionId!) === 'button',
4553
+ )
4554
+ .map((node) => accessibilityLabels.get(node.actionId!)),
4555
+ }
4556
+ },
4557
+ /** Dispatch through the same event scope as the AppKit pan recognizer. */
4558
+ panView(view: any, event: PanEvent) {
4559
+ const handler = panHandlersByView.get(view)
4560
+ if (!handler) {
4561
+ throw new Error('No AppKit pan handler attached')
4562
+ }
4563
+
4564
+ handler({ ...event, view })
4565
+ },
4566
+ pressId(id: string) {
4567
+ const node = [...container.nodes.values()].find((candidate) => candidate.props.id === id)
4568
+ if (!node || node.actionId === undefined) {
4569
+ throw new Error('No AppKit pressable with id ' + id)
4570
+ }
4571
+
4572
+ invokeAction(node.actionId)
4573
+ },
4574
+ setText(idOrPlaceholder: string, value: any) {
4575
+ const node = [...container.nodes.values()].find(
4576
+ (candidate) =>
4577
+ (candidate.type === 'textfield' || candidate.type === 'textview') &&
4578
+ (candidate.props.id === idOrPlaceholder ||
4579
+ candidate.props.placeholder === idOrPlaceholder),
4580
+ )
4581
+
4582
+ if (!node || node.actionId === undefined) {
4583
+ throw new Error('No AppKit text input with id or placeholder ' + idOrPlaceholder)
4584
+ }
4585
+
4586
+ if (node.type === 'textview') {
4587
+ node.view!.string = String(value)
4588
+ buttonActionTarget.textDidChange({ object: node.view } as NSNotification)
4589
+ } else {
4590
+ node.view!.stringValue = String(value)
4591
+ buttonActionTarget.controlChanged(node.view!)
4592
+ }
4593
+ },
4594
+ pressAccessibilityLabel(label: string) {
4595
+ const node = [...container.nodes.values()].find(
4596
+ (candidate) =>
4597
+ candidate.type === 'flexboxlayout' &&
4598
+ candidate.actionId !== undefined &&
4599
+ accessibilityLabels.get(candidate.actionId) === label,
4600
+ )
4601
+
4602
+ if (!node) {
4603
+ throw new Error('No AppKit pressable labeled ' + label)
4604
+ }
4605
+
4606
+ if (!node.view!.accessibilityPerformPress()) {
4607
+ throw new Error('AppKit pressable has no action for ' + label)
4608
+ }
4609
+ },
4610
+ pressButton(title: string) {
4611
+ const node = [...container.nodes.values()].find(
4612
+ (candidate) => candidate.type === 'button' && candidate.view!.title === title,
4613
+ )
4614
+
4615
+ if (!node) {
4616
+ throw new Error('No AppKit button titled ' + title)
4617
+ }
4618
+
4619
+ node.view!.performClick(null)
4620
+ },
4621
+ hover(id: string, phase: 'enter' | 'exit') {
4622
+ const node = [...container.nodes.values()].find(
4623
+ (candidate) => candidate.props?.id === id && candidate.view,
4624
+ )
4625
+
4626
+ if (!node) {
4627
+ throw new Error('No AppKit view with id ' + id)
4628
+ }
4629
+
4630
+ const record = hoverRecordByView.get(node.view!)
4631
+ if (!record) {
4632
+ throw new Error('No hover observer on ' + id)
4633
+ }
4634
+
4635
+ const handler = phase === 'exit' ? record.exit : record.enter
4636
+ if (typeof handler !== 'function') {
4637
+ throw new Error('No ' + phase + ' handler on ' + id)
4638
+ }
4639
+
4640
+ handler()
4641
+ },
4642
+ openPopupCount() {
4643
+ return openPopups.size
4644
+ },
4645
+ }
4646
+
4647
+ Object.defineProperty(root, '__macosDebug', { value: debug })
4648
+ Object.defineProperty(globalThis, '__xplatMacOSDebug', { value: debug, configurable: true })
4649
+ }
4650
+
4651
+ return root
4652
+ }
4653
+
4654
+ export * from 'octane/universal/native'