@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.
@@ -0,0 +1,501 @@
1
+ // Shared surfaces live in the declaring window, independently of its layout.
2
+ // Kept separate from NSPopover and platform-authentic window sheets.
3
+ export function installPresentationBridge(bridge, createRoot, fontFamilyForView) {
4
+ const entries = []
5
+ const layers = new WeakMap()
6
+ class PresentationLayer extends NSView {
7
+ static {
8
+ NativeClass(this)
9
+ }
10
+ acceptsFirstResponder() {
11
+ return true
12
+ }
13
+ hitTest(point) {
14
+ const hit = super.hitTest(point)
15
+ const entry = layers.get(this)
16
+ return (hit === this || hit?.isEqual?.(this)) && !entry?.options.modal ? null : hit
17
+ }
18
+ layout() {
19
+ super.layout()
20
+ layers.get(this)?.layout()
21
+ }
22
+ }
23
+
24
+ const children = (view) => {
25
+ const result = []
26
+ const list = view?.subviews
27
+ for (let i = 0; i < Number(list?.count ?? 0); i++) {
28
+ result.push(list.objectAtIndex(i))
29
+ }
30
+
31
+ return result
32
+ }
33
+
34
+ const truth = (view, key) => (typeof view?.[key] === 'function' ? view[key]() : view?.[key])
35
+ const focusables = (view) => {
36
+ if (!view || truth(view, 'hidden')) {
37
+ return []
38
+ }
39
+
40
+ const own = truth(view, 'acceptsFirstResponder') && view.enabled !== false ? [view] : []
41
+ return [...own, ...children(view).flatMap(focusables)]
42
+ }
43
+
44
+ const inside = (view, panel) => {
45
+ for (let current = view; current; current = current.superview) {
46
+ if (current === panel || current.isEqual?.(panel)) {
47
+ return true
48
+ }
49
+ }
50
+
51
+ return false
52
+ }
53
+
54
+ const length = (value, budget, fallback) => {
55
+ if (typeof value === 'number') {
56
+ return Math.max(0, value)
57
+ }
58
+
59
+ if (typeof value !== 'string') {
60
+ return fallback
61
+ }
62
+
63
+ const match = value.trim().match(/^([\d.]+)(%|px|dip|dvh|vh)?$/)
64
+ return match
65
+ ? Number(match[1]) * (['%', 'dvh', 'vh'].includes(match[2]) ? budget / 100 : 1)
66
+ : fallback
67
+ }
68
+
69
+ bridge.setSurfaceAppearance = (view, dark) => {
70
+ const entry = entries.find((entry) => inside(view, entry.panel))
71
+ if (!entry) {
72
+ return
73
+ }
74
+
75
+ entry.options.dark = dark
76
+ entry.layout()
77
+ }
78
+
79
+ bridge.presentSurface = (initial) => {
80
+ const owner = initial.owner?.native ?? initial.owner
81
+ const nativeWindow = owner?.window ?? NSApplication.sharedApplication.keyWindow
82
+ const host = nativeWindow?.contentView
83
+ if (!host || typeof initial.component !== 'function') {
84
+ throw new Error('AppKit presentation requires a mounted window and component')
85
+ }
86
+
87
+ const layer = PresentationLayer.alloc().initWithFrame(host.bounds)
88
+ layer.autoresizingMask = 18 // width + height
89
+ layer.wantsLayer = true
90
+ const panel = NSView.alloc().initWithFrame(host.bounds)
91
+ panel.wantsLayer = true
92
+ layer.addSubview(panel)
93
+ const root = createRoot(panel, { fontFamily: fontFamilyForView(owner ?? host) })
94
+ let resolveClosed
95
+ const responder = nativeWindow.firstResponder
96
+ const previousFocus = truth(responder, 'isFieldEditor') ? responder.delegate : responder
97
+ const entry = {
98
+ options: initial,
99
+ parent: [...entries]
100
+ .reverse()
101
+ .find(
102
+ (entry) =>
103
+ entry.window === nativeWindow &&
104
+ (owner ? inside(owner, entry.panel) : entry.options.modal),
105
+ ),
106
+ panel,
107
+ layer,
108
+ window: nativeWindow,
109
+ closed: false,
110
+ observers: [],
111
+ monitor: null,
112
+ closedPromise: new Promise((resolve) => {
113
+ resolveClosed = resolve
114
+ }),
115
+ layout() {
116
+ if (entry.closed) {
117
+ return
118
+ }
119
+
120
+ const options = entry.options
121
+ const bounds = layer.bounds.size
122
+ const w = Number(bounds.width),
123
+ h = Number(bounds.height)
124
+
125
+ const content = panel.subviews?.objectAtIndex?.(0)
126
+ const fit = content?.documentView?.fittingSize ?? content?.fittingSize ?? panel.fittingSize
127
+ const full = options.kind === 'lightbox' || options.fullscreen
128
+ const width = full
129
+ ? w
130
+ : Math.min(w, length(options.width, w, options.kind === 'sheet' ? w : 400))
131
+
132
+ let height = full
133
+ ? h
134
+ : Math.min(
135
+ h,
136
+ length(options.maxHeight, h, h * 0.75),
137
+ Math.max(80, Number(fit?.height ?? 160)),
138
+ )
139
+
140
+ if (options.kind === 'sheet') {
141
+ const budget =
142
+ options.height === 'tall'
143
+ ? h * 0.92
144
+ : options.height === 'hug'
145
+ ? height
146
+ : length(options.height, h, h * 0.62)
147
+
148
+ const stops = (options.snapPoints ?? [])
149
+ .map((p) => (typeof p === 'number' && p <= 1 ? p * h : length(p, h, 0)))
150
+ .filter((p) => p > 0)
151
+ .map((p) => Math.min(p, h))
152
+ .sort((a, b) => a - b)
153
+
154
+ entry.stops = [...new Set(stops)]
155
+ height = entry.stops.length
156
+ ? entry.stops[Math.min(entry.stop ?? 0, entry.stops.length - 1)]
157
+ : Math.min(h * 0.92, budget)
158
+ }
159
+
160
+ let x = (w - width) / 2,
161
+ y = (h - height) / 2
162
+
163
+ if (options.kind === 'sheet') {
164
+ y = 0
165
+ }
166
+
167
+ if (options.kind === 'toast') {
168
+ const edge = options.position ?? 'bottomEnd',
169
+ inset = options.inset ?? {}
170
+
171
+ x = edge.endsWith('Start') ? 16 + (inset.start ?? 0) : w - width - 16 - (inset.end ?? 0)
172
+ y = edge.startsWith('top') ? h - height - 16 - (inset.top ?? 0) : 16 + (inset.bottom ?? 0)
173
+ }
174
+
175
+ const pos =
176
+ !full && options.position && typeof options.position === 'object' ? options.position : {}
177
+
178
+ if (pos.start != null) {
179
+ x = length(pos.start, w, 0)
180
+ } else if (pos.end != null) {
181
+ x = w - width - length(pos.end, w, 0)
182
+ }
183
+
184
+ if (pos.top != null) {
185
+ y = h - height - length(pos.top, h, 0)
186
+ } else if (pos.bottom != null) {
187
+ y = length(pos.bottom, h, 0)
188
+ }
189
+
190
+ panel.frame = { origin: { x: Math.max(0, x), y: Math.max(0, y) }, size: { width, height } }
191
+ layer.layer.backgroundColor = NSColor.colorWithRedGreenBlueAlpha(
192
+ 0,
193
+ 0,
194
+ 0,
195
+ options.modal ? 0.35 : 0,
196
+ ).CGColor
197
+
198
+ const surfaceColor = options.dark ? 10 / 255 : 1
199
+ panel.layer.backgroundColor = NSColor.colorWithRedGreenBlueAlpha(
200
+ surfaceColor,
201
+ surfaceColor,
202
+ surfaceColor,
203
+ 1,
204
+ ).CGColor
205
+
206
+ panel.layer.cornerRadius = full ? 0 : 12
207
+ panel.layer.masksToBounds = true
208
+ if (typeof NSAppearance !== 'undefined' && options.dark != null) {
209
+ panel.appearance = NSAppearance.appearanceNamed(
210
+ options.dark ? 'NSAppearanceNameDarkAqua' : 'NSAppearanceNameAqua',
211
+ )
212
+ }
213
+
214
+ panel.accessibilityLabel = options.label ?? (options.kind === 'sheet' ? 'Sheet' : 'Dialog')
215
+
216
+ panel.accessibilityRole = 'AXGroup'
217
+ },
218
+ update(options) {
219
+ if (entry.closed) {
220
+ return
221
+ }
222
+
223
+ const transition =
224
+ entry.didRender &&
225
+ entry.options.kind === 'sheet' &&
226
+ options.transitionKey !== entry.options.transitionKey
227
+
228
+ const hadFocus =
229
+ inside(nativeWindow.firstResponder, panel) ||
230
+ inside(nativeWindow.firstResponder?.delegate, panel) ||
231
+ nativeWindow.firstResponder === layer
232
+
233
+ entry.options = { ...entry.options, ...options }
234
+ root.render(entry.options.component, entry.options.props ?? {})
235
+ entry.layout()
236
+ entry.didRender = true
237
+ if (hadFocus && entry.options.modal) {
238
+ queueMicrotask(() => {
239
+ const top = [...entries]
240
+ .reverse()
241
+ .find((e) => e.window === nativeWindow && e.options.modal && !e.closed)
242
+
243
+ if (
244
+ top === entry &&
245
+ !inside(nativeWindow.firstResponder, panel) &&
246
+ !inside(nativeWindow.firstResponder?.delegate, panel)
247
+ ) {
248
+ nativeWindow.makeFirstResponder(focusables(panel)[0] ?? layer)
249
+ }
250
+ })
251
+ }
252
+
253
+ if (transition && typeof CABasicAnimation !== 'undefined') {
254
+ const animation = CABasicAnimation.animationWithKeyPath('opacity')
255
+ animation.fromValue = NSNumber.numberWithDouble(0.65)
256
+ animation.toValue = NSNumber.numberWithDouble(1)
257
+ animation.duration = 0.18
258
+ panel.layer.addAnimationForKey(animation, 'xplat-sheet-switch')
259
+ }
260
+
261
+ if (entry.options.kind === 'lightbox') {
262
+ const visit = (view) => {
263
+ if ('allowsMagnification' in view) {
264
+ view.allowsMagnification = entry.options.zoom !== false
265
+ view.minMagnification = 1
266
+ view.maxMagnification = 4
267
+ } else {
268
+ for (const child of children(view)) {
269
+ visit(child)
270
+ }
271
+ }
272
+ }
273
+
274
+ visit(panel)
275
+ }
276
+ },
277
+ close(reason) {
278
+ if (entry.closed) {
279
+ return
280
+ }
281
+
282
+ entry.closed = true
283
+ const owns = (candidate) => {
284
+ for (let current = candidate; current; current = current.parent) {
285
+ if (current === entry) {
286
+ return true
287
+ }
288
+ }
289
+
290
+ return false
291
+ }
292
+
293
+ const hadFocus = entries
294
+ .filter(owns)
295
+ .some(
296
+ (candidate) =>
297
+ nativeWindow.firstResponder === candidate.layer ||
298
+ nativeWindow.firstResponder?.isEqual?.(candidate.layer) ||
299
+ inside(nativeWindow.firstResponder, candidate.panel) ||
300
+ inside(nativeWindow.firstResponder?.delegate, candidate.panel),
301
+ )
302
+
303
+ for (const child of [...entries].reverse()) {
304
+ if (child.parent === entry) {
305
+ child.close(reason === 'owner' ? 'owner' : 'ancestor')
306
+ }
307
+ }
308
+
309
+ entries.splice(entries.indexOf(entry), 1)
310
+ if (entry.monitor) {
311
+ NSEvent.removeMonitor(entry.monitor)
312
+ }
313
+
314
+ for (const observer of entry.observers) {
315
+ NSNotificationCenter.defaultCenter.removeObserver(observer)
316
+ }
317
+
318
+ try {
319
+ root.unmount()
320
+ } finally {
321
+ layer.removeFromSuperview()
322
+ layers.delete(layer)
323
+ const target = entry.options.finalFocusRef?.current
324
+ if (hadFocus && reason !== 'owner' && reason !== 'ancestor') {
325
+ if (typeof target?.focus === 'function') {
326
+ target.focus()
327
+ } else if (
328
+ nativeWindow.makeFirstResponder(target?.native ?? target ?? previousFocus ?? host) ===
329
+ false
330
+ ) {
331
+ nativeWindow.makeFirstResponder(null)
332
+ }
333
+ }
334
+
335
+ resolveClosed(reason)
336
+ }
337
+
338
+ if (reason === 'user') {
339
+ entry.options.onDismiss?.()
340
+ }
341
+ },
342
+ requestDismiss(path) {
343
+ const purpose = entry.options.purpose ?? 'info'
344
+ if (purpose === 'required' || (path === 'outside' && purpose !== 'info')) {
345
+ return
346
+ }
347
+
348
+ if (entry.options.canDismiss?.(path) === false) {
349
+ return
350
+ }
351
+
352
+ entry.close('user')
353
+ },
354
+ }
355
+
356
+ layers.set(layer, entry)
357
+ entries.push(entry)
358
+ try {
359
+ host.addSubview(layer)
360
+ entry.update(initial)
361
+ entry.observers.push(
362
+ NSNotificationCenter.defaultCenter.addObserverForNameObjectQueueUsingBlock(
363
+ 'NSWindowWillCloseNotification',
364
+ nativeWindow,
365
+ null,
366
+ () => entry.close('owner'),
367
+ ),
368
+ )
369
+
370
+ entry.monitor = NSEvent.addLocalMonitorForEventsMatchingMaskHandler(
371
+ 1024 | 2 | 8 | 64 | 4,
372
+ (event) => {
373
+ if (
374
+ entry.closed ||
375
+ !(event.window === nativeWindow || event.window?.isEqual?.(nativeWindow))
376
+ ) {
377
+ return event
378
+ }
379
+
380
+ const top = [...entries]
381
+ .reverse()
382
+ .find((e) => e.window === nativeWindow && e.options.modal && !e.closed)
383
+
384
+ if (top && top !== entry) {
385
+ return event
386
+ }
387
+
388
+ const type = Number(event.type)
389
+ if (type === 10) {
390
+ if (!entry.options.modal && !inside(nativeWindow.firstResponder, panel)) {
391
+ return event
392
+ }
393
+
394
+ const code = Number(event.keyCode)
395
+ if (code === 53) {
396
+ entry.requestDismiss('escape')
397
+ return null
398
+ }
399
+
400
+ if (code === 48 && entry.options.modal) {
401
+ const controls = focusables(panel),
402
+ current = nativeWindow.firstResponder
403
+
404
+ const i = controls.findIndex(
405
+ (v) =>
406
+ v === current ||
407
+ v === current?.delegate ||
408
+ v.isEqual?.(current?.delegate ?? current),
409
+ )
410
+
411
+ const next =
412
+ controls[
413
+ (i + (Number(event.modifierFlags) & 131072 ? -1 : 1) + controls.length) %
414
+ controls.length
415
+ ]
416
+
417
+ nativeWindow.makeFirstResponder(next ?? layer)
418
+ return null
419
+ }
420
+
421
+ if (entry.options.onKey?.(code) === true) {
422
+ return null
423
+ }
424
+
425
+ return event
426
+ }
427
+
428
+ const point = panel.convertPointFromView(event.locationInWindow, null)
429
+ const bounds = panel.bounds.size
430
+ const within =
431
+ point.x >= 0 && point.y >= 0 && point.x <= bounds.width && point.y <= bounds.height
432
+
433
+ if (
434
+ type === 1 &&
435
+ within &&
436
+ entry.options.kind === 'sheet' &&
437
+ point.y >= bounds.height - 24
438
+ ) {
439
+ entry.drag = { y: event.locationInWindow.y, height: panel.frame.size.height }
440
+ return null
441
+ }
442
+
443
+ if (entry.drag && type === 6) {
444
+ const height = Math.max(
445
+ 24,
446
+ Math.min(
447
+ entry.stops?.at(-1) ?? layer.bounds.size.height * 0.92,
448
+ entry.drag.height + event.locationInWindow.y - entry.drag.y,
449
+ ),
450
+ )
451
+
452
+ panel.frame = { origin: panel.frame.origin, size: { width: bounds.width, height } }
453
+ return null
454
+ }
455
+
456
+ if (entry.drag && type === 2) {
457
+ entry.drag = null
458
+ const height = panel.frame.size.height
459
+ if (height < (entry.stops?.[0] ?? 120) * 0.5) {
460
+ entry.requestDismiss('drag')
461
+ } else if (entry.stops?.length) {
462
+ entry.stop = entry.stops.reduce(
463
+ (best, stop, i, stops) =>
464
+ Math.abs(stop - height) < Math.abs(stops[best] - height) ? i : best,
465
+ 0,
466
+ )
467
+ }
468
+
469
+ entry.layout()
470
+ return null
471
+ }
472
+
473
+ if (!within && entry.options.modal && (type === 1 || type === 3)) {
474
+ entry.requestDismiss('outside')
475
+ return null
476
+ }
477
+
478
+ return event
479
+ },
480
+ )
481
+
482
+ if (initial.modal) {
483
+ nativeWindow.makeFirstResponder(focusables(panel)[0] ?? layer)
484
+ }
485
+ } catch (error) {
486
+ entry.close()
487
+ throw error
488
+ }
489
+
490
+ return {
491
+ update: entry.update,
492
+ close: entry.close,
493
+ get closed() {
494
+ return entry.closed
495
+ },
496
+ closedPromise: entry.closedPromise,
497
+ panel,
498
+ layer,
499
+ }
500
+ }
501
+ }