@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/LICENSE +21 -0
- package/README.md +232 -3
- package/package.json +38 -3
- package/src/appkit.d.ts +199 -0
- package/src/fonts.ts +100 -0
- package/src/image.ts +23 -0
- package/src/index.d.ts +18 -0
- package/src/index.ts +4654 -0
- package/src/intrinsics.d.ts +62 -0
- package/src/jsx-runtime.d.ts +7 -0
- package/src/layer.d.mts +17 -0
- package/src/layer.mjs +221 -0
- package/src/native-core-shim.ts +47 -0
- package/src/native-escape-props.ts +30 -0
- package/src/presentation.mjs +501 -0
- package/src/webview.ts +252 -0
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'
|