basalt-appkit 0.0.0-rc5

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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/cli/runMacos.js +33 -0
  3. package/cli/shared.js +75 -0
  4. package/native/CMakeLists.txt +298 -0
  5. package/native/appkit/AppKitAnimationChoreographer.h +54 -0
  6. package/native/appkit/AppKitAnimationChoreographer.mm +125 -0
  7. package/native/appkit/AppKitDropTarget.h +34 -0
  8. package/native/appkit/AppKitDropTarget.mm +292 -0
  9. package/native/appkit/AppKitFocus.h +120 -0
  10. package/native/appkit/AppKitFocus.mm +293 -0
  11. package/native/appkit/AppKitImageLoader.h +80 -0
  12. package/native/appkit/AppKitImageLoader.mm +156 -0
  13. package/native/appkit/AppKitKeyEvents.h +26 -0
  14. package/native/appkit/AppKitKeyEvents.mm +97 -0
  15. package/native/appkit/AppKitMenuBar.mm +424 -0
  16. package/native/appkit/AppKitMountingManager.h +135 -0
  17. package/native/appkit/AppKitMountingManager.mm +946 -0
  18. package/native/appkit/AppKitRunLoopObserver.h +34 -0
  19. package/native/appkit/AppKitRunLoopObserver.mm +42 -0
  20. package/native/appkit/AppKitScrollView.h +168 -0
  21. package/native/appkit/AppKitScrollView.mm +550 -0
  22. package/native/appkit/AppKitSkiaContext.h +33 -0
  23. package/native/appkit/AppKitSkiaContext.mm +255 -0
  24. package/native/appkit/AppKitSkiaModule.h +53 -0
  25. package/native/appkit/AppKitSkiaModule.mm +81 -0
  26. package/native/appkit/AppKitSkiaPeer.h +37 -0
  27. package/native/appkit/AppKitSkiaPeer.mm +123 -0
  28. package/native/appkit/AppKitSkiaView.h +62 -0
  29. package/native/appkit/AppKitSnapshot.h +21 -0
  30. package/native/appkit/AppKitSnapshot.mm +91 -0
  31. package/native/appkit/AppKitTextInput.h +140 -0
  32. package/native/appkit/AppKitTextInput.mm +588 -0
  33. package/native/appkit/AppKitTextPeer.h +74 -0
  34. package/native/appkit/AppKitTextPeer.mm +353 -0
  35. package/native/appkit/AppKitTitleBar.h +90 -0
  36. package/native/appkit/AppKitTitleBar.mm +186 -0
  37. package/native/appkit/AppKitTouchDispatcher.h +138 -0
  38. package/native/appkit/AppKitTouchDispatcher.mm +514 -0
  39. package/native/appkit/AppKitWindowControl.mm +222 -0
  40. package/native/appkit/AppKitWindowModule.h +62 -0
  41. package/native/appkit/AppKitWindowModule.mm +194 -0
  42. package/native/appkit/ColorSchemeAppKit.mm +71 -0
  43. package/native/appkit/ComponentRegistryAppKit.mm +121 -0
  44. package/native/appkit/CoreTextLayout.h +43 -0
  45. package/native/appkit/CoreTextLayout.mm +252 -0
  46. package/native/appkit/CoreTextLayoutManager.mm +138 -0
  47. package/native/appkit/FontRegistryCoreText.mm +148 -0
  48. package/native/appkit/PlatformServicesAppKit.mm +540 -0
  49. package/native/appkit/RnAppKitView.h +417 -0
  50. package/native/appkit/RnAppKitView.mm +1605 -0
  51. package/native/appkit/RnTextLayout.h +38 -0
  52. package/native/appkit/RnTextLayout.mm +168 -0
  53. package/native/appkit/demo_layout_appkit.mm +93 -0
  54. package/native/appkit/main_appkit.mm +1860 -0
  55. package/native/appkit/mount_harness_appkit.mm +197 -0
  56. package/native/tests/test_appkit_accessibility.mm +170 -0
  57. package/native/tests/test_appkit_image.mm +237 -0
  58. package/native/tests/test_appkit_input.mm +852 -0
  59. package/native/tests/test_appkit_key_events.mm +150 -0
  60. package/native/tests/test_appkit_mounting.mm +430 -0
  61. package/native/tests/test_appkit_scrollbar.mm +132 -0
  62. package/native/tests/test_appkit_services.mm +57 -0
  63. package/native/tests/test_appkit_skia.mm +212 -0
  64. package/native/tests/test_appkit_text.mm +180 -0
  65. package/native/tests/test_appkit_textinput.mm +748 -0
  66. package/native/tests/test_appkit_titlebar.mm +107 -0
  67. package/native/tests/test_appkit_view.mm +237 -0
  68. package/package.json +26 -0
  69. package/react-native.config.js +22 -0
@@ -0,0 +1,1605 @@
1
+ #import "RnAppKitView.h"
2
+
3
+ #include "ControlMetrics.h"
4
+ #include "Backface.h"
5
+ #include "FocusRing.h"
6
+ #include "ScrollIndicator.h"
7
+
8
+ #include <vector>
9
+
10
+ #import "AppKitTextPeer.h"
11
+
12
+ #include "PointerButtons.h"
13
+
14
+ #include <cmath>
15
+
16
+ #import "RnTextLayout.h"
17
+
18
+ #import <QuartzCore/QuartzCore.h>
19
+
20
+ // Topmost first: AppKit's subviews array is back to front, and a hit test wants
21
+ // the view that is drawn on top of the others.
22
+ //
23
+ // A child is only entered when the point is inside its frame, which means a
24
+ // child that overflows a parent -- React Native's default, since `overflow` is
25
+ // `visible` -- is not reachable through that parent. In practice Fabric's view
26
+ // flattening hoists most such children out to an ancestor that does contain
27
+ // them, so this matters less than it reads; the GTK side has the same limit for
28
+ // the same reason.
29
+ // The spelling React Native uses for the prop, which is also CSS's, so the
30
+ // three hosts' dumps say the same words.
31
+ static const char *RnAppKitPointerEventsName(RnAppKitPointerEvents mode) {
32
+ switch (mode) {
33
+ case RnAppKitPointerEventsNone:
34
+ return "none";
35
+ case RnAppKitPointerEventsBoxNone:
36
+ return "box-none";
37
+ case RnAppKitPointerEventsBoxOnly:
38
+ return "box-only";
39
+ case RnAppKitPointerEventsAuto:
40
+ break;
41
+ }
42
+ return "auto";
43
+ }
44
+
45
+ RnAppKitView *RnAppKitHitTest(RnAppKitView *root, CGFloat x, CGFloat y) {
46
+ // `pointerEvents: none` takes the view and everything inside it out of hit
47
+ // testing entirely, so the caller's loop carries on to whatever is behind.
48
+ if (root == nil || root.hidden || root.rnPointerEvents == RnAppKitPointerEventsNone) {
49
+ return nil;
50
+ }
51
+
52
+ // The point arrives relative to this view's visible top-left; child frames
53
+ // are in its *bounds* space. For an unscrolled view those are the same, and
54
+ // for a scrolled one they differ by exactly the offset -- which is what makes
55
+ // hit testing follow the scroll without anything here knowing about
56
+ // ScrollViews.
57
+ const NSRect bounds = root.bounds;
58
+ const CGFloat bx = x + bounds.origin.x;
59
+ const CGFloat by = y + bounds.origin.y;
60
+
61
+ if (!NSPointInRect(NSMakePoint(bx, by), bounds)) {
62
+ return nil;
63
+ }
64
+
65
+ // Back to front, so reversed is topmost first -- and "topmost" has to mean
66
+ // what was painted last, which zIndex can change. Win32's hitTest walks its
67
+ // own childrenInPaintOrder the same way round.
68
+ //
69
+ // `box-only` is the one mode that skips this: the box is the target and
70
+ // nothing inside it is, which is what makes an overlay swallow a press meant
71
+ // for a button drawn on top of it.
72
+ if (root.rnPointerEvents != RnAppKitPointerEventsBoxOnly) {
73
+ for (RnAppKitView *child in [root rnChildrenInPaintOrder].reverseObjectEnumerator) {
74
+ // Into the child's own space by inverting what places it in this one.
75
+ // Subtracting the frame origin would be the same thing for an
76
+ // untransformed view and wrong for every other: a rotated view was
77
+ // clickable where it would have been rather than where it is drawn.
78
+ const CGAffineTransform toParent = [child rnLocalToParent];
79
+
80
+ // A transform can be degenerate -- `scale: 0` is legal and draws
81
+ // nothing. There is no point inside something with no area, so it is
82
+ // skipped rather than inverted.
83
+ const CGFloat determinant = toParent.a * toParent.d - toParent.b * toParent.c;
84
+ if (determinant == 0.0) {
85
+ continue;
86
+ }
87
+
88
+ const CGPoint local =
89
+ CGPointApplyAffineTransform(NSMakePoint(bx, by), CGAffineTransformInvert(toParent));
90
+ RnAppKitView *hit = RnAppKitHitTest(child, local.x, local.y);
91
+ if (hit != nil) {
92
+ return hit;
93
+ }
94
+ }
95
+ }
96
+
97
+ // `box-none` is transparent to a press that misses everything inside it.
98
+ // Returning nil rather than the parent is the whole of it: the caller is
99
+ // partway through its own list of children, so the press carries on to the
100
+ // sibling *behind* this view -- which is what the absolutely-positioned
101
+ // overlay this mode exists for is asking for.
102
+ if (root.rnPointerEvents == RnAppKitPointerEventsBoxNone) {
103
+ return nil;
104
+ }
105
+ return root;
106
+ }
107
+
108
+ // A rounded rectangle with a different radius pair at each corner.
109
+ //
110
+ // CGPathAddArcToPoint only draws circular arcs, and React Native's radii are
111
+ // elliptical, so the corners are cubic Beziers. kappa is the usual constant
112
+ // for approximating a quarter ellipse with one curve -- the error is under a
113
+ // thousandth of the radius, which is far below a pixel at any radius an
114
+ // interface uses.
115
+ static CGPathRef RnAppKitCreateRoundedPath(CGRect rect, const CGFloat radii[8]) {
116
+ static const CGFloat kappa = (CGFloat)0.5522847498307936;
117
+ const CGFloat x = CGRectGetMinX(rect);
118
+ const CGFloat y = CGRectGetMinY(rect);
119
+ const CGFloat w = CGRectGetWidth(rect);
120
+ const CGFloat h = CGRectGetHeight(rect);
121
+
122
+ // Clamp so that two radii along one edge can never exceed it. The values
123
+ // arriving from Fabric are already clamped, but this path is also built for
124
+ // the *inner* edge of a border, whose radii are reduced by the border widths
125
+ // and can be anything.
126
+ CGFloat r[8];
127
+ for (int i = 0; i < 8; i++) {
128
+ r[i] = radii[i] > 0 ? radii[i] : 0;
129
+ }
130
+ const CGFloat tlw = r[0], tlh = r[1], trw = r[2], trh = r[3];
131
+ const CGFloat brw = r[4], brh = r[5], blw = r[6], blh = r[7];
132
+
133
+ CGMutablePathRef path = CGPathCreateMutable();
134
+ CGPathMoveToPoint(path, NULL, x + tlw, y);
135
+ CGPathAddLineToPoint(path, NULL, x + w - trw, y);
136
+ CGPathAddCurveToPoint(path, NULL,
137
+ x + w - trw + trw * kappa, y,
138
+ x + w, y + trh - trh * kappa,
139
+ x + w, y + trh);
140
+ CGPathAddLineToPoint(path, NULL, x + w, y + h - brh);
141
+ CGPathAddCurveToPoint(path, NULL,
142
+ x + w, y + h - brh + brh * kappa,
143
+ x + w - brw + brw * kappa, y + h,
144
+ x + w - brw, y + h);
145
+ CGPathAddLineToPoint(path, NULL, x + blw, y + h);
146
+ CGPathAddCurveToPoint(path, NULL,
147
+ x + blw - blw * kappa, y + h,
148
+ x, y + h - blh + blh * kappa,
149
+ x, y + h - blh);
150
+ CGPathAddLineToPoint(path, NULL, x, y + tlh);
151
+ CGPathAddCurveToPoint(path, NULL,
152
+ x, y + tlh - tlh * kappa,
153
+ x + tlw - tlw * kappa, y,
154
+ x + tlw, y);
155
+ CGPathCloseSubpath(path);
156
+ return path;
157
+ }
158
+
159
+ // Clips to the side of the line through `p` with direction `dir` that contains
160
+ // `inside`. Two of these give an edge the sector it owns at a corner.
161
+ //
162
+ // A half-plane rather than a polygon because a sector has no far end: the
163
+ // border band at a rounded corner runs as far out as the radius takes it, and
164
+ // any bounded shape has to guess how far that is.
165
+ static void RnAppKitClipToHalfPlane(CGContextRef context,
166
+ CGPoint p,
167
+ CGPoint dir,
168
+ CGPoint inside,
169
+ CGFloat extent) {
170
+ const CGFloat length = std::hypot(dir.x, dir.y);
171
+ if (length < (CGFloat)1e-6) {
172
+ // Both borders meeting here are zero wide, so there is nothing to divide.
173
+ return;
174
+ }
175
+ const CGPoint along = {dir.x / length, dir.y / length};
176
+ const CGPoint normal = {-along.y, along.x};
177
+ const CGFloat side =
178
+ (inside.x - p.x) * normal.x + (inside.y - p.y) * normal.y;
179
+ const CGFloat sign = side < 0 ? -1 : 1;
180
+
181
+ const CGPoint a = {p.x + along.x * extent, p.y + along.y * extent};
182
+ const CGPoint b = {p.x - along.x * extent, p.y - along.y * extent};
183
+ const CGPoint c = {b.x + normal.x * sign * extent, b.y + normal.y * sign * extent};
184
+ const CGPoint d = {a.x + normal.x * sign * extent, a.y + normal.y * sign * extent};
185
+
186
+ CGContextBeginPath(context);
187
+ CGContextMoveToPoint(context, a.x, a.y);
188
+ CGContextAddLineToPoint(context, b.x, b.y);
189
+ CGContextAddLineToPoint(context, c.x, c.y);
190
+ CGContextAddLineToPoint(context, d.x, d.y);
191
+ CGContextClosePath(context);
192
+ CGContextClip(context);
193
+ }
194
+
195
+ // The overlay scrollbars, as a view of their own.
196
+ //
197
+ // AppKit paints subviews over their superview, and a ScrollView always has one
198
+ // -- its content -- covering the whole of it, so a thumb drawn in the scroll
199
+ // view's own `drawRect:` would be behind everything it is meant to float over.
200
+ // This sits above them all instead. See core/ScrollIndicator.h for the
201
+ // geometry, which all three hosts share.
202
+ @interface RnAppKitScrollIndicatorView : NSView
203
+ @property(nonatomic) CGFloat rnVerticalOffset;
204
+ @property(nonatomic) CGFloat rnVerticalLength;
205
+ @property(nonatomic) CGFloat rnHorizontalOffset;
206
+ @property(nonatomic) CGFloat rnHorizontalLength;
207
+ @end
208
+
209
+ @implementation RnAppKitScrollIndicatorView
210
+
211
+ // Top-left origin, like every other view here and like React Native.
212
+ - (BOOL)isFlipped {
213
+ return YES;
214
+ }
215
+
216
+ // Paint and nothing else: a press falls through to the content underneath.
217
+ // Nothing here is a drag target yet -- docs/backlog records that.
218
+ - (NSView *)hitTest:(NSPoint)point {
219
+ (void)point;
220
+ return nil;
221
+ }
222
+
223
+ - (void)rnFillPill:(NSRect)bar inContext:(CGContextRef)context {
224
+ const CGFloat radius = basalt::kScrollIndicatorThickness / 2.0;
225
+ CGPathRef path = CGPathCreateWithRoundedRect(bar, radius, radius, NULL);
226
+ CGContextAddPath(context, path);
227
+ CGContextFillPath(context);
228
+ CGPathRelease(path);
229
+ }
230
+
231
+ - (void)drawRect:(NSRect)dirtyRect {
232
+ (void)dirtyRect;
233
+ if (_rnVerticalLength <= 0.0 && _rnHorizontalLength <= 0.0) {
234
+ return;
235
+ }
236
+ CGContextRef context = [NSGraphicsContext currentContext].CGContext;
237
+ const NSSize size = self.bounds.size;
238
+ const CGFloat thickness = basalt::kScrollIndicatorThickness;
239
+ const CGFloat inset = basalt::kScrollIndicatorInset;
240
+ // Neutral and translucent, so it reads over light and dark content alike --
241
+ // the same colour the GTK and Win32 hosts use.
242
+ CGContextSetRGBFillColor(context, 0.0, 0.0, 0.0, 0.35);
243
+
244
+ if (_rnVerticalLength > 0.0) {
245
+ [self rnFillPill:NSMakeRect(size.width - thickness - inset,
246
+ _rnVerticalOffset,
247
+ thickness,
248
+ _rnVerticalLength)
249
+ inContext:context];
250
+ }
251
+ if (_rnHorizontalLength > 0.0) {
252
+ [self rnFillPill:NSMakeRect(_rnHorizontalOffset,
253
+ size.height - thickness - inset,
254
+ _rnHorizontalLength,
255
+ thickness)
256
+ inContext:context];
257
+ }
258
+ }
259
+
260
+ @end
261
+
262
+ @implementation RnAppKitView {
263
+ // React DevTools' overlay rectangles: eight floats each, plus a fill flag.
264
+ // See setRnHighlights:filled:count:.
265
+ std::vector<float> _highlights;
266
+ std::vector<bool> _highlightFilled;
267
+ NSString *_roleName;
268
+ RnTextLayout *_textLayout;
269
+ CGImageRef _image;
270
+ NSColor *_imageTint;
271
+ RnAppKitImageFit _imageFit;
272
+ BOOL _hasBackgroundColor;
273
+ CGFloat _backgroundComponents[4];
274
+ CGFloat _opacity;
275
+ BOOL _clipsChildren;
276
+ CGFloat _cornerRadius;
277
+ // Eight floats: a horizontal and a vertical radius per corner, in the order
278
+ // top-left, top-right, bottom-right, bottom-left. React Native clamps
279
+ // opposite corners against the frame before these arrive, so nothing here
280
+ // has to.
281
+ CGFloat _borderRadii[8];
282
+ BOOL _hasBorderRadii;
283
+ // top, right, bottom, left -- the order CSS names them.
284
+ CGFloat _borderWidths[4];
285
+ // Four RGBA quadruples, in the same edge order.
286
+ CGFloat _borderColors[16];
287
+ BOOL _hasBorders;
288
+ CATransform3D _transform;
289
+ BOOL _hasTransform;
290
+ BOOL _hidesBackFace;
291
+ BOOL _hiddenByApp;
292
+ BOOL _hiddenByBackFace;
293
+ NSInteger _zIndex;
294
+ // Only the surface root has one; see -updateTrackingAreas.
295
+ NSTrackingArea *_rnHoverTrackingArea;
296
+ // Only a ScrollView with something to indicate has one.
297
+ RnAppKitScrollIndicatorView *_indicators;
298
+ }
299
+
300
+ + (instancetype)viewWithTag:(NSInteger)tag {
301
+ RnAppKitView *view = [[RnAppKitView alloc] initWithFrame:NSZeroRect];
302
+ view->_rnTag = tag;
303
+ return view;
304
+ }
305
+
306
+ - (void)dealloc {
307
+ CGImageRelease(_image);
308
+ }
309
+
310
+ - (instancetype)initWithFrame:(NSRect)frame {
311
+ self = [super initWithFrame:frame];
312
+ if (self != nil) {
313
+ _opacity = 1.0;
314
+ _imageFit = RnAppKitImageFitCover;
315
+ _transform = CATransform3DIdentity;
316
+ // Layer-backed from the start rather than on demand: a view that acquires a
317
+ // layer later loses whatever was set on it before, and the props arrive in
318
+ // whatever order the mutation stream happens to carry them.
319
+ self.wantsLayer = YES;
320
+ // Frames come from Fabric already resolved. Autoresizing would fight them.
321
+ self.autoresizingMask = NSViewNotSizable;
322
+ self.translatesAutoresizingMaskIntoConstraints = YES;
323
+ }
324
+ return self;
325
+ }
326
+
327
+ // See the note in the header: React Native's coordinates are top-left.
328
+ - (BOOL)isFlipped {
329
+ return YES;
330
+ }
331
+
332
+ - (void)setRnFrameX:(CGFloat)x y:(CGFloat)y width:(CGFloat)width height:(CGFloat)height {
333
+ self.frame = NSMakeRect(x, y, width, height);
334
+ // A mask layer is built against the bounds, so a resize invalidates it. The
335
+ // uniform case is a plain cornerRadius and needs nothing.
336
+ if (self.layer.mask != nil) {
337
+ [self rnUpdateRadiusMask];
338
+ }
339
+ // Borders are drawn along the bounds too.
340
+ if (_hasBorders) {
341
+ self.needsDisplay = YES;
342
+ }
343
+ }
344
+
345
+ - (void)setRnBackgroundColorRed:(CGFloat)red
346
+ green:(CGFloat)green
347
+ blue:(CGFloat)blue
348
+ alpha:(CGFloat)alpha
349
+ hasColor:(BOOL)hasColor {
350
+ _hasBackgroundColor = hasColor;
351
+ _backgroundComponents[0] = red;
352
+ _backgroundComponents[1] = green;
353
+ _backgroundComponents[2] = blue;
354
+ _backgroundComponents[3] = alpha;
355
+
356
+ if (!hasColor) {
357
+ self.layer.backgroundColor = nil;
358
+ return;
359
+ }
360
+ // sRGB explicitly. React Native's colour components are sRGB, and letting
361
+ // CoreGraphics pick a device space shifts every colour slightly on a
362
+ // wide-gamut display -- visible next to the same app on another platform,
363
+ // which is exactly what this project is trying not to be.
364
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
365
+ const CGFloat components[4] = {red, green, blue, alpha};
366
+ CGColorRef color = CGColorCreate(space, components);
367
+ self.layer.backgroundColor = color;
368
+ CGColorRelease(color);
369
+ CGColorSpaceRelease(space);
370
+ }
371
+
372
+ // --- Accessibility ---------------------------------------------------------
373
+
374
+ // React Native's role vocabulary is mostly ARIA's; AppKit's is its own, older
375
+ // and smaller. Several entries below are the closest thing rather than an
376
+ // equivalent, and each says so -- an approximate role is better than none, and
377
+ // much better than a wrong one, which makes a control announce itself as
378
+ // something it is not.
379
+ static NSAccessibilityRole RnAccessibilityRoleFor(NSString *name) {
380
+ static NSDictionary<NSString *, NSAccessibilityRole> *roles = nil;
381
+ static dispatch_once_t once;
382
+ dispatch_once(&once, ^{
383
+ roles = @{
384
+ @"button" : NSAccessibilityButtonRole,
385
+ @"imagebutton" : NSAccessibilityButtonRole,
386
+ @"link" : NSAccessibilityLinkRole,
387
+ // A search field is a text field with a subrole on macOS, not a role of
388
+ // its own. Reporting the role loses the "this searches" part, which is
389
+ // what NSAccessibilitySearchFieldSubrole would carry -- a subrole needs a
390
+ // second property, and this does not set one yet.
391
+ @"search" : NSAccessibilityTextFieldRole,
392
+ @"image" : NSAccessibilityImageRole,
393
+ @"text" : NSAccessibilityStaticTextRole,
394
+ @"adjustable" : NSAccessibilitySliderRole,
395
+ @"checkbox" : NSAccessibilityCheckBoxRole,
396
+ @"combobox" : NSAccessibilityComboBoxRole,
397
+ @"menu" : NSAccessibilityMenuRole,
398
+ @"menubar" : NSAccessibilityMenuBarRole,
399
+ @"menuitem" : NSAccessibilityMenuItemRole,
400
+ @"progressbar" : NSAccessibilityProgressIndicatorRole,
401
+ @"radio" : NSAccessibilityRadioButtonRole,
402
+ @"radiogroup" : NSAccessibilityRadioGroupRole,
403
+ @"scrollbar" : NSAccessibilityScrollBarRole,
404
+ @"spinbutton" : NSAccessibilityIncrementorRole,
405
+ @"list" : NSAccessibilityListRole,
406
+ @"grid" : NSAccessibilityTableRole,
407
+ @"toolbar" : NSAccessibilityToolbarRole,
408
+ @"tooltip" : NSAccessibilityHelpTagRole,
409
+ // macOS has no toggle-button or switch role: VoiceOver announces both as
410
+ // checkboxes, which is also what AppKit's own NSSwitch reports.
411
+ @"togglebutton" : NSAccessibilityCheckBoxRole,
412
+ @"switch" : NSAccessibilityCheckBoxRole,
413
+ // A tab in an AX tab group is a radio button, which reads oddly and is
414
+ // what every Mac application does.
415
+ @"tab" : NSAccessibilityRadioButtonRole,
416
+ @"tablist" : NSAccessibilityTabGroupRole,
417
+ // No standalone header or alert role for a view. A group at least says
418
+ // "these belong together", which a static text would not.
419
+ @"header" : NSAccessibilityGroupRole,
420
+ @"alert" : NSAccessibilityGroupRole,
421
+ // Explicitly nothing: `none` and `presentation` mean "do not announce
422
+ // this", which is handled by isAccessibilityElement below.
423
+ @"none" : NSAccessibilityUnknownRole,
424
+ @"presentation" : NSAccessibilityUnknownRole,
425
+ };
426
+ });
427
+ NSAccessibilityRole role = roles[name];
428
+ // Anything unrecognised is a group rather than a guess: a wrong role is worse
429
+ // for a screen reader than a vague one, because it makes the view announce
430
+ // itself as something it is not.
431
+ return role != nil ? role : NSAccessibilityGroupRole;
432
+ }
433
+
434
+ - (void)setRnAccessibleRole:(NSString *)role {
435
+ _roleName = role.length > 0 ? [role copy] : nil;
436
+
437
+ if (_roleName == nil) {
438
+ self.accessibilityRole = NSAccessibilityGroupRole;
439
+ // A plain <View> is scenery. Leaving every one of them in the tree would
440
+ // bury the handful that mean something under hundreds that do not.
441
+ self.accessibilityElement = NO;
442
+ return;
443
+ }
444
+
445
+ const NSAccessibilityRole mapped = RnAccessibilityRoleFor(_roleName);
446
+ self.accessibilityRole = mapped;
447
+ self.accessibilityElement = mapped != NSAccessibilityUnknownRole;
448
+ }
449
+
450
+ - (void)setRnAccessibleLabel:(NSString *)label hint:(NSString *)hint {
451
+ self.accessibilityLabel = label.length > 0 ? label : nil;
452
+ // `accessibilityHelp`, not `accessibilityValue`: React Native's hint is the
453
+ // supplementary description, which on a Mac is what a help tag carries.
454
+ self.accessibilityHelp = hint.length > 0 ? hint : nil;
455
+
456
+ // A label makes a view worth announcing even when it has no role -- which is
457
+ // the common case for an icon-only <Pressable> with an accessibilityLabel.
458
+ if (label.length > 0) {
459
+ self.accessibilityElement = YES;
460
+ }
461
+ }
462
+
463
+ - (void)setRnAccessibleStateDisabled:(RnAppKitAccessibleFlag)disabled
464
+ checked:(RnAppKitAccessibleFlag)checked
465
+ selected:(RnAppKitAccessibleFlag)selected
466
+ expanded:(RnAppKitAccessibleFlag)expanded
467
+ busy:(RnAppKitAccessibleFlag)busy {
468
+ if (disabled != RnAppKitAccessibleUnset) {
469
+ self.accessibilityEnabled = disabled != RnAppKitAccessibleTrue;
470
+ }
471
+ if (checked != RnAppKitAccessibleUnset) {
472
+ // AppKit expresses checked as the element's value, the way a checkbox does,
473
+ // rather than as a state of its own.
474
+ self.accessibilityValue = @(checked == RnAppKitAccessibleTrue);
475
+ }
476
+ if (selected != RnAppKitAccessibleUnset) {
477
+ self.accessibilitySelected = selected == RnAppKitAccessibleTrue;
478
+ }
479
+ if (expanded != RnAppKitAccessibleUnset) {
480
+ self.accessibilityExpanded = expanded == RnAppKitAccessibleTrue;
481
+ }
482
+ // `busy` has no AppKit equivalent. React Native's meaning is "this is
483
+ // loading", which VoiceOver has no way to be told about a plain view; the
484
+ // nearest thing is an NSAccessibilityProgressIndicator, which would be a
485
+ // different role rather than a state. Left unreported rather than mapped onto
486
+ // something that means something else.
487
+ (void)busy;
488
+ }
489
+
490
+ - (void)setRnAccessibleHidden:(BOOL)hidden {
491
+ if (hidden) {
492
+ self.accessibilityElement = NO;
493
+ }
494
+ // Not `else { YES }`: whether an unhidden view is an element is decided by
495
+ // its role and label above, and overriding that here would put every plain
496
+ // <View> back into the tree.
497
+ }
498
+
499
+ - (void)setRnScrollOffsetX:(CGFloat)x y:(CGFloat)y {
500
+ const NSRect bounds = self.bounds;
501
+ if (bounds.origin.x == x && bounds.origin.y == y) {
502
+ return;
503
+ }
504
+ [self setBoundsOrigin:NSMakePoint(x, y)];
505
+ // setBoundsOrigin: moves subviews but does not repaint what this view draws
506
+ // itself, which matters the moment a ScrollView has a background or text.
507
+ self.needsDisplay = YES;
508
+ }
509
+
510
+ - (NSPoint)rnScrollOffset {
511
+ return self.bounds.origin;
512
+ }
513
+
514
+ - (void)setRnScrollIndicatorVerticalOffset:(CGFloat)verticalOffset
515
+ verticalLength:(CGFloat)verticalLength
516
+ horizontalOffset:(CGFloat)horizontalOffset
517
+ horizontalLength:(CGFloat)horizontalLength {
518
+ if (verticalLength <= 0.0 && horizontalLength <= 0.0) {
519
+ [_indicators removeFromSuperview];
520
+ _indicators = nil;
521
+ return;
522
+ }
523
+
524
+ if (_indicators == nil) {
525
+ _indicators = [[RnAppKitScrollIndicatorView alloc] initWithFrame:self.bounds];
526
+ [self addSubview:_indicators positioned:NSWindowAbove relativeTo:nil];
527
+ }
528
+
529
+ // `bounds` rather than a rect at the origin: a scrolled view's bounds origin
530
+ // is the scroll offset, so this pins the overlay to the viewport instead of
531
+ // letting it travel with the content.
532
+ _indicators.frame = self.bounds;
533
+
534
+ if (_indicators.rnVerticalOffset == verticalOffset &&
535
+ _indicators.rnVerticalLength == verticalLength &&
536
+ _indicators.rnHorizontalOffset == horizontalOffset &&
537
+ _indicators.rnHorizontalLength == horizontalLength) {
538
+ return;
539
+ }
540
+ _indicators.rnVerticalOffset = verticalOffset;
541
+ _indicators.rnVerticalLength = verticalLength;
542
+ _indicators.rnHorizontalOffset = horizontalOffset;
543
+ _indicators.rnHorizontalLength = horizontalLength;
544
+ _indicators.needsDisplay = YES;
545
+ }
546
+
547
+ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
548
+ switch (fit) {
549
+ case RnAppKitImageFitContain:
550
+ return "contain";
551
+ case RnAppKitImageFitStretch:
552
+ return "stretch";
553
+ case RnAppKitImageFitCenter:
554
+ return "center";
555
+ case RnAppKitImageFitRepeat:
556
+ return "repeat";
557
+ case RnAppKitImageFitCover:
558
+ break;
559
+ }
560
+ return "cover";
561
+ }
562
+
563
+ - (void)setRnImageTint:(NSColor *)tint {
564
+ if (_imageTint == tint || [_imageTint isEqual:tint]) {
565
+ return;
566
+ }
567
+ _imageTint = tint;
568
+ [self setNeedsDisplay:YES];
569
+ }
570
+
571
+ - (void)setRnImage:(CGImageRef)image fit:(RnAppKitImageFit)fit {
572
+ if (_image == image && _imageFit == fit) {
573
+ return;
574
+ }
575
+ // Retained, not borrowed: the loader's cache owns one reference and this owns
576
+ // another, so a view outliving an eviction still has pixels to draw.
577
+ CGImageRef previous = _image;
578
+ _image = image != nullptr ? CGImageRetain(image) : nullptr;
579
+ CGImageRelease(previous);
580
+ _imageFit = fit;
581
+ self.needsDisplay = YES;
582
+ }
583
+
584
+ // Where the image lands inside the frame.
585
+ //
586
+ // The same arithmetic as the GTK side's, deliberately -- two platforms
587
+ // disagreeing about what `resizeMode: 'contain'` means would be the sort of
588
+ // difference nobody thinks to check. Duplicated rather than shared because the
589
+ // view layers link no common code at all, which is what keeps them testable
590
+ // without React Native.
591
+ - (NSRect)rnImageRectForSize:(NSSize)size {
592
+ const CGFloat imageWidth = (CGFloat)CGImageGetWidth(_image);
593
+ const CGFloat imageHeight = (CGFloat)CGImageGetHeight(_image);
594
+ if (_imageFit == RnAppKitImageFitStretch || imageWidth <= 0 || imageHeight <= 0) {
595
+ return NSMakeRect(0, 0, size.width, size.height);
596
+ }
597
+
598
+ CGFloat scale = 1.0;
599
+ switch (_imageFit) {
600
+ case RnAppKitImageFitContain:
601
+ scale = MIN(size.width / imageWidth, size.height / imageHeight);
602
+ break;
603
+ case RnAppKitImageFitCover:
604
+ scale = MAX(size.width / imageWidth, size.height / imageHeight);
605
+ break;
606
+ case RnAppKitImageFitCenter:
607
+ // Centre at natural size, but never larger than the frame -- which is
608
+ // what React Native's `center` does.
609
+ scale = MIN(1.0, MIN(size.width / imageWidth, size.height / imageHeight));
610
+ break;
611
+ case RnAppKitImageFitRepeat:
612
+ // One tile, at natural size. Where it is placed and how many there are
613
+ // is the draw's business, not this function's.
614
+ break;
615
+ case RnAppKitImageFitStretch:
616
+ break;
617
+ }
618
+
619
+ const CGFloat width = imageWidth * scale;
620
+ const CGFloat height = imageHeight * scale;
621
+ return NSMakeRect((size.width - width) / 2.0, (size.height - height) / 2.0, width, height);
622
+ }
623
+
624
+ - (void)setRnTextLayout:(id)layout {
625
+ _textLayout = (RnTextLayout *)layout;
626
+ // A layer-backed view with a drawRect: gets its contents from that draw, and
627
+ // only redraws when told. Without this the first paragraph appears and no
628
+ // later one ever does.
629
+ self.needsDisplay = YES;
630
+ }
631
+
632
+ // Only views with text draw anything; the rest are pure CALayer properties and
633
+ // this is never called for them.
634
+ //
635
+ // The background is *not* drawn here. It is a layer property, which means Core
636
+ // Animation paints it under this and a view can have both without either
637
+ // knowing about the other.
638
+ - (void)setRnHighlights:(const float *)rectangles
639
+ filled:(const bool *)filled
640
+ count:(NSInteger)count {
641
+ _highlights.clear();
642
+ _highlightFilled.clear();
643
+ if (rectangles != nullptr && count > 0) {
644
+ _highlights.assign(rectangles, rectangles + (count * 8));
645
+ _highlightFilled.assign(filled, filled + count);
646
+ }
647
+ self.needsDisplay = YES;
648
+ }
649
+
650
+ - (void)drawRect:(NSRect)dirtyRect {
651
+ (void)dirtyRect;
652
+ const BOOL ring = [self rnShowsFocusRing];
653
+ if (_textLayout == nil && _image == nullptr && !_hasBorders && !ring &&
654
+ _highlightFilled.empty()) {
655
+ return;
656
+ }
657
+ CGContextRef context = [NSGraphicsContext currentContext].CGContext;
658
+ const NSSize size = self.bounds.size;
659
+
660
+ if (_image != nullptr) {
661
+ const NSRect destination = [self rnImageRectForSize:size];
662
+
663
+ CGContextSaveGState(context);
664
+ // cover, center and repeat can all put pixels outside the frame, and an
665
+ // <Image> never paints beyond its own box on iOS or Android.
666
+ if (_imageFit != RnAppKitImageFitContain && _imageFit != RnAppKitImageFitStretch) {
667
+ CGContextClipToRect(context, NSMakeRect(0, 0, size.width, size.height));
668
+ }
669
+ // CGImage draws bottom-up and this view is flipped, so without this every
670
+ // photograph comes out upside down -- which reads as a broken decoder
671
+ // rather than a coordinate system.
672
+ CGContextTranslateCTM(context, 0, size.height);
673
+ CGContextScaleCTM(context, 1, -1);
674
+ const NSRect drawn = NSMakeRect(destination.origin.x,
675
+ size.height - destination.origin.y - destination.size.height,
676
+ destination.size.width,
677
+ destination.size.height);
678
+ if (_imageFit == RnAppKitImageFitRepeat) {
679
+ // Tiled from the top left at natural size. `CGContextDrawTiledImage`
680
+ // takes the *first* tile's rect and repeats it across the clip, which is
681
+ // why the clip above is what stops it painting the whole window.
682
+ //
683
+ // The first tile is at the top of the view, which in this flipped
684
+ // context is `size.height - tileHeight` -- so a box that is not a whole
685
+ // number of tiles high has its partial tile at the bottom, as CSS
686
+ // `repeat` does, rather than at the top.
687
+ const NSRect tile = NSMakeRect(0,
688
+ size.height - destination.size.height,
689
+ destination.size.width,
690
+ destination.size.height);
691
+ if (_imageTint != nil) {
692
+ CGContextClipToMask(context, tile, _image);
693
+ CGContextSetFillColorWithColor(context, _imageTint.CGColor);
694
+ CGContextFillRect(context, NSMakeRect(0, 0, size.width, size.height));
695
+ } else {
696
+ CGContextDrawTiledImage(context, tile, _image);
697
+ }
698
+ } else if (_imageTint != nil) {
699
+ // The image becomes a stencil and the colour is what is drawn. Clipping
700
+ // to the mask uses the image's alpha, which is what `tintColor` means:
701
+ // recolour the silhouette rather than blend with the pixels.
702
+ CGContextClipToMask(context, drawn, _image);
703
+ CGContextSetFillColorWithColor(context, _imageTint.CGColor);
704
+ CGContextFillRect(context, drawn);
705
+ } else {
706
+ CGContextDrawImage(context, drawn, _image);
707
+ }
708
+ CGContextRestoreGState(context);
709
+ }
710
+
711
+ // Text sits above the image and below any children, which is the order the
712
+ // GTK side paints in too.
713
+ if (_textLayout != nil) {
714
+ [_textLayout drawInContext:context size:size];
715
+ }
716
+
717
+ // Borders paint over the content, as they do on every other platform.
718
+ if (_hasBorders) {
719
+ [self rnDrawBordersInContext:context size:size];
720
+ }
721
+
722
+ // The focus ring, over everything including the border, because it is the
723
+ // answer to "where am I" and must not be hidden by what it is drawn on.
724
+ //
725
+ // Inside the bounds rather than outside: drawRect: is clipped to them, and a
726
+ // ring that is inset here and outset on Linux would be a difference an app
727
+ // did not ask for. It follows the view's own corner radii, so it hugs a
728
+ // rounded button.
729
+ // React DevTools' overlay, over everything including the border. Above the
730
+ // app on purpose: it is not part of it, and an inspected element half hidden
731
+ // behind a card would be pointing at the wrong thing.
732
+ for (size_t i = 0; i < _highlightFilled.size(); i++) {
733
+ const float *rectangle = _highlights.data() + (i * 8);
734
+ const NSRect area = NSMakeRect(rectangle[0], rectangle[1], rectangle[2], rectangle[3]);
735
+ if (_highlightFilled[i]) {
736
+ CGContextSetRGBFillColor(context, rectangle[4], rectangle[5], rectangle[6], rectangle[7]);
737
+ CGContextFillRect(context, area);
738
+ }
739
+ // Opaque on the outline even when the fill is not, so the edge of an
740
+ // inspected element is a line rather than a suggestion. Stroked down the
741
+ // middle, so the path is inset by half the width to keep it inside.
742
+ CGContextSetRGBStrokeColor(context, rectangle[4], rectangle[5], rectangle[6], 1.0);
743
+ CGContextSetLineWidth(context, basalt::kHighlightBorderWidth);
744
+ const CGFloat inset = basalt::kHighlightBorderWidth / 2.0;
745
+ CGContextStrokeRect(context, NSInsetRect(area, inset, inset));
746
+ }
747
+
748
+ if (ring) {
749
+ [self rnDrawFocusRingInContext:context size:size];
750
+ }
751
+ }
752
+
753
+ - (void)rnDrawFocusRingInContext:(CGContextRef)context size:(NSSize)size {
754
+ const CGFloat width = basalt::kFocusRingWidth;
755
+ // Stroked down the middle of the line, so the path is inset by half of it to
756
+ // keep the whole ring inside the view.
757
+ const NSRect rect = NSMakeRect(width / 2.0, width / 2.0,
758
+ MAX(0.0, size.width - width),
759
+ MAX(0.0, size.height - width));
760
+ CGFloat radii[8];
761
+ for (int i = 0; i < 8; i++) {
762
+ radii[i] = _hasBorderRadii ? MAX(0.0, _borderRadii[i] - width / 2.0) : 0.0;
763
+ }
764
+ CGPathRef path = RnAppKitCreateRoundedPath(rect, radii);
765
+ CGContextSaveGState(context);
766
+ CGContextSetRGBStrokeColor(context,
767
+ basalt::kFocusRingRed,
768
+ basalt::kFocusRingGreen,
769
+ basalt::kFocusRingBlue,
770
+ basalt::kFocusRingAlpha);
771
+ CGContextSetLineWidth(context, width);
772
+ CGContextAddPath(context, path);
773
+ CGContextStrokePath(context);
774
+ CGContextRestoreGState(context);
775
+ CGPathRelease(path);
776
+ }
777
+
778
+ // The four edges, each its own width and colour.
779
+ //
780
+ // This is CSS's own algorithm rather than a stroke: an edge is the region
781
+ // between the outer and inner rounded rectangles, clipped to a wedge whose
782
+ // sides are the diagonals from the outer corner to the inner one. That is what
783
+ // mitres two edges of different colours against each other, and a stroke
784
+ // cannot do it -- which matters as soon as one edge differs, and is invisible
785
+ // until then.
786
+ - (void)rnDrawBordersInContext:(CGContextRef)context size:(NSSize)size {
787
+ const CGFloat w = size.width;
788
+ const CGFloat h = size.height;
789
+ const CGFloat top = _borderWidths[0];
790
+ const CGFloat right = _borderWidths[1];
791
+ const CGFloat bottom = _borderWidths[2];
792
+ const CGFloat left = _borderWidths[3];
793
+
794
+ CGPathRef outer = RnAppKitCreateRoundedPath(CGRectMake(0, 0, w, h), _borderRadii);
795
+
796
+ // The inner radii shrink by the width of the edges that meet at the corner.
797
+ // A corner whose radius is smaller than its border is square on the inside,
798
+ // which the clamp in the path builder takes care of.
799
+ const CGFloat innerRadii[8] = {
800
+ _borderRadii[0] - left, _borderRadii[1] - top,
801
+ _borderRadii[2] - right, _borderRadii[3] - top,
802
+ _borderRadii[4] - right, _borderRadii[5] - bottom,
803
+ _borderRadii[6] - left, _borderRadii[7] - bottom,
804
+ };
805
+ const CGFloat innerWidth = w - left - right;
806
+ const CGFloat innerHeight = h - top - bottom;
807
+ CGPathRef inner = nullptr;
808
+ if (innerWidth > 0 && innerHeight > 0) {
809
+ inner = RnAppKitCreateRoundedPath(
810
+ CGRectMake(left, top, innerWidth, innerHeight), innerRadii);
811
+ }
812
+
813
+ // The diagonal at each corner, as a point and a direction, and a point known
814
+ // to be inside each edge's own band.
815
+ //
816
+ // Deliberately not four quadrilaterals stopping at the inner rectangle. That
817
+ // is right only while the corners are square: a radius pushes the border band
818
+ // outside those quads, and the part of the arc beyond them is then inside no
819
+ // wedge at all and never painted. What that looks like is not a missing
820
+ // sliver -- it is a corner whose colour stops early, which reads as a chamfer
821
+ // rather than as a bug.
822
+ //
823
+ // Two half-plane clips per edge instead. Successive clips intersect, so the
824
+ // pair is the sector the edge owns, and a sector is unbounded: it covers the
825
+ // whole arc however large the radius. It also stays correct when the two
826
+ // diagonals cross inside the view, which a quadrilateral cannot -- that
827
+ // happens whenever the border is thick relative to the frame, and it turns a
828
+ // quad into a bowtie.
829
+ const CGPoint corners[4] = {{0, 0}, {w, 0}, {w, h}, {0, h}};
830
+ const CGPoint directions[4] = {
831
+ {left, top}, {-right, top}, {-right, -bottom}, {left, -bottom}};
832
+ // Edge e is bounded by the diagonals at corner e and corner e+1.
833
+ const CGPoint insides[4] = {
834
+ {w / 2, top / 2},
835
+ {w - right / 2, h / 2},
836
+ {w / 2, h - bottom / 2},
837
+ {left / 2, h / 2},
838
+ };
839
+ const CGFloat extent = (w + h) * 4;
840
+
841
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
842
+ for (int edge = 0; edge < 4; edge++) {
843
+ if (_borderWidths[edge] <= 0 || _borderColors[edge * 4 + 3] <= 0) {
844
+ continue;
845
+ }
846
+ CGContextSaveGState(context);
847
+
848
+ RnAppKitClipToHalfPlane(context, corners[edge], directions[edge], insides[edge], extent);
849
+ RnAppKitClipToHalfPlane(
850
+ context, corners[(edge + 1) % 4], directions[(edge + 1) % 4], insides[edge], extent);
851
+
852
+ // Outer minus inner, as an even-odd fill of the two subpaths.
853
+ CGContextBeginPath(context);
854
+ CGContextAddPath(context, outer);
855
+ if (inner != nullptr) {
856
+ CGContextAddPath(context, inner);
857
+ }
858
+ const CGFloat components[4] = {
859
+ _borderColors[edge * 4 + 0], _borderColors[edge * 4 + 1],
860
+ _borderColors[edge * 4 + 2], _borderColors[edge * 4 + 3]};
861
+ CGColorRef color = CGColorCreate(space, components);
862
+ CGContextSetFillColorWithColor(context, color);
863
+ CGContextEOFillPath(context);
864
+ CGColorRelease(color);
865
+
866
+ CGContextRestoreGState(context);
867
+ }
868
+ CGColorSpaceRelease(space);
869
+ CGPathRelease(outer);
870
+ if (inner != nullptr) {
871
+ CGPathRelease(inner);
872
+ }
873
+ }
874
+
875
+ - (void)setRnOpacity:(CGFloat)opacity {
876
+ _opacity = opacity;
877
+ self.layer.opacity = (float)opacity;
878
+ }
879
+
880
+ - (void)setRnHidesBackFace:(BOOL)hides {
881
+ if (_hidesBackFace == hides) {
882
+ return;
883
+ }
884
+ _hidesBackFace = hides;
885
+ [self rnUpdateBackFaceVisibility];
886
+ }
887
+
888
+ // Hidden through `self.hidden` rather than by skipping the draw: it takes the
889
+ // children with it, which is what a back face should do, and it takes hit
890
+ // testing with it too -- the back of a card is not clickable.
891
+ //
892
+ // The two reasons are kept apart and combined here. `self.hidden` is one flag
893
+ // and `display: none` writes it too, so whichever of the two spoke last used
894
+ // to answer for both -- and layout metrics are applied after props, so a card
895
+ // turned away from the viewer was reliably un-hidden a moment later.
896
+ - (void)rnApplyVisibility {
897
+ self.hidden = (_hiddenByApp || _hiddenByBackFace) ? YES : NO;
898
+ }
899
+
900
+ - (void)setRnHidden:(BOOL)hidden {
901
+ _hiddenByApp = hidden;
902
+ [self rnApplyVisibility];
903
+ }
904
+
905
+ - (void)rnUpdateBackFaceVisibility {
906
+ BOOL away = NO;
907
+ if (_hidesBackFace) {
908
+ float matrix[16];
909
+ const CGFloat *fields = (const CGFloat *)&_transform;
910
+ for (int index = 0; index < 16; index++) {
911
+ matrix[index] = (float)fields[index];
912
+ }
913
+ away = basalt::facesAway(matrix) ? YES : NO;
914
+ }
915
+ // Recomputed rather than returned early on, so that turning the prop off
916
+ // shows a view it had hidden instead of leaving it hidden forever.
917
+ _hiddenByBackFace = away;
918
+ [self rnApplyVisibility];
919
+ }
920
+
921
+ - (void)setRnTransform:(nullable const float *)matrix {
922
+ if (matrix == nullptr) {
923
+ _hasTransform = NO;
924
+ _transform = CATransform3DIdentity;
925
+ self.layer.transform = CATransform3DIdentity;
926
+ [self rnUpdateBackFaceVisibility];
927
+ return;
928
+ }
929
+
930
+ // Field by field rather than a memcpy: CATransform3D's members are CGFloat,
931
+ // which is not guaranteed to be double, and the layout is only documented as
932
+ // "m11 through m44 in this order".
933
+ CATransform3D transform;
934
+ CGFloat *fields = (CGFloat *)&transform;
935
+ for (int index = 0; index < 16; index++) {
936
+ fields[index] = (CGFloat)matrix[index];
937
+ }
938
+
939
+ _transform = transform;
940
+ _hasTransform = !CATransform3DIsIdentity(transform);
941
+ [self rnUpdateBackFaceVisibility];
942
+ self.layer.transform = transform;
943
+ }
944
+
945
+ - (void)setRnClipsChildren:(BOOL)clips {
946
+ _clipsChildren = clips;
947
+ self.layer.masksToBounds = clips;
948
+ }
949
+
950
+ - (void)setRnZIndex:(NSInteger)zIndex {
951
+ _zIndex = zIndex;
952
+ // Core Animation composites sibling layers by zPosition, so painting reorders
953
+ // without the subviews array moving -- and the subviews array is the child
954
+ // list Fabric indexes into. Reordering it to paint would corrupt the next
955
+ // Insert.
956
+ self.layer.zPosition = (CGFloat)zIndex;
957
+ }
958
+
959
+ - (NSInteger)rnZIndex {
960
+ return _zIndex;
961
+ }
962
+
963
+ - (CGAffineTransform)rnLocalToParent {
964
+ const NSRect frame = self.frame;
965
+ const CGAffineTransform translation =
966
+ CGAffineTransformMakeTranslation(frame.origin.x, frame.origin.y);
967
+
968
+ // `CATransform3DIsAffine` is false for a perspective transform, which has no
969
+ // 2D inverse to hit test with. React Native can express one; nothing here
970
+ // draws it yet -- see docs/backlog/correctness.md -- and answering with the
971
+ // translation keeps such a view clickable at its untransformed place rather
972
+ // than nowhere at all.
973
+ if (!_hasTransform || !CATransform3DIsAffine(_transform)) {
974
+ return translation;
975
+ }
976
+
977
+ // Anchored at the centre, because that is where CALayer's default anchor
978
+ // point is and so where the drawn transform turns. Written as move the
979
+ // centre to the origin, transform, move it back, then place the frame --
980
+ // the same four steps, in the same order, as Win32's `localToParent`.
981
+ const CGFloat centreX = frame.size.width / 2.0;
982
+ const CGFloat centreY = frame.size.height / 2.0;
983
+
984
+ CGAffineTransform local = CGAffineTransformMakeTranslation(-centreX, -centreY);
985
+ local = CGAffineTransformConcat(local, CATransform3DGetAffineTransform(_transform));
986
+ local = CGAffineTransformConcat(local, CGAffineTransformMakeTranslation(centreX, centreY));
987
+ return CGAffineTransformConcat(local, translation);
988
+ }
989
+
990
+ - (BOOL)rnPageToLocal:(NSPoint)page fromRoot:(RnAppKitView *)root into:(NSPoint *)out {
991
+ CGAffineTransform toPage = CGAffineTransformIdentity;
992
+
993
+ for (RnAppKitView *node = self; node != nil && node != root;) {
994
+ toPage = CGAffineTransformConcat(toPage, [node rnLocalToParent]);
995
+
996
+ RnAppKitView *parent =
997
+ [node.superview isKindOfClass:[RnAppKitView class]] ? (RnAppKitView *)node.superview : nil;
998
+ if (parent == nil) {
999
+ break;
1000
+ }
1001
+ // A scrolled ancestor moves its children by its bounds origin, which is
1002
+ // not part of any child's own placement -- the same offset the hit test
1003
+ // adds back on the way down.
1004
+ const NSPoint scroll = parent.bounds.origin;
1005
+ toPage =
1006
+ CGAffineTransformConcat(toPage, CGAffineTransformMakeTranslation(-scroll.x, -scroll.y));
1007
+ node = parent;
1008
+ }
1009
+
1010
+ const CGFloat determinant = toPage.a * toPage.d - toPage.b * toPage.c;
1011
+ if (determinant == 0.0) {
1012
+ return NO;
1013
+ }
1014
+ if (out != NULL) {
1015
+ *out = CGPointApplyAffineTransform(page, CGAffineTransformInvert(toPage));
1016
+ }
1017
+ return YES;
1018
+ }
1019
+
1020
+ - (NSArray<RnAppKitView *> *)rnChildrenInPaintOrder {
1021
+ NSMutableArray<RnAppKitView *> *ordered = [NSMutableArray array];
1022
+ for (NSView *child in self.subviews) {
1023
+ if ([child isKindOfClass:[RnAppKitView class]]) {
1024
+ [ordered addObject:(RnAppKitView *)child];
1025
+ }
1026
+ }
1027
+ // Only when something asked for it. The overwhelmingly common view has no
1028
+ // zIndex anywhere among its children, and sorting every tree on every hit
1029
+ // test to discover that would be paid on every press.
1030
+ BOOL any = NO;
1031
+ for (RnAppKitView *child in ordered) {
1032
+ if (child.rnZIndex != 0) {
1033
+ any = YES;
1034
+ break;
1035
+ }
1036
+ }
1037
+ if (!any) {
1038
+ return ordered;
1039
+ }
1040
+ // Stable, so equal zIndex keeps document order.
1041
+ return [ordered sortedArrayWithOptions:NSSortStable
1042
+ usingComparator:^NSComparisonResult(RnAppKitView *a, RnAppKitView *b) {
1043
+ if (a.rnZIndex < b.rnZIndex) return NSOrderedAscending;
1044
+ if (a.rnZIndex > b.rnZIndex) return NSOrderedDescending;
1045
+ return NSOrderedSame;
1046
+ }];
1047
+ }
1048
+
1049
+ - (void)setRnCornerRadius:(CGFloat)radius {
1050
+ const CGFloat radii[8] = {radius, radius, radius, radius,
1051
+ radius, radius, radius, radius};
1052
+ [self setRnBorderRadii:radii];
1053
+ }
1054
+
1055
+ - (void)setRnBorderRadii:(nullable const CGFloat *)radii {
1056
+ BOOL any = NO;
1057
+ for (int i = 0; i < 8; i++) {
1058
+ _borderRadii[i] = radii != nullptr && radii[i] > 0 ? radii[i] : 0;
1059
+ if (_borderRadii[i] > 0) {
1060
+ any = YES;
1061
+ }
1062
+ }
1063
+ _hasBorderRadii = any;
1064
+
1065
+ // A single circular radius is what CALayer can express directly, and is the
1066
+ // overwhelmingly common case -- one `borderRadius` in a stylesheet. Keeping
1067
+ // it on cornerRadius rather than on a mask means the usual view stays a
1068
+ // plain layer, and means `overflow: 'visible'` still lets children escape.
1069
+ BOOL uniform = YES;
1070
+ for (int i = 1; i < 8; i++) {
1071
+ if (_borderRadii[i] != _borderRadii[0]) {
1072
+ uniform = NO;
1073
+ break;
1074
+ }
1075
+ }
1076
+
1077
+ if (uniform) {
1078
+ _cornerRadius = _borderRadii[0];
1079
+ self.layer.cornerRadius = _borderRadii[0];
1080
+ self.layer.mask = nil;
1081
+ } else {
1082
+ // Anything else needs a mask layer, which is what UIKit's own RCTView does
1083
+ // for the same reason. The cost is that a mask clips children whatever
1084
+ // `overflow` says -- CALayer offers no way to round a background without
1085
+ // clipping what sits on it.
1086
+ _cornerRadius = 0;
1087
+ self.layer.cornerRadius = 0;
1088
+ [self rnUpdateRadiusMask];
1089
+ }
1090
+ // The border is drawn along these radii.
1091
+ self.needsDisplay = YES;
1092
+ }
1093
+
1094
+ - (void)rnUpdateRadiusMask {
1095
+ if (!_hasBorderRadii) {
1096
+ self.layer.mask = nil;
1097
+ return;
1098
+ }
1099
+ CAShapeLayer *mask = [CAShapeLayer layer];
1100
+ mask.frame = self.bounds;
1101
+ CGPathRef path = RnAppKitCreateRoundedPath(self.bounds, _borderRadii);
1102
+ mask.path = path;
1103
+ CGPathRelease(path);
1104
+ self.layer.mask = mask;
1105
+ }
1106
+
1107
+ - (void)setRnBorderWidths:(nullable const CGFloat *)widths
1108
+ colors:(nullable const CGFloat *)colors {
1109
+ BOOL any = NO;
1110
+ for (int edge = 0; edge < 4; edge++) {
1111
+ _borderWidths[edge] = widths != nullptr && widths[edge] > 0 ? widths[edge] : 0;
1112
+ for (int c = 0; c < 4; c++) {
1113
+ _borderColors[edge * 4 + c] = colors != nullptr ? colors[edge * 4 + c] : 0;
1114
+ }
1115
+ // A width with a transparent colour paints nothing, and the GTK side makes
1116
+ // the same judgement, so the two agree on whether a view has a border at
1117
+ // all rather than only on what it looks like.
1118
+ if (_borderWidths[edge] > 0 && _borderColors[edge * 4 + 3] > 0) {
1119
+ any = YES;
1120
+ }
1121
+ }
1122
+ _hasBorders = any;
1123
+ self.needsDisplay = YES;
1124
+ }
1125
+
1126
+ - (void)insertRnChild:(RnAppKitView *)child atIndex:(NSInteger)index {
1127
+ NSArray<NSView *> *children = self.subviews;
1128
+ if (index >= (NSInteger)children.count) {
1129
+ [self addSubview:child];
1130
+ [self rnRaiseIndicators];
1131
+ return;
1132
+ }
1133
+ [self addSubview:child positioned:NSWindowBelow relativeTo:children[(NSUInteger)index]];
1134
+ }
1135
+
1136
+ // A child added at or past the end lands above the overlay, so put it back on
1137
+ // top. Cheap, and the alternative -- teaching every insert about a view that is
1138
+ // not part of the tree -- puts the overlay into code that has nothing to do
1139
+ // with it.
1140
+ - (void)rnRaiseIndicators {
1141
+ if (_indicators != nil) {
1142
+ [self addSubview:_indicators positioned:NSWindowAbove relativeTo:nil];
1143
+ }
1144
+ }
1145
+
1146
+ - (void)removeRnChild:(RnAppKitView *)child {
1147
+ // Removed, not destroyed: a Delete mutation for the same tag follows
1148
+ // separately, and until then the view may be inserted somewhere else.
1149
+ if (child.superview == self) {
1150
+ [child removeFromSuperview];
1151
+ }
1152
+ }
1153
+
1154
+ - (void)describeInto:(NSMutableString *)out depth:(NSInteger)depth {
1155
+ for (NSInteger i = 0; i < depth; i++) {
1156
+ [out appendString:@" "];
1157
+ }
1158
+ const NSRect frame = self.frame;
1159
+ [out appendFormat:@"view tag=%ld frame=(%g,%g %gx%g)",
1160
+ (long)self.rnTag,
1161
+ frame.origin.x,
1162
+ frame.origin.y,
1163
+ frame.size.width,
1164
+ frame.size.height];
1165
+
1166
+ if (_hasBackgroundColor) {
1167
+ [out appendFormat:@" bg=#%02x%02x%02x%02x",
1168
+ (unsigned)(_backgroundComponents[0] * 255.0 + 0.5),
1169
+ (unsigned)(_backgroundComponents[1] * 255.0 + 0.5),
1170
+ (unsigned)(_backgroundComponents[2] * 255.0 + 0.5),
1171
+ (unsigned)(_backgroundComponents[3] * 255.0 + 0.5)];
1172
+ }
1173
+ // Field order matches the GTK side exactly -- bg, opacity, clip, then text --
1174
+ // because scripts/compare_hosts.sh diffs the two dumps line by line and a
1175
+ // reordering would read as every line differing.
1176
+ if (_opacity < 1.0) {
1177
+ [out appendFormat:@" opacity=%g", _opacity];
1178
+ }
1179
+ if (_clipsChildren) {
1180
+ [out appendString:@" clip"];
1181
+ }
1182
+ // Whether anything is drawn at all. Without this a view hidden by
1183
+ // `display: none` or by a back face turned away reads exactly like a visible
1184
+ // one, and the only prop in this dump that removes a view entirely was the
1185
+ // only one it could not show.
1186
+ if (self.hidden) {
1187
+ [out appendString:@" hidden"];
1188
+ }
1189
+ // Per-corner radii and per-edge borders, in the same fields and the same
1190
+ // order the GTK and Win32 sides print. They are here for the same reason
1191
+ // `transform=` is, and the comment there names the precedent: a frame cannot
1192
+ // show a border, so a bordered view and a bare one read as identical in this
1193
+ // dump -- which is how this host went until phase 47 applying neither.
1194
+ if (_hasBorderRadii) {
1195
+ [out appendFormat:@" radii=(%g,%g,%g,%g,%g,%g,%g,%g)",
1196
+ (double)_borderRadii[0], (double)_borderRadii[1],
1197
+ (double)_borderRadii[2], (double)_borderRadii[3],
1198
+ (double)_borderRadii[4], (double)_borderRadii[5],
1199
+ (double)_borderRadii[6], (double)_borderRadii[7]];
1200
+ }
1201
+ if (_hasBorders) {
1202
+ [out appendFormat:@" borderw=(%g,%g,%g,%g)",
1203
+ (double)_borderWidths[0], (double)_borderWidths[1],
1204
+ (double)_borderWidths[2], (double)_borderWidths[3]];
1205
+ [out appendString:@" borderc=("];
1206
+ for (int edge = 0; edge < 4; edge++) {
1207
+ [out appendFormat:@"%s#%02x%02x%02x%02x",
1208
+ edge == 0 ? "" : ",",
1209
+ (unsigned)(_borderColors[edge * 4 + 0] * 255.0 + 0.5),
1210
+ (unsigned)(_borderColors[edge * 4 + 1] * 255.0 + 0.5),
1211
+ (unsigned)(_borderColors[edge * 4 + 2] * 255.0 + 0.5),
1212
+ (unsigned)(_borderColors[edge * 4 + 3] * 255.0 + 0.5)];
1213
+ }
1214
+ [out appendString:@")"];
1215
+ }
1216
+ if (_hasTransform) {
1217
+ // The 2D affine part, which is all either platform draws, in the order
1218
+ // CSS writes a matrix(): a, b, c, d, tx, ty. The GTK side prints the same
1219
+ // six from its own matrix, so a transform is comparable across the two --
1220
+ // without which a view that is rotated on one desktop and not on the other
1221
+ // looks identical in this dump. Which is exactly how the macOS host went
1222
+ // this long without applying transforms at all.
1223
+ [out appendFormat:@" transform=(%g,%g,%g,%g,%g,%g)",
1224
+ (double)_transform.m11,
1225
+ (double)_transform.m12,
1226
+ (double)_transform.m21,
1227
+ (double)_transform.m22,
1228
+ (double)_transform.m41,
1229
+ (double)_transform.m42];
1230
+ }
1231
+ const NSPoint scroll = self.bounds.origin;
1232
+ if (scroll.x != 0 || scroll.y != 0) {
1233
+ [out appendFormat:@" scroll=(%g,%g)", scroll.x, scroll.y];
1234
+ }
1235
+ // The overlay scrollbars, which are otherwise pure paint and so invisible to
1236
+ // every test this project has. Printed only when there is one, so a view that
1237
+ // does not scroll stays as short as it was.
1238
+ if (_indicators != nil && _indicators.rnVerticalLength > 0) {
1239
+ [out appendFormat:@" scrollbar-v=(%g,%g)",
1240
+ _indicators.rnVerticalOffset,
1241
+ _indicators.rnVerticalLength];
1242
+ }
1243
+ if (_indicators != nil && _indicators.rnHorizontalLength > 0) {
1244
+ [out appendFormat:@" scrollbar-h=(%g,%g)",
1245
+ _indicators.rnHorizontalOffset,
1246
+ _indicators.rnHorizontalLength];
1247
+ }
1248
+ // Printed only when it is not the default, like every other field here.
1249
+ // Worth printing at all because it is invisible: a view with
1250
+ // `pointerEvents: none` is drawn exactly like one without, and the only way
1251
+ // the cross-host diff can say the prop arrived on all three is if each one
1252
+ // reports it.
1253
+ if (self.rnPointerEvents != RnAppKitPointerEventsAuto) {
1254
+ [out appendFormat:@" pe=%s", RnAppKitPointerEventsName(self.rnPointerEvents)];
1255
+ }
1256
+ if (_image != nullptr) {
1257
+ // The same `texture=WxH fit=<name>` the GTK side emits, so an <Image> shows
1258
+ // up in the cross-platform diff and the end-to-end suite can assert on it.
1259
+ // The fit is here because it is the only thing about a drawn image that a
1260
+ // frame cannot show: two views the same size holding the same picture are
1261
+ // identical in every other line of this dump and different on screen.
1262
+ [out appendFormat:@" texture=%zux%zu", CGImageGetWidth(_image), CGImageGetHeight(_image)];
1263
+ [out appendFormat:@" fit=%s", RnAppKitImageFitName(_imageFit)];
1264
+ // Printed for the same reason the fit is: a tinted image and an untinted
1265
+ // one are identical in every other line of this dump and different on
1266
+ // screen. Formatted exactly as GTK formats a colour, so the cross-host diff
1267
+ // can compare them.
1268
+ if (_imageTint != nil) {
1269
+ NSColor *rgb = [_imageTint colorUsingColorSpace:NSColorSpace.sRGBColorSpace] ?: _imageTint;
1270
+ [out appendFormat:@" tint=#%02x%02x%02x%02x",
1271
+ (unsigned)(rgb.redComponent * 255.0 + 0.5),
1272
+ (unsigned)(rgb.greenComponent * 255.0 + 0.5),
1273
+ (unsigned)(rgb.blueComponent * 255.0 + 0.5),
1274
+ (unsigned)(rgb.alphaComponent * 255.0 + 0.5)];
1275
+ }
1276
+ }
1277
+ if (_textLayout != nil) {
1278
+ NSString *text = _textLayout.attributedString.string;
1279
+ if (text.length > 0) {
1280
+ // Escaped like g_strescape's output on the other side, so a string with a
1281
+ // quote or a newline in it stays one line and stays comparable.
1282
+ NSMutableString *escaped = [text mutableCopy];
1283
+ [escaped replaceOccurrencesOfString:@"\\" withString:@"\\\\"
1284
+ options:0 range:NSMakeRange(0, escaped.length)];
1285
+ [escaped replaceOccurrencesOfString:@"\"" withString:@"\\\""
1286
+ options:0 range:NSMakeRange(0, escaped.length)];
1287
+ [escaped replaceOccurrencesOfString:@"\n" withString:@"\\n"
1288
+ options:0 range:NSMakeRange(0, escaped.length)];
1289
+ [out appendFormat:@" text=\"%@\"", escaped];
1290
+ }
1291
+ }
1292
+
1293
+ // A text field's content lives in its NSTextField peer, not in anything this
1294
+ // view draws, so it would otherwise be invisible to every test that reads
1295
+ // this tree. Same spelling as the GTK side's.
1296
+ if (self.rnEditable != nil) {
1297
+ NSString *value = RnPeerText(self.rnEditable);
1298
+ NSMutableString *escaped = [value mutableCopy];
1299
+ [escaped replaceOccurrencesOfString:@"\\" withString:@"\\\\"
1300
+ options:0 range:NSMakeRange(0, escaped.length)];
1301
+ [escaped replaceOccurrencesOfString:@"\"" withString:@"\\\""
1302
+ options:0 range:NSMakeRange(0, escaped.length)];
1303
+ [out appendFormat:@" editable=\"%@\"", escaped];
1304
+
1305
+ // First responder is the field's *field editor* while it is being edited,
1306
+ // not the field itself, so asking the window whether it is editing this one
1307
+ // is the question that actually has the right answer.
1308
+ NSWindow *window = self.window;
1309
+ // A multiline peer *is* the editor, so it is the first responder itself;
1310
+ // a single-line one is being edited through a field editor whose delegate
1311
+ // is the field. RnPeerEditor answers for both.
1312
+ const BOOL focused = window != nil && window.firstResponder != nil &&
1313
+ window.firstResponder == RnPeerEditor(self.rnEditable);
1314
+ if (focused) {
1315
+ [out appendString:@" focused"];
1316
+ }
1317
+ }
1318
+
1319
+ // How many DevTools highlights this view is drawing. In the dump because they
1320
+ // are otherwise invisible to everything but a screenshot, and because a
1321
+ // command that arrived and drew nothing is exactly the failure worth
1322
+ // catching.
1323
+ if (!_highlightFilled.empty()) {
1324
+ [out appendFormat:@" highlights=%lu", (unsigned long)_highlightFilled.size()];
1325
+ }
1326
+
1327
+ // What kind of control this view is, and what state it is in. Written by
1328
+ // core/DesktopControls.h rather than formatted here, for the same reason the
1329
+ // role name below is React Native's vocabulary and not AppKit's.
1330
+ if (_rnControlDescription != nil) {
1331
+ [out appendFormat:@" control=%@", _rnControlDescription];
1332
+ }
1333
+
1334
+ // React Native's role name, not AppKit's. The GTK side reports the same
1335
+ // string for the same reason: this dump is compared line by line across two
1336
+ // platforms, and each reporting its own toolkit's vocabulary would make every
1337
+ // accessible view look like a difference. That the *platform* role was really
1338
+ // applied is asserted in each platform's unit tests instead.
1339
+ if (_roleName != nil) {
1340
+ [out appendFormat:@" role=%@", _roleName];
1341
+ }
1342
+ // Whether Tab stops here, after `role=` because that is where the GTK side
1343
+ // prints it and this dump is diffed line by line. Whether it is focused *now*
1344
+ // is deliberately not printed: that depends on what the window server did
1345
+ // when the window opened, which is not a property of the platform and would
1346
+ // make this dump differ between two machines running the same app.
1347
+ if (self.rnFocusable) {
1348
+ [out appendString:@" focusable"];
1349
+ }
1350
+ [out appendString:@"\n"];
1351
+
1352
+ for (NSView *child in self.subviews) {
1353
+ if ([child isKindOfClass:[RnAppKitView class]]) {
1354
+ [(RnAppKitView *)child describeInto:out depth:depth + 1];
1355
+ }
1356
+ }
1357
+ }
1358
+
1359
+ // --- Input ----------------------------------------------------------------
1360
+ //
1361
+ // AppKit delivers a mouse event to the view its own hit testing picked, which
1362
+ // is some view in this tree; React Native wants it against the surface root,
1363
+ // hit-tested React Native's way. So every view forwards, and the root is where
1364
+ // the forwarding stops.
1365
+ //
1366
+ // Not overriding hitTest: to make the root swallow everything instead: that
1367
+ // would also swallow the cursor rectangles, tooltips and tracking areas any
1368
+ // later component needs, and AppKit's own hit testing is doing no harm here.
1369
+
1370
+ // Keyboard focus.
1371
+ //
1372
+ // AppKit's key-view loop is the chain: a view that accepts first responder
1373
+ // status is reached by Tab, and `nextValidKeyView` walks the view hierarchy, so
1374
+ // the order is tree order without this project deciding what "next" means. The
1375
+ // GTK host gets the same for the same reason; the Win32 host has no such loop
1376
+ // and builds one. See AppKitFocus.h.
1377
+
1378
+ - (BOOL)acceptsFirstResponder {
1379
+ return _rnFocusable;
1380
+ }
1381
+
1382
+ - (BOOL)canBecomeKeyView {
1383
+ // Not inherited: NSView's default also consults the Full Keyboard Access
1384
+ // setting for some views, and a React Native app's buttons are not AppKit
1385
+ // controls that a user has opted into tabbing to -- they are the whole
1386
+ // interface.
1387
+ return _rnFocusable && !self.isHiddenOrHasHiddenAncestor;
1388
+ }
1389
+
1390
+ - (nullable id<RnAppKitFocusHandler>)rnFocusHandlerForTree {
1391
+ NSView *view = self;
1392
+ while (view != nil) {
1393
+ if ([view isKindOfClass:[RnAppKitView class]]) {
1394
+ id<RnAppKitFocusHandler> handler = ((RnAppKitView *)view).rnFocusHandler;
1395
+ if (handler != nil) {
1396
+ return handler;
1397
+ }
1398
+ }
1399
+ view = view.superview;
1400
+ }
1401
+ return nil;
1402
+ }
1403
+
1404
+ - (BOOL)becomeFirstResponder {
1405
+ if (![super becomeFirstResponder]) {
1406
+ return NO;
1407
+ }
1408
+ // The ring is drawn by this view, so it has to redraw when focus arrives.
1409
+ self.needsDisplay = YES;
1410
+ [[self rnFocusHandlerForTree] rnView:self didChangeFocus:YES];
1411
+ return YES;
1412
+ }
1413
+
1414
+ - (BOOL)resignFirstResponder {
1415
+ if (![super resignFirstResponder]) {
1416
+ return NO;
1417
+ }
1418
+ self.needsDisplay = YES;
1419
+ [[self rnFocusHandlerForTree] rnView:self didChangeFocus:NO];
1420
+ return YES;
1421
+ }
1422
+
1423
+ - (void)keyDown:(NSEvent *)event {
1424
+ // An app's own declared shortcuts first, before activation and before Tab.
1425
+ //
1426
+ // Deliberately first: an app that binds Enter or Tab means it, and a platform
1427
+ // default that won over an explicit declaration would be impossible to
1428
+ // override. The cost is that binding Tab breaks focus movement inside that
1429
+ // view, which is the app's business and is what a browser does too.
1430
+ if ([[self rnFocusHandlerForTree] rnView:self handlesKey:event]) {
1431
+ return;
1432
+ }
1433
+ // Return, enter and space, which are the two keys that activate a control on
1434
+ // every desktop and in the browser. Anything else goes on, so a view with
1435
+ // focus does not swallow the window's own shortcuts.
1436
+ const unichar first = event.charactersIgnoringModifiers.length > 0
1437
+ ? [event.charactersIgnoringModifiers characterAtIndex:0]
1438
+ : 0;
1439
+ const BOOL activates = first == NSCarriageReturnCharacter ||
1440
+ first == NSEnterCharacter || first == ' ';
1441
+ if (activates && [[self rnFocusHandlerForTree] rnActivateView:self]) {
1442
+ return;
1443
+ }
1444
+ // Tab, which on this platform is this project's own: AppKit's key-view loop
1445
+ // does nothing for a window built without a nib. Shift-Tab arrives as the
1446
+ // same character with the shift modifier set.
1447
+ if (first == NSTabCharacter || first == NSBackTabCharacter) {
1448
+ const BOOL forward = first == NSTabCharacter &&
1449
+ (event.modifierFlags & NSEventModifierFlagShift) == 0;
1450
+ if ([[self rnFocusHandlerForTree] rnMoveFocusForward:forward]) {
1451
+ return;
1452
+ }
1453
+ }
1454
+ [super keyDown:event];
1455
+ }
1456
+
1457
+ // Whether to paint the ring: focused, and focused by this window rather than by
1458
+ // a window that is not in front.
1459
+ - (BOOL)rnShowsFocusRing {
1460
+ NSWindow *window = self.window;
1461
+ return _rnFocusable && window != nil && window.firstResponder == self;
1462
+ }
1463
+
1464
+ - (nullable id<RnAppKitInputHandler>)rnHandler:(NSView **)outRoot {
1465
+ NSView *view = self;
1466
+ while (view != nil) {
1467
+ if ([view isKindOfClass:[RnAppKitView class]]) {
1468
+ id<RnAppKitInputHandler> handler = ((RnAppKitView *)view).rnInputHandler;
1469
+ if (handler != nil) {
1470
+ *outRoot = view;
1471
+ return handler;
1472
+ }
1473
+ }
1474
+ view = view.superview;
1475
+ }
1476
+ return nil;
1477
+ }
1478
+
1479
+ - (void)forwardMouse:(NSEvent *)event kind:(char)kind {
1480
+ NSView *root = nil;
1481
+ id<RnAppKitInputHandler> handler = [self rnHandler:&root];
1482
+ if (handler == nil) {
1483
+ return;
1484
+ }
1485
+ const NSPoint point = [root convertPoint:event.locationInWindow fromView:nil];
1486
+ // AppKit's `buttonNumber` is 0 for left, 1 for right, 2 for the wheel. W3C
1487
+ // has the middle one and the right one the other way round, which is exactly
1488
+ // the kind of thing worth converting once rather than comparing twice.
1489
+ int button = 0;
1490
+ switch (event.buttonNumber) {
1491
+ case 0: button = (int)basalt::PointerButton::Primary; break;
1492
+ case 1: button = (int)basalt::PointerButton::Secondary; break;
1493
+ default: button = (int)basalt::PointerButton::Middle; break;
1494
+ }
1495
+ switch (kind) {
1496
+ case 'd': [handler rnMouseDownAt:point button:button]; break;
1497
+ case 'm': [handler rnMouseDraggedTo:point]; break;
1498
+ case 'u': [handler rnMouseUpAt:point button:button]; break;
1499
+ case 'h': [handler rnMouseMovedTo:point]; break;
1500
+ default: break;
1501
+ }
1502
+ }
1503
+
1504
+ // Hover.
1505
+ //
1506
+ // A view gets `mouseMoved:` only if something asked for it, and the tracking
1507
+ // area is that ask. Only the surface root has an input handler, and the area
1508
+ // covers its whole visible rect, so one area serves every view in the tree --
1509
+ // which is the same arrangement the GTK side gets by attaching its motion
1510
+ // controller to the root rather than to each widget.
1511
+ //
1512
+ // NSTrackingInVisibleRect leaves the area's geometry to AppKit, so a window
1513
+ // resize does not need this recomputed; `updateTrackingAreas` still has to
1514
+ // exist, because that is where AppKit expects an area to be re-added.
1515
+ - (void)updateTrackingAreas {
1516
+ [super updateTrackingAreas];
1517
+ if (self.rnInputHandler == nil) {
1518
+ if (_rnHoverTrackingArea != nil) {
1519
+ [self removeTrackingArea:_rnHoverTrackingArea];
1520
+ _rnHoverTrackingArea = nil;
1521
+ }
1522
+ return;
1523
+ }
1524
+ if (_rnHoverTrackingArea != nil) {
1525
+ return;
1526
+ }
1527
+ _rnHoverTrackingArea = [[NSTrackingArea alloc]
1528
+ initWithRect:NSZeroRect
1529
+ options:NSTrackingMouseMoved | NSTrackingMouseEnteredAndExited | NSTrackingInVisibleRect |
1530
+ NSTrackingActiveInKeyWindow
1531
+ owner:self
1532
+ userInfo:nil];
1533
+ [self addTrackingArea:_rnHoverTrackingArea];
1534
+ }
1535
+
1536
+ - (void)mouseMoved:(NSEvent *)event {
1537
+ [self forwardMouse:event kind:'h'];
1538
+ }
1539
+
1540
+ - (void)mouseExited:(NSEvent *)event {
1541
+ NSView *root = nil;
1542
+ id<RnAppKitInputHandler> handler = [self rnHandler:&root];
1543
+ if (handler != nil) {
1544
+ [handler rnMouseExited];
1545
+ }
1546
+ [super mouseExited:event];
1547
+ }
1548
+
1549
+ // Wheel and touchpad. Unhandled scrolls go to super, which walks the responder
1550
+ // chain -- so a wheel over a plain view inside a list reaches the list.
1551
+ - (void)scrollWheel:(NSEvent *)event {
1552
+ id<RnAppKitScrollHandler> handler = self.rnScrollHandler;
1553
+ if (handler != nil) {
1554
+ const NSPoint delta = NSMakePoint(event.scrollingDeltaX, event.scrollingDeltaY);
1555
+ // `hasPreciseScrollingDeltas` is the only thing that can tell a touchpad's
1556
+ // pixels from a wheel's line counts -- both arrive as small numbers, so the
1557
+ // deltas alone cannot. Passed on rather than resolved here: how many pixels
1558
+ // a line is worth is a cross-platform decision, and it is made next to the
1559
+ // GTK side's copy of it.
1560
+ if ([handler rnScrollView:self
1561
+ by:delta
1562
+ precise:event.hasPreciseScrollingDeltas
1563
+ phase:event.phase
1564
+ momentum:event.momentumPhase]) {
1565
+ return;
1566
+ }
1567
+ }
1568
+ [super scrollWheel:event];
1569
+ }
1570
+
1571
+ - (void)mouseDown:(NSEvent *)event {
1572
+ [self forwardMouse:event kind:'d'];
1573
+ }
1574
+
1575
+ - (void)mouseDragged:(NSEvent *)event {
1576
+ [self forwardMouse:event kind:'m'];
1577
+ }
1578
+
1579
+ - (void)mouseUp:(NSEvent *)event {
1580
+ [self forwardMouse:event kind:'u'];
1581
+ }
1582
+
1583
+ // Every button, and which one it was now travels with it. React Native's touch
1584
+ // model still has no concept of one -- that belongs to pointer events -- so a
1585
+ // secondary click arrives as a pointer event and presses nothing, which is what
1586
+ // it means on every desktop. See core/PointerButtons.h.
1587
+ - (void)rightMouseDown:(NSEvent *)event {
1588
+ [self forwardMouse:event kind:'d'];
1589
+ }
1590
+
1591
+ - (void)rightMouseDragged:(NSEvent *)event {
1592
+ [self forwardMouse:event kind:'m'];
1593
+ }
1594
+
1595
+ - (void)rightMouseUp:(NSEvent *)event {
1596
+ [self forwardMouse:event kind:'u'];
1597
+ }
1598
+
1599
+ - (NSString *)describeTree {
1600
+ NSMutableString *out = [NSMutableString string];
1601
+ [self describeInto:out depth:0];
1602
+ return out;
1603
+ }
1604
+
1605
+ @end