@octane-xplat/macos-renderer 0.11.0 → 0.12.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/README.md +152 -8
- package/package.json +4 -3
- package/src/appearance.ts +56 -0
- package/src/appkit.d.ts +303 -30
- package/src/image.ts +92 -0
- package/src/index.ts +1216 -411
- package/src/intrinsics.d.ts +8 -2
- package/src/{layer.mjs → layer.ts} +74 -22
- package/src/presentation.d.mts +29 -0
- package/src/{presentation.mjs → presentation.ts} +102 -17
package/src/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { showWindowLayer } from './layer
|
|
1
|
+
import { showWindowLayer, type WindowLayerOptions } from './layer'
|
|
2
2
|
import '@nativescript/macos-node-api'
|
|
3
|
-
import {
|
|
4
|
-
import { installPresentationBridge } from './presentation
|
|
3
|
+
import { disposeImage, updateImage } from './image'
|
|
4
|
+
import { installPresentationBridge } from './presentation'
|
|
5
|
+
import { installAppearanceBridge } from './appearance'
|
|
5
6
|
import { makeWebView, updateWebView, disposeWebView } from './webview'
|
|
6
7
|
import { createUniversalRoot } from 'octane/universal/native'
|
|
7
8
|
import type {
|
|
@@ -43,8 +44,10 @@ export interface ElementNode {
|
|
|
43
44
|
scrollObserverInstalled: boolean
|
|
44
45
|
text: string
|
|
45
46
|
// Per-kind extras: placeholderView, marginHost, marginConstraints,
|
|
46
|
-
// crossAxisConstraint, sizeConstraints, styleBg, bgSlot,
|
|
47
|
-
// scrollObserver, scheme, textRuns,
|
|
47
|
+
// crossAxisConstraint, sizeConstraints, placementPins, styleBg, bgSlot,
|
|
48
|
+
// layoutObserver, scrollObserver, scheme, textRuns, appliedStyle,
|
|
49
|
+
// styleFont*/classFont*/propFontSize, stylePaddingBox/Edges, classInsets,
|
|
50
|
+
// styleRadius, classRadius, classSpacing, classOrientation, ...
|
|
48
51
|
[key: string]: any
|
|
49
52
|
}
|
|
50
53
|
|
|
@@ -92,6 +95,9 @@ interface RootContainer {
|
|
|
92
95
|
nodes: Map<number, ElementNode>
|
|
93
96
|
children: ElementNode[]
|
|
94
97
|
root: UniversalRoot | null
|
|
98
|
+
/** Parents needing a sibling-wide layout pass; non-null only while a
|
|
99
|
+
* command batch is applying. */
|
|
100
|
+
layoutDirty: Set<ElementNode> | null
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
const actionHandlers = new Map<number, (() => void) | null>()
|
|
@@ -122,6 +128,8 @@ class ButtonActionTarget extends NSObject {
|
|
|
122
128
|
viewPressed: { params: [NSObject], returns: interop.types.void },
|
|
123
129
|
viewPanned: { params: [NSPanGestureRecognizer], returns: interop.types.void },
|
|
124
130
|
controlChanged: { params: [NSObject], returns: interop.types.void },
|
|
131
|
+
controlTextDidChange: { params: [NSNotification], returns: interop.types.void },
|
|
132
|
+
textFieldSubmitted: { params: [NSObject], returns: interop.types.void },
|
|
125
133
|
textDidChange: { params: [NSNotification], returns: interop.types.void },
|
|
126
134
|
}
|
|
127
135
|
|
|
@@ -133,14 +141,28 @@ class ButtonActionTarget extends NSObject {
|
|
|
133
141
|
invokeAction(sender.tag)
|
|
134
142
|
}
|
|
135
143
|
|
|
136
|
-
viewPressed(sender:
|
|
144
|
+
viewPressed(sender: NSClickGestureRecognizer) {
|
|
137
145
|
invokeAction(actionIdsByView.get(sender.view))
|
|
138
146
|
}
|
|
139
147
|
|
|
140
|
-
controlChanged(sender:
|
|
148
|
+
controlChanged(sender: NSControl) {
|
|
141
149
|
invokeAction(sender.tag ?? actionIdsByView.get(sender))
|
|
142
150
|
}
|
|
143
151
|
|
|
152
|
+
// NSControlTextEditingDelegate: fires on every edit while a single-line
|
|
153
|
+
// field is being edited — the per-edit source for onTextChange.
|
|
154
|
+
controlTextDidChange(notification: NSNotification) {
|
|
155
|
+
const node = textNodesByView.get(notification.object)
|
|
156
|
+
invokeAction(node?.actionId ?? actionIdsByView.get(notification.object))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Single-line fields keep target/action for submission (Return). The
|
|
160
|
+
// change handler lives on controlTextDidChange, so the action never
|
|
161
|
+
// masquerades as a value-change event.
|
|
162
|
+
textFieldSubmitted(sender: NSObject) {
|
|
163
|
+
textNodesByView.get(sender)?.submitHandler?.()
|
|
164
|
+
}
|
|
165
|
+
|
|
144
166
|
viewPanned(sender: NSPanGestureRecognizer) {
|
|
145
167
|
const handler = panHandlersByView.get(sender.view)
|
|
146
168
|
if (!handler) {
|
|
@@ -190,6 +212,16 @@ class ContentAlignedTextField extends NSTextField {
|
|
|
190
212
|
}
|
|
191
213
|
}
|
|
192
214
|
|
|
215
|
+
class ContentAlignedSecureTextField extends NSSecureTextField {
|
|
216
|
+
static {
|
|
217
|
+
NativeClass(this)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
alignmentRectInsets() {
|
|
221
|
+
return { top: 0, left: 0, bottom: 0, right: 0 }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
193
225
|
const inputTransparentViews = new WeakSet<object>()
|
|
194
226
|
|
|
195
227
|
class AccessibleStackView extends NSStackView {
|
|
@@ -349,7 +381,7 @@ class GridLayoutView extends NSView {
|
|
|
349
381
|
}
|
|
350
382
|
|
|
351
383
|
// Absolute's host — children position via their own left/top/right/bottom
|
|
352
|
-
// layout-child props; the layout() pass
|
|
384
|
+
// layout-child props; the layout() pass updates parent-anchored constraints.
|
|
353
385
|
class AbsoluteLayoutView extends NSView {
|
|
354
386
|
static {
|
|
355
387
|
NativeClass(this)
|
|
@@ -400,12 +432,74 @@ function stackAlignmentAttribute(view: any, value: string) {
|
|
|
400
432
|
return horizontal ? NSLayoutAttribute.Top : NSLayoutAttribute.Left
|
|
401
433
|
}
|
|
402
434
|
|
|
435
|
+
// Container layout follows the same source order as fonts and padding. Resolve
|
|
436
|
+
// whole channels so an axis-specific gap never overrides a higher-source gap.
|
|
437
|
+
function syncStackLayout(node: ElementNode) {
|
|
438
|
+
if (node.type !== 'flexboxlayout' && node.type !== 'stack') {
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const stack = node.view!
|
|
443
|
+
const style = node.styleLayout ?? {}
|
|
444
|
+
const previousOrientation = stack.orientation
|
|
445
|
+
stack.orientation =
|
|
446
|
+
style.flexDirection != null
|
|
447
|
+
? style.flexDirection === 'row'
|
|
448
|
+
? NSUserInterfaceLayoutOrientation.Horizontal
|
|
449
|
+
: NSUserInterfaceLayoutOrientation.Vertical
|
|
450
|
+
: (node.classOrientation ??
|
|
451
|
+
(node.props.flexDirection === 'row'
|
|
452
|
+
? NSUserInterfaceLayoutOrientation.Horizontal
|
|
453
|
+
: NSUserInterfaceLayoutOrientation.Vertical))
|
|
454
|
+
|
|
455
|
+
const gapKey =
|
|
456
|
+
stack.orientation === NSUserInterfaceLayoutOrientation.Horizontal ? 'columnGap' : 'rowGap'
|
|
457
|
+
|
|
458
|
+
stack.spacing = Number(
|
|
459
|
+
style[gapKey] ??
|
|
460
|
+
style.gap ??
|
|
461
|
+
node.classSpacing ??
|
|
462
|
+
node.props[gapKey] ??
|
|
463
|
+
node.props.gap ??
|
|
464
|
+
node.props.spacing ??
|
|
465
|
+
0,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
stack.alignment = stackAlignmentAttribute(stack, stackAlignItems(node))
|
|
469
|
+
const justifyContent = stackJustifyContent(node)
|
|
470
|
+
if (stack.orientation !== previousOrientation || justifyContent !== node.appliedStackJustify) {
|
|
471
|
+
moveStackChildren(node)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
node.appliedStackJustify = justifyContent
|
|
475
|
+
updateStackDistribution(node)
|
|
476
|
+
updateCrossAxisConstraints(node)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function isStackLayoutInput(name: string) {
|
|
480
|
+
return ['flexDirection', 'gap', 'rowGap', 'columnGap', 'alignItems', 'justifyContent'].includes(
|
|
481
|
+
name,
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
|
|
403
485
|
function stackAlignItems(node: ElementNode) {
|
|
486
|
+
if (node.styleLayout?.alignItems != null) {
|
|
487
|
+
return node.styleLayout.alignItems
|
|
488
|
+
}
|
|
489
|
+
|
|
404
490
|
const classes = nodeClasses(node)
|
|
405
491
|
if (classes.includes('vx-button') || classes.includes('items-center')) {
|
|
406
492
|
return 'center'
|
|
407
493
|
}
|
|
408
494
|
|
|
495
|
+
if (classes.includes('items-stretch')) {
|
|
496
|
+
return 'stretch'
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (classes.includes('items-baseline')) {
|
|
500
|
+
return 'baseline'
|
|
501
|
+
}
|
|
502
|
+
|
|
409
503
|
if (classes.includes('items-start')) {
|
|
410
504
|
return 'start'
|
|
411
505
|
}
|
|
@@ -418,6 +512,23 @@ function stackAlignItems(node: ElementNode) {
|
|
|
418
512
|
}
|
|
419
513
|
|
|
420
514
|
function stackJustifyContent(node: ElementNode) {
|
|
515
|
+
if (node.styleLayout?.justifyContent != null) {
|
|
516
|
+
return node.styleLayout.justifyContent
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const classes = nodeClasses(node)
|
|
520
|
+
if (classes.includes('justify-center')) {
|
|
521
|
+
return 'center'
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (classes.includes('justify-end')) {
|
|
525
|
+
return 'end'
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (classes.includes('justify-start')) {
|
|
529
|
+
return 'start'
|
|
530
|
+
}
|
|
531
|
+
|
|
421
532
|
if (nodeClasses(node).includes('vx-button')) {
|
|
422
533
|
return 'center'
|
|
423
534
|
}
|
|
@@ -489,7 +600,7 @@ function makeMarginHost(node: ElementNode) {
|
|
|
489
600
|
})
|
|
490
601
|
|
|
491
602
|
host.translatesAutoresizingMaskIntoConstraints = false
|
|
492
|
-
host.addSubview(node.view)
|
|
603
|
+
host.addSubview(node.view!)
|
|
493
604
|
const insets = marginInsetsOf(node)
|
|
494
605
|
const constraints = [
|
|
495
606
|
node.view!.leadingAnchor.constraintEqualToAnchorConstant(host.leadingAnchor, insets.left),
|
|
@@ -526,29 +637,24 @@ function syncMarginHost(node: ElementNode) {
|
|
|
526
637
|
}
|
|
527
638
|
|
|
528
639
|
const insets = marginInsetsOf(node)
|
|
529
|
-
const index = Math.max(parent.children.indexOf(node), 0)
|
|
530
640
|
if (insets && !node.marginHost) {
|
|
531
641
|
const gravity = stackGravity(parent, node)
|
|
642
|
+
const index = gravityInsertIndex(parent, node, gravity)
|
|
532
643
|
stack.removeArrangedSubview(node.view)
|
|
533
644
|
node.view.removeFromSuperview()
|
|
534
645
|
const host = makeMarginHost(node)
|
|
535
646
|
stack.insertViewAtIndexInGravity(host, index, gravity)
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
node.marginHost = null
|
|
543
|
-
node.marginConstraints = null
|
|
544
|
-
stack.insertViewAtIndexInGravity(node.view, index, gravity)
|
|
545
|
-
updateCrossAxisConstraints(parent)
|
|
546
|
-
} else if (insets && node.marginHost) {
|
|
647
|
+
queueLayoutReconcile(node.container, parent)
|
|
648
|
+
setLayoutAction(node, node.props?.onLayoutChanged)
|
|
649
|
+
} else if (node.marginHost) {
|
|
650
|
+
// Once wrapped, the host stays arranged for the node's lifetime —
|
|
651
|
+
// unwrapping would detach a first-responder ancestor and end live
|
|
652
|
+
// editing. Zeroed constants collapse it to the child's frame.
|
|
547
653
|
const [leading, trailing, top, bottom] = node.marginConstraints
|
|
548
|
-
leading.constant = insets
|
|
549
|
-
trailing.constant = -insets
|
|
550
|
-
top.constant = insets
|
|
551
|
-
bottom.constant = -insets
|
|
654
|
+
leading.constant = insets?.left ?? 0
|
|
655
|
+
trailing.constant = 0 - (insets?.right ?? 0)
|
|
656
|
+
top.constant = insets?.top ?? 0
|
|
657
|
+
bottom.constant = 0 - (insets?.bottom ?? 0)
|
|
552
658
|
}
|
|
553
659
|
}
|
|
554
660
|
|
|
@@ -577,7 +683,7 @@ function setMarginStyle(node: ElementNode, name: string, value: any) {
|
|
|
577
683
|
|
|
578
684
|
if (node.parent?.type === 'gridlayout') {
|
|
579
685
|
// Grids lay out children manually from props.style — no wrapper needed.
|
|
580
|
-
|
|
686
|
+
queueLayoutReconcile(node.container, node.parent)
|
|
581
687
|
return
|
|
582
688
|
}
|
|
583
689
|
|
|
@@ -614,24 +720,111 @@ function stackGravity(parent: ElementNode, child: ElementNode) {
|
|
|
614
720
|
return leading
|
|
615
721
|
}
|
|
616
722
|
|
|
723
|
+
/** AppKit insertion indices are local to a gravity area — count the
|
|
724
|
+
* same-gravity siblings ahead of `node`, not its logical child index.
|
|
725
|
+
* Shared by stack insertion and margin-host reinsertion. */
|
|
726
|
+
function gravityInsertIndex(parent: ElementNode, node: ElementNode, gravity: number) {
|
|
727
|
+
let index = 0
|
|
728
|
+
for (const sibling of parent.children) {
|
|
729
|
+
if (sibling === node) {
|
|
730
|
+
break
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (sibling.view && stackGravity(parent, sibling) === gravity) {
|
|
734
|
+
index++
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return index
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** Snapshot one gravity area's arranged views. The bridge returns an
|
|
742
|
+
* NSArray; `count` may be a method or a property depending on interop. */
|
|
743
|
+
function stackViewsInGravity(stack: any, gravity: number) {
|
|
744
|
+
const views = typeof stack?.viewsInGravity === 'function' ? stack.viewsInGravity(gravity) : null
|
|
745
|
+
const count = typeof views?.count === 'function' ? views.count() : (views?.count ?? 0)
|
|
746
|
+
const arrangedViews: any[] = []
|
|
747
|
+
for (let index = 0; index < count; index++) {
|
|
748
|
+
arrangedViews.push(views.objectAtIndex(index))
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return arrangedViews
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Reconcile one gravity area with the children's desired views. Only
|
|
755
|
+
* re-parented children land in `moved` — detaching a view that is (or
|
|
756
|
+
* contains) the first responder ends the active editing session. */
|
|
757
|
+
function syncStackGravityArea(
|
|
758
|
+
parent: ElementNode,
|
|
759
|
+
stack: any,
|
|
760
|
+
gravity: number,
|
|
761
|
+
moved: Set<ElementNode>,
|
|
762
|
+
) {
|
|
763
|
+
const desired: ElementNode[] = []
|
|
764
|
+
for (const child of parent.children) {
|
|
765
|
+
if (child.view && stackGravity(parent, child) === gravity) {
|
|
766
|
+
desired.push(child)
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const keep = new Set(desired.map((child) => arrangedView(child)))
|
|
771
|
+
const arrangedViews = stackViewsInGravity(stack, gravity)
|
|
772
|
+
for (let index = arrangedViews.length - 1; index >= 0; index--) {
|
|
773
|
+
if (!keep.has(arrangedViews[index])) {
|
|
774
|
+
stack.removeArrangedSubview(arrangedViews[index])
|
|
775
|
+
arrangedViews[index].removeFromSuperview()
|
|
776
|
+
arrangedViews.splice(index, 1)
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
for (let index = 0; index < desired.length; index++) {
|
|
781
|
+
const child = desired[index]
|
|
782
|
+
const view = arrangedView(child)
|
|
783
|
+
if (arrangedViews[index] === view) {
|
|
784
|
+
continue
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const from = arrangedViews.indexOf(view)
|
|
788
|
+
if (from >= 0) {
|
|
789
|
+
stack.removeArrangedSubview(view)
|
|
790
|
+
view.removeFromSuperview()
|
|
791
|
+
arrangedViews.splice(from, 1)
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
stack.insertViewAtIndexInGravity(view, index, gravity)
|
|
795
|
+
arrangedViews.splice(index, 0, view)
|
|
796
|
+
moved.add(child)
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
617
800
|
function moveStackChildren(parent: ElementNode) {
|
|
618
801
|
const stack = parent.childHost ?? parent.view
|
|
619
802
|
if (typeof stack?.addViewInGravity !== 'function') {
|
|
620
803
|
return
|
|
621
804
|
}
|
|
622
805
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
806
|
+
// Diff each gravity area against the live arrangement so unchanged
|
|
807
|
+
// children are never re-parented — AppKit's endEditingFor: fires when a
|
|
808
|
+
// first-responder ancestor leaves its superview. Orientation flips keep
|
|
809
|
+
// identical gravity values (Leading == Top, Trailing == Bottom), so a
|
|
810
|
+
// pure orientation change is a no-op here.
|
|
811
|
+
const moved = new Set<ElementNode>()
|
|
812
|
+
for (const gravity of [
|
|
813
|
+
NSStackViewGravity.Leading,
|
|
814
|
+
NSStackViewGravity.Center,
|
|
815
|
+
NSStackViewGravity.Trailing,
|
|
816
|
+
]) {
|
|
817
|
+
syncStackGravityArea(parent, stack, gravity, moved)
|
|
818
|
+
}
|
|
627
819
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
820
|
+
for (const child of moved) {
|
|
821
|
+
// Removing a view drops constraints to its old superview, including
|
|
822
|
+
// percentage dimensions. Reinstall them after reattaching.
|
|
823
|
+
applySizeConstraints(child)
|
|
632
824
|
setStackChildPriorities(parent, child)
|
|
633
825
|
}
|
|
634
826
|
|
|
827
|
+
updateStackDistribution(parent)
|
|
635
828
|
updateCrossAxisConstraints(parent)
|
|
636
829
|
}
|
|
637
830
|
|
|
@@ -672,8 +865,6 @@ function setStackChildPriorities(parent: ElementNode, child: ElementNode) {
|
|
|
672
865
|
target.setContentHuggingPriorityForOrientation(priority, orientation)
|
|
673
866
|
target.setContentCompressionResistancePriorityForOrientation(750, orientation)
|
|
674
867
|
}
|
|
675
|
-
|
|
676
|
-
updateStackDistribution(parent)
|
|
677
868
|
}
|
|
678
869
|
|
|
679
870
|
function makeFlexbox(props: PropBag) {
|
|
@@ -684,11 +875,12 @@ function makeFlexbox(props: PropBag) {
|
|
|
684
875
|
: undefined
|
|
685
876
|
|
|
686
877
|
if (actionId !== undefined) {
|
|
687
|
-
actionIdsByView.set(stack, actionId)
|
|
688
|
-
actionHandlers.set(actionId, null)
|
|
689
878
|
stack.addGestureRecognizer(
|
|
690
|
-
NSClickGestureRecognizer.alloc().initWithTargetAction(buttonActionTarget, 'viewPressed'),
|
|
879
|
+
NSClickGestureRecognizer.alloc().initWithTargetAction(buttonActionTarget, 'viewPressed:'),
|
|
691
880
|
)
|
|
881
|
+
|
|
882
|
+
actionIdsByView.set(stack, actionId)
|
|
883
|
+
actionHandlers.set(actionId, null)
|
|
692
884
|
}
|
|
693
885
|
|
|
694
886
|
return { view: stack, actionId }
|
|
@@ -717,7 +909,7 @@ function makeButton(props: PropBag) {
|
|
|
717
909
|
const button = NSButton.buttonWithTitleTargetAction(
|
|
718
910
|
String(props.title ?? 'Button'),
|
|
719
911
|
buttonActionTarget,
|
|
720
|
-
'buttonPressed',
|
|
912
|
+
'buttonPressed:',
|
|
721
913
|
)
|
|
722
914
|
|
|
723
915
|
button.bezelStyle = NSBezelStyle.Rounded
|
|
@@ -810,6 +1002,22 @@ function setScrollAction(node: ElementNode, handler: ((event: any) => void) | un
|
|
|
810
1002
|
|
|
811
1003
|
const layoutHandlers = new WeakMap<object, () => void>()
|
|
812
1004
|
|
|
1005
|
+
/** Margin changes swap a node's arranged view without touching the node.
|
|
1006
|
+
* Drop the observer and handler bound to the stale target — the caller
|
|
1007
|
+
* re-runs `setLayoutAction`, which rebuilds both against the current
|
|
1008
|
+
* arranged view so events keep reporting that view's bounds. */
|
|
1009
|
+
function syncLayoutObserver(node: ElementNode) {
|
|
1010
|
+
const observed = node.layoutObservedView
|
|
1011
|
+
if (!observed || observed === arrangedView(node)) {
|
|
1012
|
+
return
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
layoutHandlers.delete(observed)
|
|
1016
|
+
NSNotificationCenter.defaultCenter.removeObserver(node.layoutObserver)
|
|
1017
|
+
node.layoutObserver = null
|
|
1018
|
+
node.layoutObservedView = null
|
|
1019
|
+
}
|
|
1020
|
+
|
|
813
1021
|
/** `onLayoutChanged` prop: NSViewFrameDidChangeNotification on the arranged
|
|
814
1022
|
* view (margin host when present) — the windowed VirtualList leaf measures
|
|
815
1023
|
* rows through it. */
|
|
@@ -819,6 +1027,7 @@ function setLayoutAction(node: ElementNode, handler: ((event: any) => void) | un
|
|
|
819
1027
|
return
|
|
820
1028
|
}
|
|
821
1029
|
|
|
1030
|
+
syncLayoutObserver(node)
|
|
822
1031
|
if (typeof handler !== 'function') {
|
|
823
1032
|
layoutHandlers.delete(target)
|
|
824
1033
|
return
|
|
@@ -835,6 +1044,8 @@ function setLayoutAction(node: ElementNode, handler: ((event: any) => void) | un
|
|
|
835
1044
|
null,
|
|
836
1045
|
() => layoutHandlers.get(target)?.(),
|
|
837
1046
|
)
|
|
1047
|
+
|
|
1048
|
+
node.layoutObservedView = target
|
|
838
1049
|
}
|
|
839
1050
|
|
|
840
1051
|
layoutHandlers.set(target, () => {
|
|
@@ -878,10 +1089,12 @@ function setTextFieldPlaceholder(field: NSTextField, props: PropBag, scheme = 'l
|
|
|
878
1089
|
function makeTextField(props: PropBag, multiline = false) {
|
|
879
1090
|
const field = multiline
|
|
880
1091
|
? NSTextView.alloc().initWithFrame({ origin: { x: 0, y: 0 }, size: { width: 320, height: 72 } })
|
|
881
|
-
: ContentAlignedTextField
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
1092
|
+
: (props.secure ? ContentAlignedSecureTextField : ContentAlignedTextField)
|
|
1093
|
+
.alloc()
|
|
1094
|
+
.initWithFrame({
|
|
1095
|
+
origin: { x: 0, y: 0 },
|
|
1096
|
+
size: { width: 320, height: 28 },
|
|
1097
|
+
})
|
|
885
1098
|
|
|
886
1099
|
field.translatesAutoresizingMaskIntoConstraints = false
|
|
887
1100
|
field.font = fontForStyle(14)
|
|
@@ -892,7 +1105,7 @@ function makeTextField(props: PropBag, multiline = false) {
|
|
|
892
1105
|
field.editable = true
|
|
893
1106
|
field.selectable = true
|
|
894
1107
|
field.sendsActionOnEndEditing = false
|
|
895
|
-
setTextFieldPlaceholder(field, props)
|
|
1108
|
+
setTextFieldPlaceholder(field as NSTextField, props)
|
|
896
1109
|
} else {
|
|
897
1110
|
field.editable = true
|
|
898
1111
|
field.selectable = true
|
|
@@ -911,7 +1124,7 @@ function makeTextField(props: PropBag, multiline = false) {
|
|
|
911
1124
|
}
|
|
912
1125
|
|
|
913
1126
|
function makeSwitch(props: PropBag) {
|
|
914
|
-
const button = NSButton.buttonWithTitleTargetAction('', buttonActionTarget, 'controlChanged')
|
|
1127
|
+
const button = NSButton.buttonWithTitleTargetAction('', buttonActionTarget, 'controlChanged:')
|
|
915
1128
|
button.setButtonType(NSButtonType.Switch)
|
|
916
1129
|
button.state = props.checked ? 1 : 0
|
|
917
1130
|
button.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -931,7 +1144,7 @@ function makeSlider(props: PropBag) {
|
|
|
931
1144
|
slider.maxValue = Number(props.maxValue ?? 1)
|
|
932
1145
|
slider.doubleValue = Number(props.value ?? 0)
|
|
933
1146
|
slider.target = buttonActionTarget
|
|
934
|
-
slider.action = 'controlChanged'
|
|
1147
|
+
slider.action = 'controlChanged:'
|
|
935
1148
|
slider.translatesAutoresizingMaskIntoConstraints = false
|
|
936
1149
|
const actionId = nextActionId++
|
|
937
1150
|
slider.tag = actionId
|
|
@@ -1217,6 +1430,82 @@ function trackOffset(sizes: number[], index: number) {
|
|
|
1217
1430
|
return offset
|
|
1218
1431
|
}
|
|
1219
1432
|
|
|
1433
|
+
/** Pin a grid/absolute child to the rect its parent's layout pass computed.
|
|
1434
|
+
* Children are translates=false views, so Auto Layout owns their frames —
|
|
1435
|
+
* an imperative frame write only holds until the next solve shrinks the
|
|
1436
|
+
* child back to fitting size. Each axis gets one edge pin against the
|
|
1437
|
+
* parent plus one size pin, so parent geometry stays an input; a pin set
|
|
1438
|
+
* that also implied the parent's size would outrank the window's
|
|
1439
|
+
* 500-priority WindowSizeStayPut hold and lock the window at the content's
|
|
1440
|
+
* fitting size. */
|
|
1441
|
+
function pinLayoutChild(
|
|
1442
|
+
child: ElementNode,
|
|
1443
|
+
parentView: any,
|
|
1444
|
+
x: number,
|
|
1445
|
+
top: number,
|
|
1446
|
+
width: number,
|
|
1447
|
+
height: number,
|
|
1448
|
+
) {
|
|
1449
|
+
const pins = (child.placementPins ??= {})
|
|
1450
|
+
if (!pins.leading) {
|
|
1451
|
+
pins.leading = child.view!.leadingAnchor.constraintEqualToAnchorConstant(
|
|
1452
|
+
parentView.leadingAnchor,
|
|
1453
|
+
0,
|
|
1454
|
+
)
|
|
1455
|
+
|
|
1456
|
+
pins.top = child.view!.topAnchor.constraintEqualToAnchorConstant(parentView.topAnchor, 0)
|
|
1457
|
+
pins.width = child.view!.widthAnchor.constraintEqualToConstant(0)
|
|
1458
|
+
pins.height = child.view!.heightAnchor.constraintEqualToConstant(0)
|
|
1459
|
+
for (const pin of [pins.leading, pins.top, pins.width, pins.height]) {
|
|
1460
|
+
pin.active = true
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const same = (a: number, b: number) => a === b || (Number.isNaN(a) && Number.isNaN(b))
|
|
1465
|
+
if (
|
|
1466
|
+
same(Number(pins.leading.constant), x) &&
|
|
1467
|
+
same(Number(pins.top.constant), top) &&
|
|
1468
|
+
same(Number(pins.width.constant), width) &&
|
|
1469
|
+
same(Number(pins.height.constant), height)
|
|
1470
|
+
) {
|
|
1471
|
+
return
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
pins.leading.constant = x
|
|
1475
|
+
pins.top.constant = top
|
|
1476
|
+
pins.width.constant = width
|
|
1477
|
+
pins.height.constant = height
|
|
1478
|
+
// Constant writes inside a layout() pass apply only on the next solve, and
|
|
1479
|
+
// a needsLayout mark made now is cleared when that pass ends. Defer the
|
|
1480
|
+
// mark so the next layoutSubtreeIfNeeded/display cycle applies the pins —
|
|
1481
|
+
// without it, real-window resizes leave children at stale frames. The bare
|
|
1482
|
+
// vm slices in tests have no microtask queue, so fall back to a direct
|
|
1483
|
+
// mark there.
|
|
1484
|
+
const mark = () => {
|
|
1485
|
+
parentView.needsLayout = true
|
|
1486
|
+
}
|
|
1487
|
+
if (typeof queueMicrotask === 'function') {
|
|
1488
|
+
queueMicrotask(mark)
|
|
1489
|
+
} else {
|
|
1490
|
+
mark()
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/** Drop the pins a grid/absolute parent installed — a constraint that keeps
|
|
1495
|
+
* referencing the old parent view is invalid once the child reparents. */
|
|
1496
|
+
function releasePlacementPins(node: ElementNode) {
|
|
1497
|
+
const pins = node.placementPins
|
|
1498
|
+
if (!pins) {
|
|
1499
|
+
return
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
for (const pin of Object.values(pins) as { active: boolean }[]) {
|
|
1503
|
+
pin.active = false
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
node.placementPins = null
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1220
1509
|
function layoutGridChildren(parent: ElementNode | undefined) {
|
|
1221
1510
|
if (!parent?.view) {
|
|
1222
1511
|
return
|
|
@@ -1302,10 +1591,7 @@ function layoutGridChildren(parent: ElementNode | undefined) {
|
|
|
1302
1591
|
}
|
|
1303
1592
|
|
|
1304
1593
|
top += layoutLength(style.top, cellHeight, 0) + Number(style.marginTop ?? 0)
|
|
1305
|
-
child.view
|
|
1306
|
-
origin: { x, y: height - top - childHeight },
|
|
1307
|
-
size: { width: childWidth, height: childHeight },
|
|
1308
|
-
}
|
|
1594
|
+
pinLayoutChild(child, parent.view, x, top, childWidth, childHeight)
|
|
1309
1595
|
}
|
|
1310
1596
|
|
|
1311
1597
|
return
|
|
@@ -1346,10 +1632,7 @@ function layoutGridChildren(parent: ElementNode | undefined) {
|
|
|
1346
1632
|
y = height - childHeight - Number(style.marginTop ?? 0)
|
|
1347
1633
|
}
|
|
1348
1634
|
|
|
1349
|
-
child.view
|
|
1350
|
-
origin: { x, y },
|
|
1351
|
-
size: { width: childWidth, height: childHeight },
|
|
1352
|
-
}
|
|
1635
|
+
pinLayoutChild(child, parent.view, x, height - y - childHeight, childWidth, childHeight)
|
|
1353
1636
|
}
|
|
1354
1637
|
}
|
|
1355
1638
|
|
|
@@ -1394,10 +1677,7 @@ function layoutAbsoluteChildren(parent: ElementNode | undefined) {
|
|
|
1394
1677
|
? height - bottom - childHeight
|
|
1395
1678
|
: 0
|
|
1396
1679
|
|
|
1397
|
-
child.view
|
|
1398
|
-
origin: { x, y: height - offsetTop - childHeight },
|
|
1399
|
-
size: { width: childWidth, height: childHeight },
|
|
1400
|
-
}
|
|
1680
|
+
pinLayoutChild(child, parent.view, x, offsetTop, childWidth, childHeight)
|
|
1401
1681
|
}
|
|
1402
1682
|
}
|
|
1403
1683
|
|
|
@@ -1433,140 +1713,236 @@ function makeImageView(props: PropBag) {
|
|
|
1433
1713
|
})
|
|
1434
1714
|
|
|
1435
1715
|
image.translatesAutoresizingMaskIntoConstraints = false
|
|
1436
|
-
image
|
|
1716
|
+
updateImage(image, props.src)
|
|
1437
1717
|
|
|
1438
1718
|
return image
|
|
1439
1719
|
}
|
|
1440
1720
|
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1721
|
+
/** Types whose views arrange child views; mirrors the check in insert(). */
|
|
1722
|
+
const VIEW_PARENT_TYPES = new Set([
|
|
1723
|
+
'stack',
|
|
1724
|
+
'flexboxlayout',
|
|
1725
|
+
'scrollview',
|
|
1726
|
+
'gridlayout',
|
|
1727
|
+
'absolutelayout',
|
|
1728
|
+
])
|
|
1729
|
+
|
|
1730
|
+
/** Every type the factory switch below can build. */
|
|
1731
|
+
const SUPPORTED_TYPES = new Set([
|
|
1732
|
+
...VIEW_PARENT_TYPES,
|
|
1733
|
+
'label',
|
|
1734
|
+
'#text',
|
|
1735
|
+
'button',
|
|
1736
|
+
'textfield',
|
|
1737
|
+
'textview',
|
|
1738
|
+
'switch',
|
|
1739
|
+
'slider',
|
|
1740
|
+
'webview',
|
|
1741
|
+
'image',
|
|
1742
|
+
'span',
|
|
1743
|
+
])
|
|
1744
|
+
|
|
1745
|
+
function typeHasView(type: string) {
|
|
1746
|
+
return type !== '#text' && type !== 'span'
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
function assertSupportedType(type: string) {
|
|
1750
|
+
if (!SUPPORTED_TYPES.has(type)) {
|
|
1751
|
+
throw new Error('AppKit spike does not support <' + type + '>')
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
/** Deterministic prop failures that would otherwise throw after views and
|
|
1756
|
+
* registrations exist. `baseProps` supplies already-applied props for
|
|
1757
|
+
* update commands so merged-prop rules (e.g. placeholder color) stay exact. */
|
|
1758
|
+
function validateNodeProps(type: string, props: PropBag | undefined, baseProps?: PropBag) {
|
|
1759
|
+
if (type === '#text' || type === 'span') {
|
|
1760
|
+
return
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
const style = props?.style
|
|
1764
|
+
if (style != null) {
|
|
1765
|
+
if (typeof style !== 'object') {
|
|
1766
|
+
throw new Error('AppKit spike expects style to be an object')
|
|
1472
1767
|
}
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
break
|
|
1768
|
+
|
|
1769
|
+
// applyStyle feeds these straight into nativeColor.
|
|
1770
|
+
if (style.backgroundColor != null) {
|
|
1771
|
+
nativeColor(style.backgroundColor)
|
|
1478
1772
|
}
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
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
|
|
1773
|
+
|
|
1774
|
+
if (style.borderColor != null) {
|
|
1775
|
+
nativeColor(style.borderColor)
|
|
1490
1776
|
}
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
actionId = control.actionId
|
|
1495
|
-
break
|
|
1777
|
+
|
|
1778
|
+
if (['label', 'textfield', 'textview'].includes(type) && style.color != null) {
|
|
1779
|
+
nativeColor(style.color)
|
|
1496
1780
|
}
|
|
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
1781
|
}
|
|
1509
1782
|
|
|
1510
|
-
if (type === '
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1783
|
+
if (type === 'gridlayout') {
|
|
1784
|
+
// layoutGridChildren parses the track lists on every apply.
|
|
1785
|
+
parseGridTracks(props?.rows)
|
|
1786
|
+
parseGridTracks(props?.columns)
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
if (type === 'textfield' && props?.placeholderTextColor != null) {
|
|
1790
|
+
// setTextFieldPlaceholder only reaches the color when text exists.
|
|
1791
|
+
const placeholder = props.placeholder ?? baseProps?.placeholder
|
|
1792
|
+
if (String(placeholder ?? '') !== '') {
|
|
1793
|
+
nativeColor(props.placeholderTextColor)
|
|
1520
1794
|
}
|
|
1521
1795
|
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function makeNode(container: RootContainer, id: number, type: string, props: PropBag) {
|
|
1799
|
+
// Reject malformed props before factories allocate views or register
|
|
1800
|
+
// actions — the node only reaches container.nodes after this returns, so
|
|
1801
|
+
// a throwing prop below must unwind whatever was already acquired.
|
|
1802
|
+
assertSupportedType(type)
|
|
1803
|
+
validateNodeProps(type, props)
|
|
1522
1804
|
|
|
1523
1805
|
const node: ElementNode = {
|
|
1524
1806
|
id,
|
|
1525
1807
|
type,
|
|
1526
|
-
view,
|
|
1527
|
-
childHost,
|
|
1808
|
+
view: null,
|
|
1528
1809
|
props: {},
|
|
1529
1810
|
parent: null,
|
|
1530
1811
|
children: [],
|
|
1531
1812
|
container,
|
|
1532
1813
|
appliedFontFamily: container.fontFamily,
|
|
1533
|
-
actionId,
|
|
1534
1814
|
scrollObserverInstalled: false,
|
|
1535
1815
|
text: '',
|
|
1816
|
+
// The native class is fixed at creation; post-mount `secure` updates
|
|
1817
|
+
// are rejected against this in applyProps.
|
|
1818
|
+
secure: type === 'textfield' && !!props.secure,
|
|
1536
1819
|
}
|
|
1537
1820
|
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1821
|
+
try {
|
|
1822
|
+
switch (type) {
|
|
1823
|
+
case 'stack':
|
|
1824
|
+
node.view = makeStack(props)
|
|
1825
|
+
break
|
|
1826
|
+
case 'flexboxlayout': {
|
|
1827
|
+
const flexbox = makeFlexbox(props)
|
|
1828
|
+
node.view = flexbox.view
|
|
1829
|
+
node.actionId = flexbox.actionId
|
|
1830
|
+
break
|
|
1831
|
+
}
|
|
1832
|
+
case 'gridlayout':
|
|
1833
|
+
node.view = makeGridLayout()
|
|
1834
|
+
break
|
|
1835
|
+
case 'absolutelayout':
|
|
1836
|
+
node.view = makeAbsoluteLayout()
|
|
1837
|
+
break
|
|
1838
|
+
case 'label':
|
|
1839
|
+
node.view = makeLabel()
|
|
1840
|
+
break
|
|
1841
|
+
case '#text':
|
|
1842
|
+
break
|
|
1843
|
+
case 'button': {
|
|
1844
|
+
const button = makeButton(props)
|
|
1845
|
+
node.view = button.view
|
|
1846
|
+
node.actionId = button.actionId
|
|
1847
|
+
break
|
|
1848
|
+
}
|
|
1849
|
+
case 'scrollview': {
|
|
1850
|
+
const scroll = makeScrollView()
|
|
1851
|
+
node.view = scroll.view
|
|
1852
|
+
node.childHost = scroll.childHost
|
|
1853
|
+
break
|
|
1854
|
+
}
|
|
1855
|
+
case 'textfield':
|
|
1856
|
+
node.view = makeTextField(props)
|
|
1857
|
+
break
|
|
1858
|
+
case 'textview':
|
|
1859
|
+
node.view = makeTextField(props, true)
|
|
1860
|
+
break
|
|
1861
|
+
case 'switch': {
|
|
1862
|
+
const control = makeSwitch(props)
|
|
1863
|
+
node.view = control.view
|
|
1864
|
+
node.actionId = control.actionId
|
|
1865
|
+
break
|
|
1866
|
+
}
|
|
1867
|
+
case 'slider': {
|
|
1868
|
+
const control = makeSlider(props)
|
|
1869
|
+
node.view = control.view
|
|
1870
|
+
node.actionId = control.actionId
|
|
1871
|
+
break
|
|
1872
|
+
}
|
|
1873
|
+
case 'webview':
|
|
1874
|
+
node.view = makeWebView()
|
|
1875
|
+
break
|
|
1876
|
+
case 'image':
|
|
1877
|
+
node.view = makeImageView(props)
|
|
1878
|
+
break
|
|
1879
|
+
case 'span':
|
|
1880
|
+
break
|
|
1881
|
+
default:
|
|
1882
|
+
throw new Error('AppKit spike does not support <' + type + '>')
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
if (type === 'textfield' || type === 'textview') {
|
|
1886
|
+
node.actionId = nextActionId++
|
|
1887
|
+
actionIdsByView.set(node.view!, node.actionId)
|
|
1888
|
+
actionHandlers.set(node.actionId, null)
|
|
1889
|
+
if (type === 'textfield') {
|
|
1890
|
+
// Per-edit events arrive through controlTextDidChange (delegate);
|
|
1891
|
+
// the target/action is reserved for submission on Return.
|
|
1892
|
+
node.view!.delegate = buttonActionTarget
|
|
1893
|
+
node.view!.target = buttonActionTarget
|
|
1894
|
+
node.view!.action = 'textFieldSubmitted:'
|
|
1895
|
+
} else {
|
|
1896
|
+
node.view!.delegate = buttonActionTarget
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1541
1899
|
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1900
|
+
if (['label', 'textfield', 'textview'].includes(type) && node.view!.font) {
|
|
1901
|
+
node.view!.font = fontForFamilyStyle(node.view!.font.pointSize, 400, container.fontFamily)
|
|
1902
|
+
}
|
|
1545
1903
|
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1904
|
+
if (type === 'gridlayout') {
|
|
1905
|
+
gridLayoutNodesByView.set(node.view!, node)
|
|
1906
|
+
}
|
|
1549
1907
|
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
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
|
|
1908
|
+
if (type === 'absolutelayout') {
|
|
1909
|
+
absoluteLayoutNodesByView.set(node.view!, node)
|
|
1910
|
+
}
|
|
1559
1911
|
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1912
|
+
if (type === 'textfield') {
|
|
1913
|
+
textNodesByView.set(node.view!, node)
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
if (type === 'textview') {
|
|
1917
|
+
const placeholder = makeLabel()
|
|
1918
|
+
placeholder.font = fontForFamilyStyle(14, 400, node.appliedFontFamily)
|
|
1919
|
+
placeholder.textColor = nativeColor('#666666')
|
|
1920
|
+
placeholder.stringValue = String(props.placeholder ?? '')
|
|
1921
|
+
placeholder.translatesAutoresizingMaskIntoConstraints = false
|
|
1922
|
+
placeholder.heightAnchor.constraintEqualToConstant(
|
|
1923
|
+
Math.ceil(14 * DEFAULT_TEXT_LINE_HEIGHT_RATIO),
|
|
1924
|
+
).active = true
|
|
1925
|
+
|
|
1926
|
+
placeholder.hidden = String(props.value ?? '').length > 0
|
|
1927
|
+
node.view!.addSubview(placeholder)
|
|
1928
|
+
placeholder.leadingAnchor.constraintEqualToAnchorConstant(
|
|
1929
|
+
node.view!.leadingAnchor,
|
|
1930
|
+
0,
|
|
1931
|
+
).active = true
|
|
1932
|
+
|
|
1933
|
+
placeholder.topAnchor.constraintEqualToAnchor(node.view!.topAnchor).active = true
|
|
1934
|
+
node.placeholderView = placeholder
|
|
1935
|
+
textNodesByView.set(node.view!, node)
|
|
1936
|
+
}
|
|
1567
1937
|
|
|
1568
|
-
|
|
1569
|
-
|
|
1938
|
+
applyProps(node, props)
|
|
1939
|
+
return node
|
|
1940
|
+
} catch (error) {
|
|
1941
|
+
// Release whatever the failed creation registered — action slots,
|
|
1942
|
+
// observers, WebView delegates — since no caller can destroy it.
|
|
1943
|
+
destroy(node)
|
|
1944
|
+
throw error
|
|
1945
|
+
}
|
|
1570
1946
|
}
|
|
1571
1947
|
|
|
1572
1948
|
const EDGE_INSET_PROPS = new Map([
|
|
@@ -1694,16 +2070,20 @@ function applyThemeColors(node: ElementNode) {
|
|
|
1694
2070
|
}
|
|
1695
2071
|
|
|
1696
2072
|
if (node.type === 'textfield') {
|
|
1697
|
-
setTextFieldPlaceholder(view, node.props, node.scheme)
|
|
2073
|
+
setTextFieldPlaceholder(view as NSTextField, node.props, node.scheme)
|
|
1698
2074
|
}
|
|
1699
2075
|
|
|
1700
2076
|
if (node.type === 'textview' && node.placeholderView) {
|
|
1701
2077
|
node.placeholderView.textColor = nativeColor(colors.placeholder)
|
|
1702
2078
|
}
|
|
1703
2079
|
|
|
1704
|
-
if (node.bgSlot) {
|
|
2080
|
+
if (node.bgSlot || node.styleBg != null) {
|
|
2081
|
+
node.bgApplied = true
|
|
1705
2082
|
view.wantsLayer = true
|
|
1706
2083
|
view.layer.backgroundColor = nativeColor(node.styleBg ?? colors[node.bgSlot]).CGColor
|
|
2084
|
+
} else if (node.bgApplied) {
|
|
2085
|
+
node.bgApplied = false
|
|
2086
|
+
view.layer.backgroundColor = null
|
|
1707
2087
|
}
|
|
1708
2088
|
}
|
|
1709
2089
|
|
|
@@ -1729,28 +2109,25 @@ function setSizeConstraint(node: ElementNode, name: string, value: any) {
|
|
|
1729
2109
|
const specs = (node.sizeConstraintSpecs ??= {})
|
|
1730
2110
|
if (!spec) {
|
|
1731
2111
|
delete specs[name]
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
2112
|
+
// null/undefined is an intentional clear — only malformed values warn.
|
|
2113
|
+
if (value != null) {
|
|
2114
|
+
console.warn(
|
|
2115
|
+
'[macos-style] ignored unsupported style.' + name + ' value ' + JSON.stringify(value),
|
|
2116
|
+
)
|
|
2117
|
+
}
|
|
1735
2118
|
} else {
|
|
1736
2119
|
specs[name] = spec
|
|
1737
2120
|
}
|
|
1738
2121
|
|
|
1739
|
-
if (node.parent?.type === 'gridlayout') {
|
|
2122
|
+
if (node.parent?.type === 'gridlayout' || node.parent?.type === 'absolutelayout') {
|
|
1740
2123
|
deactivateSizeConstraints(node)
|
|
1741
|
-
|
|
1742
|
-
return
|
|
1743
|
-
}
|
|
1744
|
-
|
|
1745
|
-
if (node.parent?.type === 'absolutelayout') {
|
|
1746
|
-
deactivateSizeConstraints(node)
|
|
1747
|
-
layoutAbsoluteChildren(node.parent)
|
|
2124
|
+
queueLayoutReconcile(node.container, node.parent)
|
|
1748
2125
|
return
|
|
1749
2126
|
}
|
|
1750
2127
|
|
|
1751
2128
|
applySizeConstraint(node, name)
|
|
1752
2129
|
if (node.parent) {
|
|
1753
|
-
|
|
2130
|
+
queueLayoutReconcile(node.container, node.parent)
|
|
1754
2131
|
}
|
|
1755
2132
|
}
|
|
1756
2133
|
|
|
@@ -1831,21 +2208,201 @@ function deactivateSizeConstraints(node: ElementNode) {
|
|
|
1831
2208
|
}
|
|
1832
2209
|
}
|
|
1833
2210
|
|
|
1834
|
-
|
|
1835
|
-
|
|
2211
|
+
// Style, class, and (for labels) prop sources all write one native font.
|
|
2212
|
+
// Each source is tracked on the node so removing one re-resolves the rest —
|
|
2213
|
+
// explicit style wins over classes, which win over the element defaults.
|
|
2214
|
+
function syncNodeFont(node: ElementNode) {
|
|
2215
|
+
const size =
|
|
2216
|
+
node.styleFontSize ??
|
|
2217
|
+
node.propFontSize ??
|
|
2218
|
+
node.classFontSize ??
|
|
2219
|
+
(node.type === 'label' ? 16 : 14)
|
|
2220
|
+
|
|
2221
|
+
const weight = node.styleFontWeight ?? node.classFontWeight ?? 400
|
|
2222
|
+
node.appliedFontWeight = String(weight)
|
|
2223
|
+
node.appliedFontFamily = node.styleFontFamily ?? node.container.fontFamily
|
|
2224
|
+
node.view!.font = fontForFamilyStyle(size, weight, node.appliedFontFamily)
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
// NSStackView edgeInsets blend padding styles over class padding over zero.
|
|
2228
|
+
// Edges a style never set fall through to the class contribution.
|
|
2229
|
+
function syncEdgeInsets(node: ElementNode) {
|
|
2230
|
+
const view = node.view
|
|
2231
|
+
if (!view) {
|
|
2232
|
+
return
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
const edges = node.stylePaddingEdges
|
|
2236
|
+
const box = node.stylePaddingBox
|
|
2237
|
+
const klass = node.classInsets
|
|
2238
|
+
if (!edges && !box && !klass && !node.insetsApplied) {
|
|
1836
2239
|
return
|
|
1837
2240
|
}
|
|
1838
2241
|
|
|
1839
|
-
|
|
2242
|
+
node.insetsApplied = true
|
|
2243
|
+
view.edgeInsets = {
|
|
2244
|
+
top: edges?.top ?? box?.top ?? klass?.top ?? 0,
|
|
2245
|
+
right: edges?.right ?? box?.right ?? klass?.right ?? 0,
|
|
2246
|
+
bottom: edges?.bottom ?? box?.bottom ?? klass?.bottom ?? 0,
|
|
2247
|
+
left: edges?.left ?? box?.left ?? klass?.left ?? 0,
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
// borderRadius (style) and rounded-* (class) share cornerRadius; either source
|
|
2252
|
+
// keeps masksToBounds on only while a positive radius is in effect.
|
|
2253
|
+
function syncCornerRadius(node: ElementNode) {
|
|
2254
|
+
const view = node.view
|
|
2255
|
+
if (!view) {
|
|
2256
|
+
return
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
const radius = Number(node.styleRadius ?? node.classRadius ?? 0) || 0
|
|
2260
|
+
if (radius <= 0 && !node.radiusApplied) {
|
|
2261
|
+
return
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
node.radiusApplied = radius > 0
|
|
2265
|
+
view.wantsLayer = true
|
|
2266
|
+
view.layer.cornerRadius = Math.max(0, radius)
|
|
2267
|
+
view.layer.masksToBounds = radius > 0
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
// Zero the margin sides a removed style owned, then let the stack unwrap or
|
|
2271
|
+
// the grid relayout — marginInsetsOf() returns null once every side is zero.
|
|
2272
|
+
function clearMarginStyle(node: ElementNode, name: string) {
|
|
2273
|
+
const insets = node.marginInsets
|
|
2274
|
+
if (!insets) {
|
|
2275
|
+
return
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
if (name === 'margin') {
|
|
2279
|
+
insets.top = insets.right = insets.bottom = insets.left = 0
|
|
2280
|
+
} else {
|
|
2281
|
+
insets[MARGIN_SIDES[name]] = 0
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
if (node.parent?.type === 'gridlayout') {
|
|
2285
|
+
layoutGridChildren(node.parent)
|
|
2286
|
+
return
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
syncMarginHost(node)
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
// A style key present in the last bag but absent or nulled in the new one must
|
|
2293
|
+
// put back the native state it installed: constraints deactivate, hit-testing
|
|
2294
|
+
// reverts, and shared properties resolve their class/default source again.
|
|
2295
|
+
function resetStyle(node: ElementNode, name: string) {
|
|
2296
|
+
const view = node.view
|
|
2297
|
+
if (!view) {
|
|
2298
|
+
return
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
const textControl = ['label', 'textfield', 'textview'].includes(node.type)
|
|
2302
|
+
if (name === 'pointerEvents') {
|
|
2303
|
+
inputTransparentViews.delete(view)
|
|
2304
|
+
} else if (name === 'objectFit' && node.type === 'image') {
|
|
2305
|
+
// NSImageScaleProportionallyDown — the makeImageView/AppKit default.
|
|
2306
|
+
view.imageScaling = 0
|
|
2307
|
+
} else if (name === 'fontSize' && textControl) {
|
|
2308
|
+
node.styleFontSize = undefined
|
|
2309
|
+
syncNodeFont(node)
|
|
2310
|
+
} else if (name === 'fontFamily' && textControl) {
|
|
2311
|
+
node.styleFontFamily = undefined
|
|
2312
|
+
syncNodeFont(node)
|
|
2313
|
+
} else if (name === 'fontWeight' && textControl) {
|
|
2314
|
+
node.styleFontWeight = undefined
|
|
2315
|
+
syncNodeFont(node)
|
|
2316
|
+
} else if (name === 'color' && textControl) {
|
|
2317
|
+
node.styleColor = undefined
|
|
2318
|
+
applyThemeColors(node)
|
|
2319
|
+
} else if (name === 'padding' && node.type === 'flexboxlayout') {
|
|
2320
|
+
node.stylePaddingBox = undefined
|
|
2321
|
+
syncEdgeInsets(node)
|
|
2322
|
+
} else if (EDGE_INSET_PROPS.has(name) && node.type === 'flexboxlayout') {
|
|
2323
|
+
for (const edge of EDGE_INSET_PROPS.get(name)!) {
|
|
2324
|
+
delete node.stylePaddingEdges?.[edge]
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
syncEdgeInsets(node)
|
|
2328
|
+
} else if (name === 'backgroundColor') {
|
|
2329
|
+
node.styleBg = undefined
|
|
2330
|
+
applyThemeColors(node)
|
|
2331
|
+
} else if (name === 'borderRadius') {
|
|
2332
|
+
node.styleRadius = undefined
|
|
2333
|
+
syncCornerRadius(node)
|
|
2334
|
+
} else if (name === 'width' || name === 'height') {
|
|
2335
|
+
setSizeConstraint(node, name, undefined)
|
|
2336
|
+
} else if (name === 'left' || name === 'top' || name === 'right' || name === 'bottom') {
|
|
2337
|
+
if (node.parent?.type === 'gridlayout') {
|
|
2338
|
+
layoutGridChildren(node.parent)
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
if (node.parent?.type === 'absolutelayout') {
|
|
2342
|
+
layoutAbsoluteChildren(node.parent)
|
|
2343
|
+
}
|
|
2344
|
+
} else if (name === 'translateX' || name === 'translateY') {
|
|
2345
|
+
if (typeof view.layer?.setValueForKeyPath === 'function') {
|
|
2346
|
+
view.layer.setValueForKeyPath(
|
|
2347
|
+
0,
|
|
2348
|
+
name === 'translateY' ? 'transform.translation.y' : 'transform.translation.x',
|
|
2349
|
+
)
|
|
2350
|
+
}
|
|
2351
|
+
} else if (name === 'zIndex') {
|
|
2352
|
+
if (view.layer) {
|
|
2353
|
+
view.layer.zPosition = 0
|
|
2354
|
+
}
|
|
2355
|
+
} else if (name === 'opacity') {
|
|
2356
|
+
view.alphaValue = 1
|
|
2357
|
+
} else if (name === 'textAlign' && node.type === 'label') {
|
|
2358
|
+
view.alignment = nodeClasses(node).includes('vx-sheet-grabber')
|
|
2359
|
+
? NSTextAlignment.Center
|
|
2360
|
+
: NSTextAlignment.Left
|
|
2361
|
+
} else if (name === 'borderWidth') {
|
|
2362
|
+
if (view.layer) {
|
|
2363
|
+
view.layer.borderWidth = 0
|
|
2364
|
+
}
|
|
2365
|
+
} else if (name === 'borderColor') {
|
|
2366
|
+
if (view.layer) {
|
|
2367
|
+
view.layer.borderColor = null
|
|
2368
|
+
}
|
|
2369
|
+
} else if (MARGIN_SIDES[name] || name === 'margin') {
|
|
2370
|
+
clearMarginStyle(node, name)
|
|
2371
|
+
}
|
|
2372
|
+
// lineHeight folds into setLabelText from props.style — no native reset.
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
function applyStyle(node: ElementNode, style: PropBag) {
|
|
2376
|
+
if (style != null && typeof style !== 'object') {
|
|
1840
2377
|
throw new Error('AppKit spike expects style to be an object')
|
|
1841
2378
|
}
|
|
1842
2379
|
|
|
1843
|
-
|
|
2380
|
+
// The style prop replaces wholesale, so keys dropped or nulled since the
|
|
2381
|
+
// last apply must reset the native state they installed before the current
|
|
2382
|
+
// values go on — otherwise removed styles linger on the view.
|
|
2383
|
+
const next: PropBag = style ?? {}
|
|
2384
|
+
if (node.type === 'flexboxlayout' || node.type === 'stack') {
|
|
2385
|
+
node.styleLayout = Object.fromEntries(
|
|
2386
|
+
Object.entries(next).filter(([name]) => isStackLayoutInput(name)),
|
|
2387
|
+
)
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
const prev: PropBag = node.appliedStyle ?? {}
|
|
2391
|
+
for (const name of Object.keys(prev)) {
|
|
2392
|
+
if (prev[name] != null && next[name] == null) {
|
|
2393
|
+
resetStyle(node, name)
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
for (const [name, value] of Object.entries(next)) {
|
|
1844
2398
|
if (value == null) {
|
|
1845
2399
|
continue
|
|
1846
2400
|
}
|
|
1847
2401
|
|
|
1848
|
-
if (
|
|
2402
|
+
if ((node.type === 'flexboxlayout' || node.type === 'stack') && isStackLayoutInput(name)) {
|
|
2403
|
+
// Applied together below, after all style inputs have reconciled.
|
|
2404
|
+
continue
|
|
2405
|
+
} else if (name === 'pointerEvents') {
|
|
1849
2406
|
if (value === 'none') {
|
|
1850
2407
|
inputTransparentViews.add(node.view!)
|
|
1851
2408
|
} else {
|
|
@@ -1855,20 +2412,20 @@ function applyStyle(node: ElementNode, style: PropBag) {
|
|
|
1855
2412
|
// NSImageScaling: 0 ProportionallyDown, 1 AxesIndependently,
|
|
1856
2413
|
// 2 None (centered, unscaled), 3 ProportionallyUpOrDown.
|
|
1857
2414
|
// AppKit has no cover mode — 'cover' degrades to scale-down.
|
|
1858
|
-
|
|
2415
|
+
const imageScaling: Record<string, number> = {
|
|
2416
|
+
contain: 3,
|
|
2417
|
+
'scale-down': 0,
|
|
2418
|
+
fill: 1,
|
|
2419
|
+
none: 2,
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
node.view!.imageScaling = imageScaling[String(value)] ?? 0
|
|
1859
2423
|
} else if (name === 'fontSize' && ['label', 'textfield', 'textview'].includes(node.type)) {
|
|
1860
|
-
|
|
1861
|
-
node
|
|
1862
|
-
node.view!.font = fontForFamilyStyle(
|
|
1863
|
-
value,
|
|
1864
|
-
weight,
|
|
1865
|
-
style.fontFamily ?? node.appliedFontFamily,
|
|
1866
|
-
)
|
|
2424
|
+
node.styleFontSize = value
|
|
2425
|
+
syncNodeFont(node)
|
|
1867
2426
|
} else if (name === 'fontFamily' && ['label', 'textfield', 'textview'].includes(node.type)) {
|
|
1868
|
-
node.
|
|
1869
|
-
|
|
1870
|
-
const weight = style.fontWeight ?? node.appliedFontWeight ?? 400
|
|
1871
|
-
node.view!.font = fontForFamilyStyle(size, weight, value)
|
|
2427
|
+
node.styleFontFamily = String(value)
|
|
2428
|
+
syncNodeFont(node)
|
|
1872
2429
|
} else if (name === 'color' && ['label', 'textfield', 'textview'].includes(node.type)) {
|
|
1873
2430
|
node.styleColor = String(value)
|
|
1874
2431
|
node.view!.textColor = nativeColor(value)
|
|
@@ -1876,31 +2433,28 @@ function applyStyle(node: ElementNode, style: PropBag) {
|
|
|
1876
2433
|
// syncText applies this to each paragraph; it is not the label's fixed height.
|
|
1877
2434
|
continue
|
|
1878
2435
|
} else if (name === 'padding' && node.type === 'flexboxlayout') {
|
|
1879
|
-
node.
|
|
2436
|
+
node.stylePaddingBox = parseEdgeInsets(value)
|
|
2437
|
+
syncEdgeInsets(node)
|
|
1880
2438
|
} else if (EDGE_INSET_PROPS.has(name) && node.type === 'flexboxlayout') {
|
|
1881
|
-
const
|
|
2439
|
+
const edges = (node.stylePaddingEdges ??= {})
|
|
1882
2440
|
for (const edge of EDGE_INSET_PROPS.get(name)!) {
|
|
1883
|
-
|
|
2441
|
+
edges[edge] = Number(value) || 0
|
|
1884
2442
|
}
|
|
1885
2443
|
|
|
1886
|
-
node
|
|
2444
|
+
syncEdgeInsets(node)
|
|
1887
2445
|
} else if (name === 'backgroundColor' && node.view) {
|
|
1888
2446
|
node.styleBg = String(value)
|
|
2447
|
+
node.bgApplied = true
|
|
1889
2448
|
node.view.wantsLayer = true
|
|
1890
2449
|
node.view.layer.backgroundColor = nativeColor(value).CGColor
|
|
1891
2450
|
} else if (name === 'borderRadius' && node.view) {
|
|
1892
|
-
node.
|
|
1893
|
-
node
|
|
1894
|
-
node.view.layer.masksToBounds = true
|
|
2451
|
+
node.styleRadius = value
|
|
2452
|
+
syncCornerRadius(node)
|
|
1895
2453
|
} else if ((name === 'width' || name === 'height') && node.view) {
|
|
1896
2454
|
setSizeConstraint(node, name, value)
|
|
1897
2455
|
} else if (name === 'left' && node.view) {
|
|
1898
|
-
if (node.parent?.type === 'gridlayout') {
|
|
1899
|
-
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
if (node.parent?.type === 'absolutelayout') {
|
|
1903
|
-
layoutAbsoluteChildren(node.parent)
|
|
2456
|
+
if (node.parent?.type === 'gridlayout' || node.parent?.type === 'absolutelayout') {
|
|
2457
|
+
queueLayoutReconcile(node.container, node.parent)
|
|
1904
2458
|
}
|
|
1905
2459
|
} else if ((name === 'translateX' || name === 'translateY' || name === 'zIndex') && node.view) {
|
|
1906
2460
|
node.view.wantsLayer = true
|
|
@@ -1917,12 +2471,8 @@ function applyStyle(node: ElementNode, style: PropBag) {
|
|
|
1917
2471
|
} else if (name === 'opacity' && node.view) {
|
|
1918
2472
|
node.view.alphaValue = Number(value)
|
|
1919
2473
|
} else if (name === 'fontWeight' && ['label', 'textfield', 'textview'].includes(node.type)) {
|
|
1920
|
-
node.
|
|
1921
|
-
node
|
|
1922
|
-
node.view!.font.pointSize,
|
|
1923
|
-
value,
|
|
1924
|
-
style.fontFamily ?? node.appliedFontFamily,
|
|
1925
|
-
)
|
|
2474
|
+
node.styleFontWeight = String(value)
|
|
2475
|
+
syncNodeFont(node)
|
|
1926
2476
|
} else if (name === 'textAlign' && node.type === 'label') {
|
|
1927
2477
|
node.view!.alignment =
|
|
1928
2478
|
value === 'left'
|
|
@@ -1942,6 +2492,9 @@ function applyStyle(node: ElementNode, style: PropBag) {
|
|
|
1942
2492
|
console.warn('[macos-style] ignored unsupported style.' + name + ' on <' + node.type + '>')
|
|
1943
2493
|
}
|
|
1944
2494
|
}
|
|
2495
|
+
|
|
2496
|
+
node.appliedStyle = next
|
|
2497
|
+
syncStackLayout(node)
|
|
1945
2498
|
}
|
|
1946
2499
|
|
|
1947
2500
|
function applyClassName(node: ElementNode, value: any) {
|
|
@@ -1949,11 +2502,23 @@ function applyClassName(node: ElementNode, value: any) {
|
|
|
1949
2502
|
.split(/\s+/)
|
|
1950
2503
|
.filter(Boolean)
|
|
1951
2504
|
|
|
2505
|
+
// Class effects re-derive from scratch on every apply: the class-owned
|
|
2506
|
+
// fields reset here and re-populate below, so removing a class drops the
|
|
2507
|
+
// native state it installed instead of leaving stale values behind.
|
|
2508
|
+
node.classFontSize = undefined
|
|
2509
|
+
node.classFontWeight = undefined
|
|
2510
|
+
node.classInsets = undefined
|
|
2511
|
+
node.classRadius = undefined
|
|
2512
|
+
node.classSpacing = undefined
|
|
2513
|
+
node.classOrientation = undefined
|
|
2514
|
+
node.colorSlot = undefined
|
|
2515
|
+
node.bgSlot = undefined
|
|
2516
|
+
|
|
1952
2517
|
if (node.type === 'label') {
|
|
1953
|
-
|
|
2518
|
+
const grabber = classes.includes('vx-sheet-grabber')
|
|
2519
|
+
if (grabber) {
|
|
1954
2520
|
node.classLineHeight = 24
|
|
1955
2521
|
setSizeConstraint(node, 'height', 24)
|
|
1956
|
-
node.view!.alignment = NSTextAlignment.Center
|
|
1957
2522
|
}
|
|
1958
2523
|
|
|
1959
2524
|
const sizes: Record<string, number> = {
|
|
@@ -1986,18 +2551,14 @@ function applyClassName(node: ElementNode, value: any) {
|
|
|
1986
2551
|
for (const name of classes) {
|
|
1987
2552
|
const heading = headingMetrics[name]
|
|
1988
2553
|
if (heading) {
|
|
1989
|
-
node.
|
|
2554
|
+
node.classFontWeight = '700'
|
|
2555
|
+
node.classFontSize = heading.size
|
|
1990
2556
|
node.headingDefaultHeight = heading.height
|
|
1991
|
-
node.view!.font = fontForFamilyStyle(heading.size, 700, node.appliedFontFamily)
|
|
1992
2557
|
}
|
|
1993
2558
|
|
|
1994
2559
|
if (sizes[name]) {
|
|
2560
|
+
node.classFontSize = sizes[name]
|
|
1995
2561
|
node.headingDefaultHeight = undefined
|
|
1996
|
-
node.view!.font = fontForFamilyStyle(
|
|
1997
|
-
sizes[name],
|
|
1998
|
-
node.appliedFontWeight ?? 400,
|
|
1999
|
-
node.appliedFontFamily,
|
|
2000
|
-
)
|
|
2001
2562
|
}
|
|
2002
2563
|
|
|
2003
2564
|
if (lineHeights[name] != null) {
|
|
@@ -2005,13 +2566,11 @@ function applyClassName(node: ElementNode, value: any) {
|
|
|
2005
2566
|
}
|
|
2006
2567
|
|
|
2007
2568
|
if (name === 'font-semibold') {
|
|
2008
|
-
node.
|
|
2009
|
-
node.view!.font = fontForFamilyStyle(node.view!.font.pointSize, 600, node.appliedFontFamily)
|
|
2569
|
+
node.classFontWeight = '600'
|
|
2010
2570
|
}
|
|
2011
2571
|
|
|
2012
2572
|
if (name === 'font-bold') {
|
|
2013
|
-
node.
|
|
2014
|
-
node.view!.font = fontForFamilyStyle(node.view!.font.pointSize, 700, node.appliedFontFamily)
|
|
2573
|
+
node.classFontWeight = '700'
|
|
2015
2574
|
}
|
|
2016
2575
|
|
|
2017
2576
|
if (name === 'text-muted') {
|
|
@@ -2022,6 +2581,28 @@ function applyClassName(node: ElementNode, value: any) {
|
|
|
2022
2581
|
node.colorSlot = 'onprimary'
|
|
2023
2582
|
}
|
|
2024
2583
|
}
|
|
2584
|
+
|
|
2585
|
+
syncNodeFont(node)
|
|
2586
|
+
|
|
2587
|
+
if (!grabber && node.props?.style?.height != null) {
|
|
2588
|
+
// The grabber's 24pt pin shares the height spec with style.height —
|
|
2589
|
+
// re-assert the style value once the class is gone.
|
|
2590
|
+
setSizeConstraint(node, 'height', node.props.style.height)
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
if (grabber) {
|
|
2594
|
+
node.view!.alignment = NSTextAlignment.Center
|
|
2595
|
+
} else {
|
|
2596
|
+
// Without the grabber the label returns to its textAlign — or the
|
|
2597
|
+
// Left default when no alignment style is in effect.
|
|
2598
|
+
const textAlign = node.props?.style?.textAlign
|
|
2599
|
+
node.view!.alignment =
|
|
2600
|
+
textAlign == null || textAlign === 'left'
|
|
2601
|
+
? NSTextAlignment.Left
|
|
2602
|
+
: textAlign === 'right'
|
|
2603
|
+
? NSTextAlignment.Right
|
|
2604
|
+
: NSTextAlignment.Center
|
|
2605
|
+
}
|
|
2025
2606
|
}
|
|
2026
2607
|
|
|
2027
2608
|
if (
|
|
@@ -2040,37 +2621,27 @@ function applyClassName(node: ElementNode, value: any) {
|
|
|
2040
2621
|
|
|
2041
2622
|
for (const name of classes) {
|
|
2042
2623
|
if (gaps[name] !== undefined) {
|
|
2043
|
-
node.
|
|
2624
|
+
node.classSpacing = gaps[name]
|
|
2044
2625
|
}
|
|
2045
2626
|
|
|
2046
2627
|
if (name === 'flex-row') {
|
|
2047
|
-
node.
|
|
2628
|
+
node.classOrientation = NSUserInterfaceLayoutOrientation.Horizontal
|
|
2048
2629
|
}
|
|
2049
2630
|
|
|
2050
2631
|
if (name === 'flex-col') {
|
|
2051
|
-
node.
|
|
2052
|
-
}
|
|
2053
|
-
|
|
2054
|
-
if (name === 'items-center' || name === 'items-start' || name === 'items-end') {
|
|
2055
|
-
node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
|
|
2632
|
+
node.classOrientation = NSUserInterfaceLayoutOrientation.Vertical
|
|
2056
2633
|
}
|
|
2057
2634
|
|
|
2058
2635
|
if (name === 'vx-toast' && node.type === 'flexboxlayout') {
|
|
2059
|
-
|
|
2636
|
+
node.classInsets = { top: 12, right: 12, bottom: 12, left: 12 }
|
|
2060
2637
|
}
|
|
2061
2638
|
|
|
2062
2639
|
if (name === 'vx-toast-viewport') {
|
|
2063
|
-
|
|
2640
|
+
node.classSpacing = 8
|
|
2064
2641
|
}
|
|
2065
2642
|
|
|
2066
2643
|
if (name === 'vx-button') {
|
|
2067
|
-
node.
|
|
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
|
|
2644
|
+
node.classOrientation = NSUserInterfaceLayoutOrientation.Horizontal
|
|
2074
2645
|
}
|
|
2075
2646
|
|
|
2076
2647
|
if (name === 'flex-1') {
|
|
@@ -2093,37 +2664,21 @@ function applyClassName(node: ElementNode, value: any) {
|
|
|
2093
2664
|
}
|
|
2094
2665
|
|
|
2095
2666
|
if (name === 'rounded-full' || name.startsWith('rounded-')) {
|
|
2096
|
-
node.
|
|
2097
|
-
node.view!.layer.cornerRadius = name === 'rounded-full' ? 12 : 8
|
|
2098
|
-
node.view!.layer.masksToBounds = true
|
|
2667
|
+
node.classRadius = name === 'rounded-full' ? 12 : 8
|
|
2099
2668
|
}
|
|
2100
2669
|
|
|
2101
2670
|
if (name === 'bg-primary' || name === 'btn') {
|
|
2102
2671
|
node.bgSlot = 'primary'
|
|
2103
|
-
node.
|
|
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 }
|
|
2672
|
+
node.classInsets = { top: 6, right: 10, bottom: 6, left: 10 }
|
|
2109
2673
|
}
|
|
2110
2674
|
|
|
2111
2675
|
if (name === 'bg-danger') {
|
|
2112
2676
|
node.bgSlot = 'danger'
|
|
2113
|
-
node.view!.wantsLayer = true
|
|
2114
|
-
node.view!.layer.backgroundColor = nativeColor(
|
|
2115
|
-
SCHEME_COLORS[nodeScheme(node)].danger,
|
|
2116
|
-
).CGColor
|
|
2117
2677
|
}
|
|
2118
2678
|
|
|
2119
2679
|
if (name === 'btn-secondary' || name === 'chip' || name === 'chip-off') {
|
|
2120
2680
|
node.bgSlot = 'secondary'
|
|
2121
|
-
node.
|
|
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 }
|
|
2681
|
+
node.classInsets = { top: 4, right: 8, bottom: 4, left: 8 }
|
|
2127
2682
|
}
|
|
2128
2683
|
|
|
2129
2684
|
// Panels that paint the web body's --color-surface. On this host the
|
|
@@ -2136,22 +2691,18 @@ function applyClassName(node: ElementNode, value: any) {
|
|
|
2136
2691
|
name === 'modal-panel'
|
|
2137
2692
|
) {
|
|
2138
2693
|
node.bgSlot = 'surface'
|
|
2139
|
-
node.view!.wantsLayer = true
|
|
2140
|
-
node.view!.layer.backgroundColor = nativeColor(
|
|
2141
|
-
SCHEME_COLORS[nodeScheme(node)].surface,
|
|
2142
|
-
).CGColor
|
|
2143
2694
|
}
|
|
2144
2695
|
}
|
|
2696
|
+
|
|
2697
|
+
syncStackLayout(node)
|
|
2698
|
+
|
|
2699
|
+
syncEdgeInsets(node)
|
|
2700
|
+
syncCornerRadius(node)
|
|
2145
2701
|
}
|
|
2146
2702
|
|
|
2147
2703
|
if (node.type === 'textfield' || node.type === 'textview') {
|
|
2148
2704
|
if (classes.includes('vx-input') || classes.includes('vx-textarea')) {
|
|
2149
|
-
node.
|
|
2150
|
-
14,
|
|
2151
|
-
node.appliedFontWeight ?? 400,
|
|
2152
|
-
node.appliedFontFamily,
|
|
2153
|
-
)
|
|
2154
|
-
|
|
2705
|
+
node.classFontSize = 14
|
|
2155
2706
|
node.colorSlot = 'text'
|
|
2156
2707
|
node.view!.drawsBackground = false
|
|
2157
2708
|
if (node.type === 'textfield') {
|
|
@@ -2162,6 +2713,8 @@ function applyClassName(node: ElementNode, value: any) {
|
|
|
2162
2713
|
node.view!.textContainerInset = { width: 0, height: 0 }
|
|
2163
2714
|
}
|
|
2164
2715
|
}
|
|
2716
|
+
|
|
2717
|
+
syncNodeFont(node)
|
|
2165
2718
|
}
|
|
2166
2719
|
|
|
2167
2720
|
applyThemeColors(node)
|
|
@@ -2176,7 +2729,7 @@ function applyClassName(node: ElementNode, value: any) {
|
|
|
2176
2729
|
}
|
|
2177
2730
|
|
|
2178
2731
|
if (node.parent) {
|
|
2179
|
-
|
|
2732
|
+
queueLayoutReconcile(node.container, node.parent)
|
|
2180
2733
|
}
|
|
2181
2734
|
|
|
2182
2735
|
updateStackDistribution(node)
|
|
@@ -2194,6 +2747,38 @@ function textContent(node: ElementNode): string {
|
|
|
2194
2747
|
return node.children.map(textContent).join('')
|
|
2195
2748
|
}
|
|
2196
2749
|
|
|
2750
|
+
// Keep the native value intact: AppKit truncates only when drawing in the frame.
|
|
2751
|
+
function syncLabelOverflow(node: ElementNode) {
|
|
2752
|
+
const { maxLines, whiteSpace, textOverflow } = node.props
|
|
2753
|
+
if (whiteSpace != null && !['normal', 'nowrap'].includes(whiteSpace)) {
|
|
2754
|
+
throw new Error('[macos-host] label whiteSpace supports only normal and nowrap')
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
if (textOverflow != null && !['clip', 'ellipsis'].includes(textOverflow)) {
|
|
2758
|
+
throw new Error('[macos-host] label textOverflow supports only clip and ellipsis')
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
if (maxLines != null && (!Number.isInteger(maxLines) || maxLines < 0)) {
|
|
2762
|
+
throw new Error('[macos-host] label maxLines must be a non-negative integer')
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
const singleLine = whiteSpace === 'nowrap' || maxLines === 1
|
|
2766
|
+
const view = node.view as NSTextField
|
|
2767
|
+
|
|
2768
|
+
view.maximumNumberOfLines = singleLine ? 1 : (maxLines ?? 0)
|
|
2769
|
+
view.cell.wraps = !singleLine
|
|
2770
|
+
view.cell.scrollable = false
|
|
2771
|
+
view.cell.usesSingleLineMode = singleLine
|
|
2772
|
+
view.cell.lineBreakMode = singleLine
|
|
2773
|
+
? textOverflow === 'ellipsis'
|
|
2774
|
+
? NSLineBreakMode.TruncatingTail
|
|
2775
|
+
: NSLineBreakMode.Clipping
|
|
2776
|
+
: NSLineBreakMode.WordWrapping
|
|
2777
|
+
|
|
2778
|
+
view.cell.truncatesLastVisibleLine = !singleLine && textOverflow === 'ellipsis'
|
|
2779
|
+
view.invalidateIntrinsicContentSize()
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2197
2782
|
function setLabelText(node: ElementNode, text: any) {
|
|
2198
2783
|
const lineHeight = Number(node.props?.style?.lineHeight)
|
|
2199
2784
|
if (!Number.isFinite(lineHeight) || lineHeight <= 0) {
|
|
@@ -2202,6 +2787,7 @@ function setLabelText(node: ElementNode, text: any) {
|
|
|
2202
2787
|
}
|
|
2203
2788
|
|
|
2204
2789
|
const paragraphStyle = NSMutableParagraphStyle.alloc().init()
|
|
2790
|
+
paragraphStyle.lineBreakMode = (node.view as NSTextField).cell.lineBreakMode
|
|
2205
2791
|
paragraphStyle.minimumLineHeight = lineHeight
|
|
2206
2792
|
paragraphStyle.maximumLineHeight = lineHeight
|
|
2207
2793
|
node.view!.attributedStringValue = NSAttributedString.alloc().initWithStringAttributes(text, {
|
|
@@ -2235,7 +2821,7 @@ function setAction(node: ElementNode, value: any) {
|
|
|
2235
2821
|
actionIdsByView.set(node.view!, node.actionId)
|
|
2236
2822
|
actionHandlers.set(node.actionId, null)
|
|
2237
2823
|
node.view!.addGestureRecognizer(
|
|
2238
|
-
NSClickGestureRecognizer.alloc().initWithTargetAction(buttonActionTarget, 'viewPressed'),
|
|
2824
|
+
NSClickGestureRecognizer.alloc().initWithTargetAction(buttonActionTarget, 'viewPressed:'),
|
|
2239
2825
|
)
|
|
2240
2826
|
}
|
|
2241
2827
|
|
|
@@ -2257,7 +2843,7 @@ function setPanAction(node: ElementNode, value: any) {
|
|
|
2257
2843
|
if (typeof value === 'function' && !node.panGestureRecognizer) {
|
|
2258
2844
|
node.panGestureRecognizer = NSPanGestureRecognizer.alloc().initWithTargetAction(
|
|
2259
2845
|
buttonActionTarget,
|
|
2260
|
-
'viewPanned',
|
|
2846
|
+
'viewPanned:',
|
|
2261
2847
|
)
|
|
2262
2848
|
|
|
2263
2849
|
node.view!.addGestureRecognizer(node.panGestureRecognizer)
|
|
@@ -2296,6 +2882,35 @@ function setControlAction(node: ElementNode, value: any, readValue: () => any) {
|
|
|
2296
2882
|
)
|
|
2297
2883
|
}
|
|
2298
2884
|
|
|
2885
|
+
/** Return-key submission for single-line fields, invoked by the field's
|
|
2886
|
+
* target/action — kept distinct from per-edit `onTextChange`. */
|
|
2887
|
+
function setTextSubmitAction(node: ElementNode, value: any) {
|
|
2888
|
+
node.submitHandler =
|
|
2889
|
+
typeof value === 'function'
|
|
2890
|
+
? () => {
|
|
2891
|
+
try {
|
|
2892
|
+
node.container.root!.eventScope('discrete', value)
|
|
2893
|
+
} catch (error) {
|
|
2894
|
+
console.error('[macos-event] text submit handler failed', error)
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
: null
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
/** `isEnabled`/`editable` → AppKit state. Disabled fields reject edits and
|
|
2901
|
+
* selection; read-only fields stay selectable so text can be copied. */
|
|
2902
|
+
function syncTextControlState(node: ElementNode) {
|
|
2903
|
+
const enabled = node.props.isEnabled !== false && node.props.enabled !== false
|
|
2904
|
+
const editable = enabled && node.props.editable !== false
|
|
2905
|
+
const view = node.view!
|
|
2906
|
+
if (node.type === 'textfield') {
|
|
2907
|
+
view.enabled = enabled
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
view.editable = editable
|
|
2911
|
+
view.selectable = editable || enabled
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2299
2914
|
function applyAccessibility(node: ElementNode, name: string, value: any) {
|
|
2300
2915
|
if (node.type === 'flexboxlayout' && node.actionId !== undefined) {
|
|
2301
2916
|
const props = stackAccessibilityPropsByView.get(node.view!) ?? {}
|
|
@@ -2312,6 +2927,20 @@ function applyAccessibility(node: ElementNode, name: string, value: any) {
|
|
|
2312
2927
|
if (node.type === 'label' && name === 'accessibilityLabel') {
|
|
2313
2928
|
node.view!.setAccessibilityLabel?.(String(value ?? ''))
|
|
2314
2929
|
}
|
|
2930
|
+
|
|
2931
|
+
if (node.type === 'textfield' || node.type === 'textview') {
|
|
2932
|
+
if (name === 'accessibilityLabel') {
|
|
2933
|
+
node.view!.setAccessibilityLabel?.(String(value ?? ''))
|
|
2934
|
+
} else if (name === 'accessibilityHint') {
|
|
2935
|
+
node.view!.setAccessibilityHint?.(String(value ?? ''))
|
|
2936
|
+
} else if (name === 'accessible') {
|
|
2937
|
+
node.view!.setAccessibilityElement?.(value !== false)
|
|
2938
|
+
} else if (name === 'accessibilityState' && node.type === 'textview') {
|
|
2939
|
+
// NSTextField reports AX enabled through its `enabled` flag;
|
|
2940
|
+
// NSTextView has no enabled state, so it is mirrored here.
|
|
2941
|
+
node.view!.setAccessibilityEnabled?.(value?.disabled !== true)
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2315
2944
|
}
|
|
2316
2945
|
|
|
2317
2946
|
function applyProps(node: ElementNode, props: PropBag) {
|
|
@@ -2329,7 +2958,20 @@ function applyProps(node: ElementNode, props: PropBag) {
|
|
|
2329
2958
|
return
|
|
2330
2959
|
}
|
|
2331
2960
|
|
|
2961
|
+
const layoutChildProps: Record<string, readonly string[]> = {
|
|
2962
|
+
absolutelayout: ['left', 'top', 'right', 'bottom'],
|
|
2963
|
+
gridlayout: ['row', 'col', 'rowSpan', 'colSpan', 'horizontalAlignment', 'verticalAlignment'],
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2332
2966
|
for (const [name, value] of Object.entries(props)) {
|
|
2967
|
+
if (node.parent && layoutChildProps[node.parent.type]?.includes(name)) {
|
|
2968
|
+
continue
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
if ((node.type === 'flexboxlayout' || node.type === 'stack') && isStackLayoutInput(name)) {
|
|
2972
|
+
continue
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2333
2975
|
if (name === 'onLayoutChanged') {
|
|
2334
2976
|
setLayoutAction(node, value)
|
|
2335
2977
|
continue
|
|
@@ -2338,7 +2980,7 @@ function applyProps(node: ElementNode, props: PropBag) {
|
|
|
2338
2980
|
switch (node.type) {
|
|
2339
2981
|
case 'stack':
|
|
2340
2982
|
if (name === 'spacing') {
|
|
2341
|
-
node
|
|
2983
|
+
syncStackLayout(node)
|
|
2342
2984
|
} else if (name === 'style') {
|
|
2343
2985
|
applyStyle(node, value)
|
|
2344
2986
|
} else if (name === 'className') {
|
|
@@ -2351,33 +2993,12 @@ function applyProps(node: ElementNode, props: PropBag) {
|
|
|
2351
2993
|
|
|
2352
2994
|
break
|
|
2353
2995
|
case 'flexboxlayout':
|
|
2354
|
-
if (name === '
|
|
2355
|
-
node
|
|
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)
|
|
2996
|
+
if (name === 'spacing') {
|
|
2997
|
+
syncStackLayout(node)
|
|
2374
2998
|
} else if (name === 'style') {
|
|
2375
2999
|
applyStyle(node, value)
|
|
2376
3000
|
} else if (name === 'className') {
|
|
2377
3001
|
applyClassName(node, value)
|
|
2378
|
-
moveStackChildren(node)
|
|
2379
|
-
node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
|
|
2380
|
-
updateCrossAxisConstraints(node)
|
|
2381
3002
|
} else if (name === 'id') {
|
|
2382
3003
|
continue
|
|
2383
3004
|
} else if (name === 'onTap') {
|
|
@@ -2399,18 +3020,10 @@ function applyProps(node: ElementNode, props: PropBag) {
|
|
|
2399
3020
|
'alignItems',
|
|
2400
3021
|
'justifyContent',
|
|
2401
3022
|
'flexWrap',
|
|
2402
|
-
'row',
|
|
2403
|
-
'col',
|
|
2404
|
-
'rowSpan',
|
|
2405
|
-
'colSpan',
|
|
2406
|
-
'left',
|
|
2407
|
-
'top',
|
|
2408
3023
|
'flexGrow',
|
|
2409
3024
|
'flexShrink',
|
|
2410
3025
|
'alignSelf',
|
|
2411
3026
|
'order',
|
|
2412
|
-
'horizontalAlignment',
|
|
2413
|
-
'verticalAlignment',
|
|
2414
3027
|
].includes(name)
|
|
2415
3028
|
) {
|
|
2416
3029
|
continue
|
|
@@ -2441,7 +3054,7 @@ function applyProps(node: ElementNode, props: PropBag) {
|
|
|
2441
3054
|
} else if (name === 'accessible' || name.startsWith('accessibility')) {
|
|
2442
3055
|
applyAccessibility(node, name, value)
|
|
2443
3056
|
} else if (['rows', 'columns'].includes(name)) {
|
|
2444
|
-
|
|
3057
|
+
queueLayoutReconcile(node.container, node)
|
|
2445
3058
|
} else {
|
|
2446
3059
|
console.warn('[macos-host] ignored gridlayout prop ' + name)
|
|
2447
3060
|
}
|
|
@@ -2467,11 +3080,8 @@ function applyProps(node: ElementNode, props: PropBag) {
|
|
|
2467
3080
|
if (name === 'text') {
|
|
2468
3081
|
setLabelText(node, String(value ?? ''))
|
|
2469
3082
|
} else if (name === 'fontSize') {
|
|
2470
|
-
node.
|
|
2471
|
-
|
|
2472
|
-
node.appliedFontWeight ?? 400,
|
|
2473
|
-
node.appliedFontFamily,
|
|
2474
|
-
)
|
|
3083
|
+
node.propFontSize = Number(value ?? 16)
|
|
3084
|
+
syncNodeFont(node)
|
|
2475
3085
|
} else if (name === 'style') {
|
|
2476
3086
|
applyStyle(node, value)
|
|
2477
3087
|
syncText(node)
|
|
@@ -2479,9 +3089,9 @@ function applyProps(node: ElementNode, props: PropBag) {
|
|
|
2479
3089
|
applyClassName(node, value)
|
|
2480
3090
|
} else if (name === 'id') {
|
|
2481
3091
|
continue
|
|
2482
|
-
} else if (['maxLines', 'whiteSpace', 'textOverflow'
|
|
3092
|
+
} else if (['maxLines', 'whiteSpace', 'textOverflow'].includes(name)) {
|
|
2483
3093
|
continue
|
|
2484
|
-
} else if (name.startsWith('accessibility')) {
|
|
3094
|
+
} else if (name === 'accessible' || name.startsWith('accessibility')) {
|
|
2485
3095
|
applyAccessibility(node, name, value)
|
|
2486
3096
|
} else if (name.startsWith('on') && value == null) {
|
|
2487
3097
|
continue
|
|
@@ -2563,6 +3173,30 @@ function applyProps(node: ElementNode, props: PropBag) {
|
|
|
2563
3173
|
setControlAction(node, value, () =>
|
|
2564
3174
|
String(node.type === 'textview' ? node.view!.string : (node.view!.stringValue ?? '')),
|
|
2565
3175
|
)
|
|
3176
|
+
} else if (name === 'onSubmit') {
|
|
3177
|
+
if (node.type === 'textfield') {
|
|
3178
|
+
setTextSubmitAction(node, value)
|
|
3179
|
+
} else if (typeof value === 'function') {
|
|
3180
|
+
console.warn('[macos-host] onSubmit is unsupported on multiline textview')
|
|
3181
|
+
}
|
|
3182
|
+
} else if (name === 'editable' || name === 'isEnabled' || name === 'enabled') {
|
|
3183
|
+
syncTextControlState(node)
|
|
3184
|
+
} else if (name === 'secure') {
|
|
3185
|
+
// The native class is chosen in makeTextField; a secure change
|
|
3186
|
+
// would require a new view, so it is rejected rather than
|
|
3187
|
+
// silently downgraded to plain text.
|
|
3188
|
+
if (node.type !== 'textfield') {
|
|
3189
|
+
if (value) {
|
|
3190
|
+
console.warn(
|
|
3191
|
+
'[macos-host] secure is unsupported on <' + node.type + '>; rendering plain text',
|
|
3192
|
+
)
|
|
3193
|
+
}
|
|
3194
|
+
} else if (!!value !== node.secure) {
|
|
3195
|
+
console.warn(
|
|
3196
|
+
'[macos-host] changing "secure" after mount is unsupported; the field keeps its initial secure=' +
|
|
3197
|
+
node.secure,
|
|
3198
|
+
)
|
|
3199
|
+
}
|
|
2566
3200
|
} else if (name === 'style') {
|
|
2567
3201
|
applyStyle(node, value)
|
|
2568
3202
|
} else if (name === 'className') {
|
|
@@ -2571,21 +3205,11 @@ function applyProps(node: ElementNode, props: PropBag) {
|
|
|
2571
3205
|
continue
|
|
2572
3206
|
} else if (name === 'rows' && node.type === 'textview') {
|
|
2573
3207
|
continue
|
|
2574
|
-
} else if (name.startsWith('accessibility')) {
|
|
3208
|
+
} else if (name === 'accessible' || name.startsWith('accessibility')) {
|
|
2575
3209
|
applyAccessibility(node, name, value)
|
|
2576
3210
|
} else if (name.startsWith('on') && value == null) {
|
|
2577
3211
|
continue
|
|
2578
|
-
} else if (
|
|
2579
|
-
[
|
|
2580
|
-
'editable',
|
|
2581
|
-
'enabled',
|
|
2582
|
-
'secure',
|
|
2583
|
-
'keyboardType',
|
|
2584
|
-
'returnKeyType',
|
|
2585
|
-
'autoGrow',
|
|
2586
|
-
'maxRows',
|
|
2587
|
-
].includes(name)
|
|
2588
|
-
) {
|
|
3212
|
+
} else if (['keyboardType', 'returnKeyType', 'autoGrow', 'maxRows'].includes(name)) {
|
|
2589
3213
|
continue
|
|
2590
3214
|
} else {
|
|
2591
3215
|
console.warn('[macos-host] ignored text control prop ' + name)
|
|
@@ -2644,7 +3268,7 @@ function applyProps(node: ElementNode, props: PropBag) {
|
|
|
2644
3268
|
break
|
|
2645
3269
|
case 'image':
|
|
2646
3270
|
if (name === 'src') {
|
|
2647
|
-
node.view
|
|
3271
|
+
updateImage(node.view as NSImageView, value)
|
|
2648
3272
|
} else if (name === 'style') {
|
|
2649
3273
|
applyStyle(node, value)
|
|
2650
3274
|
} else if (name === 'className' || name === 'id' || name === 'alt') {
|
|
@@ -2660,6 +3284,13 @@ function applyProps(node: ElementNode, props: PropBag) {
|
|
|
2660
3284
|
}
|
|
2661
3285
|
}
|
|
2662
3286
|
|
|
3287
|
+
if (
|
|
3288
|
+
(node.type === 'flexboxlayout' || node.type === 'stack') &&
|
|
3289
|
+
Object.keys(props).some(isStackLayoutInput)
|
|
3290
|
+
) {
|
|
3291
|
+
syncStackLayout(node)
|
|
3292
|
+
}
|
|
3293
|
+
|
|
2663
3294
|
if (node.type === 'webview') {
|
|
2664
3295
|
updateWebView(node, props, (height) => {
|
|
2665
3296
|
node.props.style = { ...node.props.style, height }
|
|
@@ -2672,8 +3303,17 @@ function applyProps(node: ElementNode, props: PropBag) {
|
|
|
2672
3303
|
}
|
|
2673
3304
|
|
|
2674
3305
|
if (node.type === 'label') {
|
|
3306
|
+
syncLabelOverflow(node)
|
|
3307
|
+
syncText(node)
|
|
2675
3308
|
const style = node.props.style ?? {}
|
|
2676
|
-
if (
|
|
3309
|
+
if (
|
|
3310
|
+
style.height == null &&
|
|
3311
|
+
node.props.maxLines != null &&
|
|
3312
|
+
node.props.maxLines !== 1 &&
|
|
3313
|
+
node.props.whiteSpace !== 'nowrap'
|
|
3314
|
+
) {
|
|
3315
|
+
setSizeConstraint(node, 'height', undefined)
|
|
3316
|
+
} else if (style.lineHeight == null && style.height == null) {
|
|
2677
3317
|
const size = Number(node.view!.font?.pointSize ?? 16)
|
|
2678
3318
|
const height =
|
|
2679
3319
|
style.fontSize == null && node.headingDefaultHeight != null
|
|
@@ -2700,14 +3340,10 @@ function applyProps(node: ElementNode, props: PropBag) {
|
|
|
2700
3340
|
}
|
|
2701
3341
|
}
|
|
2702
3342
|
|
|
2703
|
-
if (node.type === 'gridlayout') {
|
|
2704
|
-
|
|
2705
|
-
} else if (node.type === 'absolutelayout') {
|
|
2706
|
-
|
|
2707
|
-
} else if (node.parent?.type === 'gridlayout') {
|
|
2708
|
-
layoutGridChildren(node.parent)
|
|
2709
|
-
} else if (node.parent?.type === 'absolutelayout') {
|
|
2710
|
-
layoutAbsoluteChildren(node.parent)
|
|
3343
|
+
if (node.type === 'gridlayout' || node.type === 'absolutelayout') {
|
|
3344
|
+
queueLayoutReconcile(node.container, node)
|
|
3345
|
+
} else if (node.parent?.type === 'gridlayout' || node.parent?.type === 'absolutelayout') {
|
|
3346
|
+
queueLayoutReconcile(node.container, node.parent)
|
|
2711
3347
|
}
|
|
2712
3348
|
}
|
|
2713
3349
|
|
|
@@ -2725,6 +3361,7 @@ function detach(container: RootContainer, node: ElementNode) {
|
|
|
2725
3361
|
|
|
2726
3362
|
node.crossAxisConstraint = null
|
|
2727
3363
|
deactivateSizeConstraints(node)
|
|
3364
|
+
releasePlacementPins(node)
|
|
2728
3365
|
if (node.view) {
|
|
2729
3366
|
const parentView = previousParent?.childHost ?? previousParent?.view
|
|
2730
3367
|
if (parentView?.removeArrangedSubview) {
|
|
@@ -2734,20 +3371,53 @@ function detach(container: RootContainer, node: ElementNode) {
|
|
|
2734
3371
|
arrangedView(node).removeFromSuperview()
|
|
2735
3372
|
}
|
|
2736
3373
|
|
|
2737
|
-
|
|
2738
|
-
|
|
3374
|
+
queueLayoutReconcile(container, previousParent)
|
|
3375
|
+
syncText(previousParent)
|
|
3376
|
+
node.parent = null
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
/** Mark a parent for one sibling-wide reconcile after the batch's commands.
|
|
3380
|
+
* Insert/remove/update each used to rescan siblings, rebuild distribution,
|
|
3381
|
+
* and recreate every child's cross-axis constraint on the spot — n inserts
|
|
3382
|
+
* into one stack made that quadratic. Structural commands queue their dirty
|
|
3383
|
+
* parents here; `prepareBatch.apply` drains the set once at the end. Outside
|
|
3384
|
+
* a batch (no `layoutDirty` set) the reconcile runs immediately. */
|
|
3385
|
+
function queueLayoutReconcile(container: RootContainer, parent: ElementNode | null | undefined) {
|
|
3386
|
+
if (!parent) {
|
|
3387
|
+
return
|
|
3388
|
+
}
|
|
3389
|
+
|
|
3390
|
+
const pending = container?.layoutDirty
|
|
3391
|
+
if (pending) {
|
|
3392
|
+
pending.add(parent)
|
|
3393
|
+
return
|
|
2739
3394
|
}
|
|
2740
3395
|
|
|
2741
|
-
|
|
2742
|
-
|
|
3396
|
+
reconcileLayoutParent(parent)
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3399
|
+
function reconcileLayoutParent(parent: ElementNode) {
|
|
3400
|
+
if (parent.type === 'gridlayout') {
|
|
3401
|
+
layoutGridChildren(parent)
|
|
3402
|
+
return
|
|
2743
3403
|
}
|
|
2744
3404
|
|
|
2745
|
-
if (
|
|
2746
|
-
|
|
3405
|
+
if (parent.type === 'absolutelayout') {
|
|
3406
|
+
layoutAbsoluteChildren(parent)
|
|
3407
|
+
return
|
|
2747
3408
|
}
|
|
2748
3409
|
|
|
2749
|
-
|
|
2750
|
-
|
|
3410
|
+
const stack = parent.childHost ?? parent.view
|
|
3411
|
+
if (typeof stack?.addViewInGravity !== 'function') {
|
|
3412
|
+
return
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3415
|
+
for (const child of parent.children) {
|
|
3416
|
+
setStackChildPriorities(parent, child)
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3419
|
+
updateStackDistribution(parent)
|
|
3420
|
+
updateCrossAxisConstraints(parent)
|
|
2751
3421
|
}
|
|
2752
3422
|
|
|
2753
3423
|
function insert(
|
|
@@ -2756,62 +3426,54 @@ function insert(
|
|
|
2756
3426
|
node: ElementNode,
|
|
2757
3427
|
beforeId: number | null,
|
|
2758
3428
|
) {
|
|
2759
|
-
|
|
3429
|
+
// Validate before detaching — a rejected insert must not leave the node
|
|
3430
|
+
// orphaned from its current parent.
|
|
2760
3431
|
const parent = (parentId === null ? null : container.nodes.get(parentId)) ?? null
|
|
2761
3432
|
if (parentId !== null && !parent) {
|
|
2762
3433
|
throw new Error('Unknown AppKit parent ' + parentId)
|
|
2763
3434
|
}
|
|
2764
3435
|
|
|
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
|
-
) {
|
|
3436
|
+
if (parent && node.view && !VIEW_PARENT_TYPES.has(parent.type)) {
|
|
2774
3437
|
throw new Error('AppKit <' + parent.type + '> cannot contain child views')
|
|
2775
3438
|
}
|
|
2776
3439
|
|
|
3440
|
+
const parentView = parent?.childHost ?? parent?.view ?? container.hostView
|
|
3441
|
+
if (node.view && !parentView) {
|
|
3442
|
+
throw new Error('AppKit host has no parent view for node ' + node.id)
|
|
3443
|
+
}
|
|
3444
|
+
|
|
3445
|
+
detach(container, node)
|
|
2777
3446
|
const siblings = parent ? parent.children : container.children
|
|
2778
3447
|
const beforeIndex = beforeId === null ? -1 : siblings.findIndex((child) => child.id === beforeId)
|
|
2779
3448
|
const index = beforeIndex < 0 ? siblings.length : beforeIndex
|
|
2780
3449
|
siblings.splice(index, 0, node)
|
|
2781
3450
|
node.parent = parent
|
|
2782
3451
|
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
3452
|
if (parent) {
|
|
2789
3453
|
if (parent.type === 'gridlayout') {
|
|
2790
3454
|
parentView.addSubview(node.view)
|
|
2791
3455
|
node.view.translatesAutoresizingMaskIntoConstraints = false
|
|
2792
3456
|
deactivateSizeConstraints(node)
|
|
2793
|
-
|
|
3457
|
+
queueLayoutReconcile(container, parent)
|
|
2794
3458
|
} else if (parent.type === 'absolutelayout') {
|
|
2795
3459
|
parentView.addSubview(node.view)
|
|
2796
3460
|
node.view.translatesAutoresizingMaskIntoConstraints = false
|
|
2797
3461
|
deactivateSizeConstraints(node)
|
|
2798
|
-
|
|
3462
|
+
queueLayoutReconcile(container, parent)
|
|
2799
3463
|
} else {
|
|
2800
|
-
if (marginInsetsOf(node)) {
|
|
3464
|
+
if (marginInsetsOf(node) && !node.marginHost) {
|
|
2801
3465
|
makeMarginHost(node)
|
|
3466
|
+
setLayoutAction(node, node.props?.onLayoutChanged)
|
|
2802
3467
|
}
|
|
2803
3468
|
|
|
2804
3469
|
// AppKit insertion indices are local to a gravity area. Honor the
|
|
2805
3470
|
// renderer's before edge instead of appending moved keyed rows.
|
|
2806
3471
|
const gravity = stackGravity(parent, node)
|
|
2807
|
-
const arrangedIndex =
|
|
2808
|
-
.slice(0, index)
|
|
2809
|
-
.filter((child) => child.view && stackGravity(parent, child) === gravity).length
|
|
3472
|
+
const arrangedIndex = gravityInsertIndex(parent, node, gravity)
|
|
2810
3473
|
|
|
2811
3474
|
parentView.insertViewAtIndexInGravity(arrangedView(node), arrangedIndex, gravity)
|
|
2812
3475
|
applySizeConstraints(node)
|
|
2813
|
-
|
|
2814
|
-
updateCrossAxisConstraints(parent)
|
|
3476
|
+
queueLayoutReconcile(container, parent)
|
|
2815
3477
|
}
|
|
2816
3478
|
} else {
|
|
2817
3479
|
parentView.addSubview(node.view)
|
|
@@ -2846,6 +3508,7 @@ function remove(container: RootContainer, parentId: number | null, node: Element
|
|
|
2846
3508
|
|
|
2847
3509
|
node.crossAxisConstraint = null
|
|
2848
3510
|
deactivateSizeConstraints(node)
|
|
3511
|
+
releasePlacementPins(node)
|
|
2849
3512
|
if (node.view) {
|
|
2850
3513
|
const parentView = expectedParent?.childHost ?? expectedParent?.view
|
|
2851
3514
|
if (parentView?.removeArrangedSubview) {
|
|
@@ -2856,31 +3519,27 @@ function remove(container: RootContainer, parentId: number | null, node: Element
|
|
|
2856
3519
|
node.marginHost?.removeFromSuperview()
|
|
2857
3520
|
node.marginHost = null
|
|
2858
3521
|
node.marginConstraints = null
|
|
3522
|
+
setLayoutAction(node, node.props?.onLayoutChanged)
|
|
2859
3523
|
}
|
|
2860
3524
|
|
|
2861
|
-
|
|
2862
|
-
layoutGridChildren(expectedParent)
|
|
2863
|
-
}
|
|
2864
|
-
|
|
2865
|
-
if (expectedParent?.type === 'absolutelayout') {
|
|
2866
|
-
layoutAbsoluteChildren(expectedParent)
|
|
2867
|
-
}
|
|
2868
|
-
|
|
2869
|
-
if (expectedParent) {
|
|
2870
|
-
updateStackDistribution(expectedParent)
|
|
2871
|
-
}
|
|
2872
|
-
|
|
3525
|
+
queueLayoutReconcile(container, expectedParent)
|
|
2873
3526
|
node.parent = null
|
|
2874
3527
|
syncText(expectedParent)
|
|
2875
3528
|
}
|
|
2876
3529
|
|
|
2877
3530
|
function destroy(node: ElementNode) {
|
|
2878
|
-
|
|
2879
|
-
|
|
3531
|
+
// Nodes destroyed by a failed makeNode may have a null view — every
|
|
3532
|
+
// branch below must tolerate partial construction.
|
|
3533
|
+
if (node.type === 'webview' && node.view) {
|
|
3534
|
+
disposeWebView(node.view as WKWebView)
|
|
2880
3535
|
}
|
|
2881
3536
|
|
|
2882
|
-
if (node.type === '
|
|
2883
|
-
|
|
3537
|
+
if (node.type === 'image' && node.view) {
|
|
3538
|
+
disposeImage(node.view as NSImageView)
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
if (node.type === 'scrollview' && node.scrollObserverInstalled && node.view) {
|
|
3542
|
+
const clipView = node.view.contentView
|
|
2884
3543
|
scrollHandlers.delete(clipView)
|
|
2885
3544
|
NSNotificationCenter.defaultCenter.removeObserver(node.scrollObserver)
|
|
2886
3545
|
node.scrollObserver = null
|
|
@@ -2892,6 +3551,9 @@ function destroy(node: ElementNode) {
|
|
|
2892
3551
|
node.layoutObserver = null
|
|
2893
3552
|
}
|
|
2894
3553
|
|
|
3554
|
+
layoutHandlers.delete(node.layoutObservedView)
|
|
3555
|
+
node.layoutObservedView = null
|
|
3556
|
+
|
|
2895
3557
|
if (node.actionId !== undefined) {
|
|
2896
3558
|
actionHandlers.delete(node.actionId)
|
|
2897
3559
|
accessibilityLabels.delete(node.actionId)
|
|
@@ -2899,18 +3561,127 @@ function destroy(node: ElementNode) {
|
|
|
2899
3561
|
}
|
|
2900
3562
|
}
|
|
2901
3563
|
|
|
3564
|
+
/** Pre-flight a whole batch without mutating, so a deterministic failure —
|
|
3565
|
+
* bad props, unknown nodes, illegal parents, unsupported ops — rejects the
|
|
3566
|
+
* batch before any command applies. Residual mid-apply failures are then
|
|
3567
|
+
* limited to unexpected native errors, and each command unwinds itself. */
|
|
3568
|
+
function validateBatch(container: RootContainer, commands: readonly UniversalHostCommand[]) {
|
|
3569
|
+
// Ids created by this batch are not in container.nodes yet — track them so
|
|
3570
|
+
// later commands validate against the intended tree.
|
|
3571
|
+
const pending = new Map<number, { type: string; props: PropBag }>()
|
|
3572
|
+
const destroyed = new Set<number>()
|
|
3573
|
+
const known = (id: number): { type: string; props: PropBag } | undefined =>
|
|
3574
|
+
pending.get(id) ?? (destroyed.has(id) ? undefined : container.nodes.get(id))
|
|
3575
|
+
|
|
3576
|
+
for (const command of commands) {
|
|
3577
|
+
switch (command.op) {
|
|
3578
|
+
case 'create':
|
|
3579
|
+
assertSupportedType(command.type)
|
|
3580
|
+
validateNodeProps(command.type, command.props)
|
|
3581
|
+
pending.set(command.id, { type: command.type, props: command.props })
|
|
3582
|
+
destroyed.delete(command.id)
|
|
3583
|
+
continue
|
|
3584
|
+
case 'recreate': {
|
|
3585
|
+
const entry = known(command.id)
|
|
3586
|
+
if (!entry) {
|
|
3587
|
+
throw new Error('Unknown AppKit node ' + command.id)
|
|
3588
|
+
}
|
|
3589
|
+
|
|
3590
|
+
assertSupportedType(command.type)
|
|
3591
|
+
validateNodeProps(command.type, command.props)
|
|
3592
|
+
const existing = container.nodes.get(command.id)
|
|
3593
|
+
if (
|
|
3594
|
+
existing?.parent &&
|
|
3595
|
+
typeHasView(command.type) &&
|
|
3596
|
+
!VIEW_PARENT_TYPES.has(existing.parent.type)
|
|
3597
|
+
) {
|
|
3598
|
+
throw new Error('AppKit <' + existing.parent.type + '> cannot contain child views')
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
pending.set(command.id, { type: command.type, props: command.props })
|
|
3602
|
+
continue
|
|
3603
|
+
}
|
|
3604
|
+
case 'update': {
|
|
3605
|
+
const entry = known(command.id)
|
|
3606
|
+
if (!entry) {
|
|
3607
|
+
throw new Error('Unknown AppKit node ' + command.id)
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3610
|
+
validateNodeProps(entry.type, command.props, entry.props)
|
|
3611
|
+
continue
|
|
3612
|
+
}
|
|
3613
|
+
case 'insert':
|
|
3614
|
+
case 'move': {
|
|
3615
|
+
const entry = known(command.id)
|
|
3616
|
+
if (!entry) {
|
|
3617
|
+
throw new Error('Unknown AppKit node ' + command.id)
|
|
3618
|
+
}
|
|
3619
|
+
|
|
3620
|
+
const parentId = command.parent as number | null
|
|
3621
|
+
if (parentId !== null) {
|
|
3622
|
+
const parent = known(parentId)
|
|
3623
|
+
if (!parent) {
|
|
3624
|
+
throw new Error('Unknown AppKit parent ' + parentId)
|
|
3625
|
+
}
|
|
3626
|
+
|
|
3627
|
+
if (typeHasView(entry.type) && !VIEW_PARENT_TYPES.has(parent.type)) {
|
|
3628
|
+
throw new Error('AppKit <' + parent.type + '> cannot contain child views')
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
|
|
3632
|
+
continue
|
|
3633
|
+
}
|
|
3634
|
+
case 'destroy':
|
|
3635
|
+
pending.delete(command.id)
|
|
3636
|
+
destroyed.add(command.id)
|
|
3637
|
+
continue
|
|
3638
|
+
case 'remove':
|
|
3639
|
+
case 'visibility':
|
|
3640
|
+
case 'ensure-public-instance':
|
|
3641
|
+
continue
|
|
3642
|
+
default:
|
|
3643
|
+
throw new Error('AppKit spike does not support host command ' + command.op)
|
|
3644
|
+
}
|
|
3645
|
+
}
|
|
3646
|
+
}
|
|
3647
|
+
|
|
2902
3648
|
function applyCommand(container: RootContainer, command: UniversalHostCommand) {
|
|
2903
3649
|
switch (command.op) {
|
|
2904
|
-
case 'create':
|
|
3650
|
+
case 'create': {
|
|
3651
|
+
// A retried batch can re-create an id the earlier attempt already
|
|
3652
|
+
// registered — release the previous node instead of orphaning it.
|
|
3653
|
+
const existing = container.nodes.get(command.id)
|
|
3654
|
+
if (existing) {
|
|
3655
|
+
detach(container, existing)
|
|
3656
|
+
destroy(existing)
|
|
3657
|
+
}
|
|
3658
|
+
|
|
2905
3659
|
container.nodes.set(command.id, makeNode(container, command.id, command.type, command.props))
|
|
2906
3660
|
return
|
|
3661
|
+
}
|
|
2907
3662
|
case 'update': {
|
|
2908
3663
|
const node = container.nodes.get(command.id)
|
|
2909
3664
|
if (!node) {
|
|
2910
3665
|
throw new Error('Unknown AppKit node ' + command.id)
|
|
2911
3666
|
}
|
|
2912
3667
|
|
|
2913
|
-
|
|
3668
|
+
// node.props records what applied — restore it when a prop throws
|
|
3669
|
+
// mid-update so the bookkeeping doesn't claim unapplied props.
|
|
3670
|
+
const previousProps = node.props
|
|
3671
|
+
try {
|
|
3672
|
+
// Host updates carry the complete current prop bag. Clear omitted
|
|
3673
|
+
// label modes before merging so spread removals restore defaults.
|
|
3674
|
+
applyProps(
|
|
3675
|
+
node,
|
|
3676
|
+
node.type === 'label'
|
|
3677
|
+
? { maxLines: undefined, whiteSpace: undefined, textOverflow: undefined, ...command.props }
|
|
3678
|
+
: command.props,
|
|
3679
|
+
)
|
|
3680
|
+
} catch (error) {
|
|
3681
|
+
node.props = previousProps
|
|
3682
|
+
throw error
|
|
3683
|
+
}
|
|
3684
|
+
|
|
2914
3685
|
return
|
|
2915
3686
|
}
|
|
2916
3687
|
case 'insert':
|
|
@@ -2956,8 +3727,14 @@ function applyCommand(container: RootContainer, command: UniversalHostCommand) {
|
|
|
2956
3727
|
throw new Error('Unknown AppKit node ' + command.id)
|
|
2957
3728
|
}
|
|
2958
3729
|
|
|
2959
|
-
const replacement = makeNode(container, command.id, command.type, command.props)
|
|
2960
3730
|
const parent = node.parent
|
|
3731
|
+
if (parent && typeHasView(command.type) && !VIEW_PARENT_TYPES.has(parent.type)) {
|
|
3732
|
+
// insert would reject the replacement after the original was
|
|
3733
|
+
// already removed and destroyed — fail while it is intact.
|
|
3734
|
+
throw new Error('AppKit <' + parent.type + '> cannot contain child views')
|
|
3735
|
+
}
|
|
3736
|
+
|
|
3737
|
+
const replacement = makeNode(container, command.id, command.type, command.props)
|
|
2961
3738
|
const siblings = parent ? parent.children : container.children
|
|
2962
3739
|
const index = siblings.indexOf(node)
|
|
2963
3740
|
const beforeId = index < 0 ? null : (siblings[index + 1]?.id ?? null)
|
|
@@ -2976,26 +3753,41 @@ const macOSDriver: UniversalHostDriver<RootContainer, any> = {
|
|
|
2976
3753
|
id: 'macos',
|
|
2977
3754
|
capabilities: { text: 'host' },
|
|
2978
3755
|
prepareBatch(container, batch) {
|
|
3756
|
+
// Reject deterministic failures before any command mutates — a
|
|
3757
|
+
// partially applied batch cannot be rolled back once commit() marks
|
|
3758
|
+
// it accepted. Prepare acquires nothing, so abort() has no cleanup.
|
|
3759
|
+
validateBatch(container, batch.commands)
|
|
2979
3760
|
return {
|
|
2980
3761
|
apply() {
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
'
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
3762
|
+
container.layoutDirty = new Set()
|
|
3763
|
+
try {
|
|
3764
|
+
for (const command of batch.commands) {
|
|
3765
|
+
try {
|
|
3766
|
+
applyCommand(container, command)
|
|
3767
|
+
} catch (error) {
|
|
3768
|
+
// The universal root can swallow a mid-batch failure when it
|
|
3769
|
+
// retries or aborts the attempt — log it here so an
|
|
3770
|
+
// unsupported element cannot silently stall the mount.
|
|
3771
|
+
console.error(
|
|
3772
|
+
'[macos-host] command ' +
|
|
3773
|
+
command.op +
|
|
3774
|
+
' failed for <' +
|
|
3775
|
+
((command as { type?: string }).type ?? '?') +
|
|
3776
|
+
'> id=' +
|
|
3777
|
+
('id' in command ? command.id : '?'),
|
|
3778
|
+
error,
|
|
3779
|
+
)
|
|
3780
|
+
|
|
3781
|
+
throw error
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
} finally {
|
|
3785
|
+
const dirty = container.layoutDirty
|
|
3786
|
+
container.layoutDirty = null
|
|
3787
|
+
if (dirty) {
|
|
3788
|
+
for (const parent of dirty) {
|
|
3789
|
+
reconcileLayoutParent(parent)
|
|
3790
|
+
}
|
|
2999
3791
|
}
|
|
3000
3792
|
}
|
|
3001
3793
|
},
|
|
@@ -3749,7 +4541,7 @@ function attachContextMenu(
|
|
|
3749
4541
|
|
|
3750
4542
|
const menuItem = NSMenuItem.alloc().initWithTitleActionKeyEquivalent(
|
|
3751
4543
|
String(item?.title ?? ''),
|
|
3752
|
-
'menuItemSelected',
|
|
4544
|
+
'menuItemSelected:',
|
|
3753
4545
|
'',
|
|
3754
4546
|
)
|
|
3755
4547
|
|
|
@@ -3882,7 +4674,7 @@ function attachDatePicker(view: NSView, options: PropBag, onChange: (event: any)
|
|
|
3882
4674
|
const actionId = nextActionId++
|
|
3883
4675
|
picker.tag = actionId
|
|
3884
4676
|
picker.target = buttonActionTarget
|
|
3885
|
-
picker.action = 'controlChanged'
|
|
4677
|
+
picker.action = 'controlChanged:'
|
|
3886
4678
|
actionHandlers.set(actionId, () => {
|
|
3887
4679
|
const date = picker.dateValue
|
|
3888
4680
|
const millis =
|
|
@@ -4088,10 +4880,11 @@ function presentSheet(view: NSView, options: PropBag) {
|
|
|
4088
4880
|
}
|
|
4089
4881
|
|
|
4090
4882
|
const appKitBridge = (globalThis.__xplatAppKit ??= {})
|
|
4883
|
+
installAppearanceBridge(appKitBridge)
|
|
4091
4884
|
appKitBridge.observeHover = observeHover
|
|
4092
4885
|
appKitBridge.showAnchoredPopup = showAnchoredPopup
|
|
4093
4886
|
appKitBridge.showLayer = (options: PropBag) =>
|
|
4094
|
-
showWindowLayer(options, {
|
|
4887
|
+
showWindowLayer(options as WindowLayerOptions, {
|
|
4095
4888
|
createRoot: (view: NSView, anchor: NSView) =>
|
|
4096
4889
|
createMacOSRoot(view, { fontFamily: fontFamilyForView(anchor) }),
|
|
4097
4890
|
fittingSize: popupFittingSize,
|
|
@@ -4105,7 +4898,7 @@ installPresentationBridge(appKitBridge, createMacOSRoot, fontFamilyForView)
|
|
|
4105
4898
|
const rootFontFamilies = new WeakMap<object, string | undefined>()
|
|
4106
4899
|
|
|
4107
4900
|
function fontFamilyForView(view: NSView | undefined): string | undefined {
|
|
4108
|
-
for (let current = view; current; current = current.superview) {
|
|
4901
|
+
for (let current: NSView | null | undefined = view; current; current = current.superview) {
|
|
4109
4902
|
if (rootFontFamilies.has(current)) {
|
|
4110
4903
|
return rootFontFamilies.get(current)
|
|
4111
4904
|
}
|
|
@@ -4127,6 +4920,7 @@ export function createMacOSRoot(hostView: NSView, { fontFamily }: MacOSRootOptio
|
|
|
4127
4920
|
nodes: new Map(),
|
|
4128
4921
|
children: [],
|
|
4129
4922
|
root: null,
|
|
4923
|
+
layoutDirty: null,
|
|
4130
4924
|
}
|
|
4131
4925
|
|
|
4132
4926
|
const root = createUniversalRoot(container, macOSDriver, {
|
|
@@ -4303,7 +5097,7 @@ export function createMacOSRoot(hostView: NSView, { fontFamily }: MacOSRootOptio
|
|
|
4303
5097
|
try {
|
|
4304
5098
|
// Row box relative to the clip's top edge, computed in doc
|
|
4305
5099
|
// coordinates so it stays docH-independent like `offset`.
|
|
4306
|
-
const inDoc = child.view.superview
|
|
5100
|
+
const inDoc = child.view.superview!.convertRectToView(child.view.frame, doc)
|
|
4307
5101
|
const h = Number(inDoc.size.height)
|
|
4308
5102
|
const y = Number(inDoc.origin.y)
|
|
4309
5103
|
const top = flipped ? y - originY : originY + clipH - y - h
|
|
@@ -4367,7 +5161,18 @@ export function createMacOSRoot(hostView: NSView, { fontFamily }: MacOSRootOptio
|
|
|
4367
5161
|
throw new Error('No AppKit node with id ' + id)
|
|
4368
5162
|
}
|
|
4369
5163
|
|
|
4370
|
-
|
|
5164
|
+
// Single-key writes merge into the node's style bag — applyStyle
|
|
5165
|
+
// diffs against the last applied bag, so a partial object here
|
|
5166
|
+
// would reset every other style.
|
|
5167
|
+
const style = { ...node.props?.style }
|
|
5168
|
+
if (value == null) {
|
|
5169
|
+
delete style[name]
|
|
5170
|
+
} else {
|
|
5171
|
+
style[name] = value
|
|
5172
|
+
}
|
|
5173
|
+
|
|
5174
|
+
node.props = { ...node.props, style }
|
|
5175
|
+
applyStyle(node, style)
|
|
4371
5176
|
container.hostView.layoutSubtreeIfNeeded?.()
|
|
4372
5177
|
return this.frameInWindow(id)
|
|
4373
5178
|
},
|
|
@@ -4588,7 +5393,7 @@ export function createMacOSRoot(hostView: NSView, { fontFamily }: MacOSRootOptio
|
|
|
4588
5393
|
buttonActionTarget.textDidChange({ object: node.view } as NSNotification)
|
|
4589
5394
|
} else {
|
|
4590
5395
|
node.view!.stringValue = String(value)
|
|
4591
|
-
buttonActionTarget.
|
|
5396
|
+
buttonActionTarget.controlTextDidChange({ object: node.view } as NSNotification)
|
|
4592
5397
|
}
|
|
4593
5398
|
},
|
|
4594
5399
|
pressAccessibilityLabel(label: string) {
|