basalt-appkit 0.0.0-rc5 → 0.1.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.
Files changed (42) hide show
  1. package/native/CMakeLists.txt +16 -2
  2. package/native/appkit/AppKitAppWindow.h +24 -0
  3. package/native/appkit/AppKitImageLoader.h +48 -1
  4. package/native/appkit/AppKitImageLoader.mm +228 -10
  5. package/native/appkit/AppKitMenuBar.mm +58 -0
  6. package/native/appkit/AppKitMountingManager.h +34 -0
  7. package/native/appkit/AppKitMountingManager.mm +576 -9
  8. package/native/appkit/AppKitScrollView.h +48 -0
  9. package/native/appkit/AppKitScrollView.mm +210 -0
  10. package/native/appkit/AppKitTextInput.h +38 -1
  11. package/native/appkit/AppKitTextInput.mm +235 -8
  12. package/native/appkit/AppKitTextPeer.h +60 -0
  13. package/native/appkit/AppKitTextPeer.mm +307 -0
  14. package/native/appkit/AppKitTouchDispatcher.h +56 -0
  15. package/native/appkit/AppKitTouchDispatcher.mm +140 -0
  16. package/native/appkit/AppKitWindowControl.mm +38 -3
  17. package/native/appkit/ComponentRegistryAppKit.mm +20 -0
  18. package/native/appkit/CoreTextLayout.h +64 -2
  19. package/native/appkit/CoreTextLayout.mm +431 -71
  20. package/native/appkit/CoreTextLayoutManager.mm +57 -6
  21. package/native/appkit/FontRegistryCoreText.mm +6 -5
  22. package/native/appkit/PlatformServicesAppKit.mm +101 -20
  23. package/native/appkit/RnAppKitView.h +492 -0
  24. package/native/appkit/RnAppKitView.mm +2266 -23
  25. package/native/appkit/RnTextLayout.h +106 -0
  26. package/native/appkit/RnTextLayout.mm +321 -2
  27. package/native/appkit/main_appkit.mm +174 -3
  28. package/native/tests/test_appkit_accessibility.mm +289 -0
  29. package/native/tests/test_appkit_borders.mm +975 -0
  30. package/native/tests/test_appkit_fonts.mm +203 -0
  31. package/native/tests/test_appkit_image.mm +392 -17
  32. package/native/tests/test_appkit_input.mm +209 -0
  33. package/native/tests/test_appkit_mounting.mm +360 -0
  34. package/native/tests/test_appkit_scrollbar.mm +125 -0
  35. package/native/tests/test_appkit_scrollview.mm +371 -0
  36. package/native/tests/test_appkit_selection.mm +349 -0
  37. package/native/tests/test_appkit_services.mm +51 -1
  38. package/native/tests/test_appkit_text.mm +1628 -0
  39. package/native/tests/test_appkit_textinput.mm +819 -0
  40. package/native/tests/test_appkit_titlebar.mm +43 -0
  41. package/native/tests/test_appkit_view.mm +609 -0
  42. package/package.json +4 -3
@@ -2,8 +2,10 @@
2
2
 
3
3
  #include "ControlMetrics.h"
4
4
  #include "Backface.h"
5
+ #include "ImageAnimation.h"
5
6
  #include "FocusRing.h"
6
7
  #include "ScrollIndicator.h"
8
+ #include "ShadowShape.h"
7
9
 
8
10
  #include <vector>
9
11
 
@@ -11,10 +13,12 @@
11
13
 
12
14
  #include "PointerButtons.h"
13
15
 
16
+ #include <algorithm>
14
17
  #include <cmath>
15
18
 
16
19
  #import "RnTextLayout.h"
17
20
 
21
+ #import <CoreImage/CoreImage.h>
18
22
  #import <QuartzCore/QuartzCore.h>
19
23
 
20
24
  // Topmost first: AppKit's subviews array is back to front, and a hit test wants
@@ -58,7 +62,10 @@ RnAppKitView *RnAppKitHitTest(RnAppKitView *root, CGFloat x, CGFloat y) {
58
62
  const CGFloat bx = x + bounds.origin.x;
59
63
  const CGFloat by = y + bounds.origin.y;
60
64
 
61
- if (!NSPointInRect(NSMakePoint(bx, by), bounds)) {
65
+ // `hitSlop` grows the box this test accepts, and only this test: the view is
66
+ // still drawn in its own frame. Children are reached through their own call,
67
+ // so each view's slop is applied to its own box on the way down.
68
+ if (!NSPointInRect(NSMakePoint(bx, by), [root rnHitArea])) {
62
69
  return nil;
63
70
  }
64
71
 
@@ -156,6 +163,209 @@ static CGPathRef RnAppKitCreateRoundedPath(CGRect rect, const CGFloat radii[8])
156
163
  return path;
157
164
  }
158
165
 
166
+ // One gradient as the view keeps it: the two points and its own stops. A
167
+ // transition hint turns three stops into eleven, so the lists are different
168
+ // lengths for no reason the caller controls, and each gradient owns its own.
169
+ struct RnAppKitGradientRecord {
170
+ RnAppKitGradientKind kind;
171
+ // Linear: the ends of the gradient line. Radial: the centre and the radii.
172
+ CGPoint start;
173
+ CGPoint end;
174
+ CGPoint center;
175
+ CGFloat radiusX;
176
+ CGFloat radiusY;
177
+ // Where the image goes and the tile it repeats in. See RnAppKitGradient.
178
+ CGRect area;
179
+ CGRect tile;
180
+ bool repeats;
181
+ std::vector<RnAppKitGradientStop> stops;
182
+ };
183
+
184
+ // The eight radii of a shadow's own box, each grown by the spread.
185
+ //
186
+ // The curve is `core/ShadowShape.h`'s, shared with the Win32 host, which builds
187
+ // a Direct2D geometry where this builds a CGPath and needs the same arithmetic.
188
+ // It lived here first; the comment about what the curve is and why it is not an
189
+ // addition moved with it.
190
+ static void RnAppKitSpreadRadii(const CGFloat radii[8], CGFloat spread, CGFloat out[8]) {
191
+ for (int i = 0; i < 8; i++) {
192
+ out[i] = static_cast<CGFloat>(basalt::spreadRadius(radii[i], spread));
193
+ }
194
+ }
195
+
196
+ // The same path, walked the other way, so that adding it to another one punches
197
+ // a hole rather than filling it: `shadowPath` has no fill rule and winds
198
+ // non-zero. NSBezierPath has reversal built in, and hand-rolling it means
199
+ // reversing four elliptical corners in the right order, which is exactly the
200
+ // kind of code that is wrong in one corner only.
201
+ static CGPathRef RnAppKitCreateReversedPath(CGPathRef path) {
202
+ NSBezierPath *bezier = [NSBezierPath bezierPathWithCGPath:path];
203
+ return CGPathCreateCopy([bezier bezierPathByReversingPath].CGPath);
204
+ }
205
+
206
+ // A shadow cast outward by the view's box.
207
+ //
208
+ // The offset and the spread are baked into the path rather than set as
209
+ // `shadowOffset`, which is what keeps a positive dy pointing down in this
210
+ // flipped view, and the mask cuts the box itself out so the shadow is only ever
211
+ // outside it -- a shadow painted under a translucent background would otherwise
212
+ // show through it, which CSS does not do.
213
+ // A `dropShadow()` beyond the first, as a layer of its own.
214
+ //
215
+ // A CALayer carries one shadow, so a list of them needs a layer each. The first
216
+ // stays on the view's own layer, where it is cast from the content's *alpha* --
217
+ // which is what makes a drop shadow a drop shadow. The rest are cast from the
218
+ // view's box through a `shadowPath`, which is the one deviation here and is
219
+ // exact for every opaque view: a rounded card with two shadows is the case this
220
+ // exists for. backlog/platform-macos.md records the difference, which shows
221
+ // only on a view whose content has holes in it.
222
+ //
223
+ // CSS applies a filter list left to right, each function working on what the
224
+ // one before produced, so the second shadow is a shadow of the content *and*
225
+ // the first shadow. These are drawn from the same box instead, which is the
226
+ // same approximation and the same reason.
227
+ static CALayer *RnAppKitFilterShadowLayer(const RnAppKitFilterShadow &shadow,
228
+ const CGFloat radii[8],
229
+ CGSize size) {
230
+ CALayer *layer = [CALayer layer];
231
+ layer.frame = CGRectMake(0, 0, size.width, size.height);
232
+ // The same conversions the view's own layer uses in -rnApplyFilterShadow:,
233
+ // so the second shadow of a pair is drawn by the same arithmetic as the
234
+ // first: the standard deviation crosses unchanged, the colour's alpha is the
235
+ // layer's opacity, and dy is turned over because the layer is not flipped
236
+ // where the view is.
237
+ layer.shadowRadius = shadow.standardDeviation;
238
+ layer.shadowOpacity = (float)shadow.color[3];
239
+ layer.shadowOffset = CGSizeMake(shadow.dx, -shadow.dy);
240
+
241
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
242
+ const CGFloat opaque[4] = {shadow.color[0], shadow.color[1], shadow.color[2], 1};
243
+ CGColorRef color = CGColorCreate(space, opaque);
244
+ layer.shadowColor = color;
245
+ CGColorRelease(color);
246
+ CGColorSpaceRelease(space);
247
+
248
+ CGPathRef shadowPath = RnAppKitCreateRoundedPath(layer.bounds, radii);
249
+ layer.shadowPath = shadowPath;
250
+ CGPathRelease(shadowPath);
251
+
252
+ // Everything outside the view's own box, the way the outset box shadows are
253
+ // masked: without it the shadow is drawn under the content as well, which
254
+ // darkens a translucent view from inside.
255
+ CAShapeLayer *mask = [CAShapeLayer layer];
256
+ mask.fillRule = kCAFillRuleEvenOdd;
257
+ CGMutablePathRef maskPath = CGPathCreateMutable();
258
+ CGPathRef viewPath = RnAppKitCreateRoundedPath(layer.bounds, radii);
259
+ const CGFloat reach = shadow.standardDeviation * 3 + std::abs(shadow.dx) + std::abs(shadow.dy);
260
+ CGPathAddRect(maskPath, nullptr, CGRectInset(layer.bounds, -reach - 1, -reach - 1));
261
+ CGPathAddPath(maskPath, nullptr, viewPath);
262
+ CGPathRelease(viewPath);
263
+ mask.path = maskPath;
264
+ CGPathRelease(maskPath);
265
+ layer.mask = mask;
266
+ return layer;
267
+ }
268
+
269
+ static CALayer *RnAppKitOutsetShadowLayer(const RnAppKitBoxShadow &shadow,
270
+ const CGFloat radii[8],
271
+ CGSize size) {
272
+ CALayer *layer = [CALayer layer];
273
+ layer.frame = CGRectMake(0, 0, size.width, size.height);
274
+ layer.shadowOffset = CGSizeZero;
275
+ layer.shadowOpacity = 1;
276
+ // Half the radius, which is the same conversion the image blur uses and the
277
+ // same one React Native's iOS half settled on: CSS's blur radius is twice the
278
+ // gaussian's sigma, and CALayer's shadowRadius is the sigma.
279
+ layer.shadowRadius = shadow.blur > 0 ? shadow.blur / 2 : 0;
280
+
281
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
282
+ CGColorRef color = CGColorCreate(space, shadow.color);
283
+ layer.shadowColor = color;
284
+ CGColorRelease(color);
285
+ CGColorSpaceRelease(space);
286
+
287
+ CGFloat spreadRadii[8];
288
+ RnAppKitSpreadRadii(radii, shadow.spread, spreadRadii);
289
+ CGRect box = CGRectInset(layer.bounds, -shadow.spread, -shadow.spread);
290
+ box = CGRectOffset(box, shadow.dx, shadow.dy);
291
+ CGPathRef shadowPath = RnAppKitCreateRoundedPath(box, spreadRadii);
292
+ layer.shadowPath = shadowPath;
293
+
294
+ // Everything outside the view's own box, as an even-odd pair: the box, and a
295
+ // rectangle big enough to hold the whole blur around it.
296
+ CAShapeLayer *mask = [CAShapeLayer layer];
297
+ mask.fillRule = kCAFillRuleEvenOdd;
298
+ CGMutablePathRef maskPath = CGPathCreateMutable();
299
+ CGPathRef viewPath = RnAppKitCreateRoundedPath(layer.bounds, radii);
300
+ CGPathAddPath(maskPath, NULL, viewPath);
301
+ const CGFloat room = 2 * (fabs(shadow.blur) + 1);
302
+ CGPathRef around = RnAppKitCreateRoundedPath(CGRectInset(box, -room, -room), spreadRadii);
303
+ CGPathAddPath(maskPath, NULL, around);
304
+ mask.path = maskPath;
305
+ layer.mask = mask;
306
+
307
+ CGPathRelease(maskPath);
308
+ CGPathRelease(viewPath);
309
+ CGPathRelease(around);
310
+ CGPathRelease(shadowPath);
311
+ return layer;
312
+ }
313
+
314
+ // A shadow cast inward from the box's own edge.
315
+ //
316
+ // Built from the hole it leaves: the shadow path is a wide rectangle with the
317
+ // clear region punched out of it, so the blur spills inward from the edge of
318
+ // that hole, and the mask keeps all of it inside the view. The clear region is
319
+ // the box moved by the offset and shrunk by the spread, which is what makes an
320
+ // inset shadow with no offset a ring and one with an offset a crescent.
321
+ static CALayer *RnAppKitInsetShadowLayer(const RnAppKitBoxShadow &shadow,
322
+ const CGFloat radii[8],
323
+ CGSize size) {
324
+ CALayer *layer = [CALayer layer];
325
+ layer.frame = CGRectMake(0, 0, size.width, size.height);
326
+ layer.shadowOffset = CGSizeZero;
327
+ layer.shadowOpacity = 1;
328
+ layer.shadowRadius = shadow.blur > 0 ? shadow.blur / 2 : 0;
329
+
330
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
331
+ CGColorRef color = CGColorCreate(space, shadow.color);
332
+ layer.shadowColor = color;
333
+ CGColorRelease(color);
334
+ CGColorSpaceRelease(space);
335
+
336
+ const CGFloat room = fabs(shadow.blur) + 1;
337
+ CGMutablePathRef path = CGPathCreateMutable();
338
+ // Square, because this one is only there to be the outside of the hole: its
339
+ // own corners are beyond the mask and never seen.
340
+ const CGFloat square[8] = {0, 0, 0, 0, 0, 0, 0, 0};
341
+ CGPathRef outer = RnAppKitCreateRoundedPath(CGRectInset(layer.bounds, -room, -room), square);
342
+ CGPathAddPath(path, NULL, outer);
343
+
344
+ CGFloat holeRadii[8];
345
+ RnAppKitSpreadRadii(radii, -shadow.spread, holeRadii);
346
+ CGRect hole = CGRectOffset(layer.bounds, shadow.dx, shadow.dy);
347
+ hole = CGRectInset(hole, shadow.spread, shadow.spread);
348
+ if (CGRectIsNull(hole) || hole.size.width < 0 || hole.size.height < 0) {
349
+ hole = CGRectZero;
350
+ }
351
+ CGPathRef holePath = RnAppKitCreateRoundedPath(hole, holeRadii);
352
+ CGPathRef reversedHole = RnAppKitCreateReversedPath(holePath);
353
+ CGPathAddPath(path, NULL, reversedHole);
354
+ layer.shadowPath = path;
355
+
356
+ CAShapeLayer *mask = [CAShapeLayer layer];
357
+ CGPathRef viewPath = RnAppKitCreateRoundedPath(layer.bounds, radii);
358
+ mask.path = viewPath;
359
+ layer.mask = mask;
360
+
361
+ CGPathRelease(path);
362
+ CGPathRelease(outer);
363
+ CGPathRelease(holePath);
364
+ CGPathRelease(reversedHole);
365
+ CGPathRelease(viewPath);
366
+ return layer;
367
+ }
368
+
159
369
  // Clips to the side of the line through `p` with direction `dir` that contains
160
370
  // `inside`. Two of these give an edge the sector it owns at a corner.
161
371
  //
@@ -204,6 +414,14 @@ static void RnAppKitClipToHalfPlane(CGContextRef context,
204
414
  @property(nonatomic) CGFloat rnVerticalLength;
205
415
  @property(nonatomic) CGFloat rnHorizontalOffset;
206
416
  @property(nonatomic) CGFloat rnHorizontalLength;
417
+ // `indicatorStyle`, as the colour core resolved it to. Black and translucent
418
+ // until something says otherwise.
419
+ @property(nonatomic) NSColor *rnThumbColour;
420
+ // The hairline around it, which `indicatorStyle: 'default'` has and the other
421
+ // two do not: iOS's default thumb is black with a white border, and that
422
+ // border is the whole difference between `default` and `black`. Nil, or a
423
+ // transparent colour, means none.
424
+ @property(nonatomic) NSColor *rnThumbBorderColour;
207
425
  @end
208
426
 
209
427
  @implementation RnAppKitScrollIndicatorView
@@ -228,6 +446,28 @@ static void RnAppKitClipToHalfPlane(CGContextRef context,
228
446
  CGPathRelease(path);
229
447
  }
230
448
 
449
+ // The hairline, stroked inside the pill it belongs to: a stroke straddles its
450
+ // path, so the rectangle is inset by half the width and the line lands on the
451
+ // thumb rather than half outside it.
452
+ - (void)rnStrokePill:(NSRect)bar inContext:(CGContextRef)context {
453
+ NSColor *const edge = _rnThumbBorderColour;
454
+ if (edge == nil || edge.alphaComponent <= 0) {
455
+ return;
456
+ }
457
+ const CGFloat width = basalt::kScrollIndicatorBorderWidth;
458
+ const NSRect inner = NSInsetRect(bar, width / 2.0, width / 2.0);
459
+ const CGFloat radius = MAX((CGFloat)0, basalt::kScrollIndicatorThickness / 2.0 - width / 2.0);
460
+ CGPathRef path = CGPathCreateWithRoundedRect(inner, radius, radius, NULL);
461
+ CGContextSaveGState(context);
462
+ CGContextSetRGBStrokeColor(context, edge.redComponent, edge.greenComponent, edge.blueComponent,
463
+ edge.alphaComponent);
464
+ CGContextSetLineWidth(context, width);
465
+ CGContextAddPath(context, path);
466
+ CGContextStrokePath(context);
467
+ CGContextRestoreGState(context);
468
+ CGPathRelease(path);
469
+ }
470
+
231
471
  - (void)drawRect:(NSRect)dirtyRect {
232
472
  (void)dirtyRect;
233
473
  if (_rnVerticalLength <= 0.0 && _rnHorizontalLength <= 0.0) {
@@ -237,23 +477,33 @@ static void RnAppKitClipToHalfPlane(CGContextRef context,
237
477
  const NSSize size = self.bounds.size;
238
478
  const CGFloat thickness = basalt::kScrollIndicatorThickness;
239
479
  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);
480
+ // Neutral and translucent, so it reads over light and dark content alike, and
481
+ // white instead when `indicatorStyle` asked for it. Core decides which, so
482
+ // the GTK and Win32 hosts answer the prop with the same colour.
483
+ NSColor *const thumb = _rnThumbColour != nil
484
+ ? _rnThumbColour
485
+ : [NSColor colorWithSRGBRed:0.0 green:0.0 blue:0.0 alpha:basalt::kScrollIndicatorAlpha];
486
+ CGContextSetRGBFillColor(context,
487
+ thumb.redComponent,
488
+ thumb.greenComponent,
489
+ thumb.blueComponent,
490
+ thumb.alphaComponent);
243
491
 
244
492
  if (_rnVerticalLength > 0.0) {
245
- [self rnFillPill:NSMakeRect(size.width - thickness - inset,
246
- _rnVerticalOffset,
247
- thickness,
248
- _rnVerticalLength)
249
- inContext:context];
493
+ const NSRect bar = NSMakeRect(size.width - thickness - inset,
494
+ _rnVerticalOffset,
495
+ thickness,
496
+ _rnVerticalLength);
497
+ [self rnFillPill:bar inContext:context];
498
+ [self rnStrokePill:bar inContext:context];
250
499
  }
251
500
  if (_rnHorizontalLength > 0.0) {
252
- [self rnFillPill:NSMakeRect(_rnHorizontalOffset,
253
- size.height - thickness - inset,
254
- _rnHorizontalLength,
255
- thickness)
256
- inContext:context];
501
+ const NSRect bar = NSMakeRect(_rnHorizontalOffset,
502
+ size.height - thickness - inset,
503
+ _rnHorizontalLength,
504
+ thickness);
505
+ [self rnFillPill:bar inContext:context];
506
+ [self rnStrokePill:bar inContext:context];
257
507
  }
258
508
  }
259
509
 
@@ -265,13 +515,34 @@ static void RnAppKitClipToHalfPlane(CGContextRef context,
265
515
  std::vector<float> _highlights;
266
516
  std::vector<bool> _highlightFilled;
267
517
  NSString *_roleName;
518
+ // The origin React Native gave this view, which is not where the view sits
519
+ // when its paragraph moved its text. See setRnFrameX:y:width:height:.
520
+ NSPoint _rnOrigin;
268
521
  RnTextLayout *_textLayout;
522
+ // `<Text selectable>`; the range itself lives on the layout, which is what
523
+ // draws it. See setRnTextSelectable.
524
+ BOOL _textSelectable;
269
525
  CGImageRef _image;
526
+ // An animated image's frames, their delays as the file carried them, and how
527
+ // many times round. See setRnImageFrames:delaysMs:loopCount:. `_image` is
528
+ // whichever frame is showing, so everything that draws stays unchanged.
529
+ NSArray *_imageFrames;
530
+ std::vector<unsigned> _imageDelays;
531
+ NSUInteger _imageLoopCount;
532
+ NSUInteger _imageFrame;
533
+ double _imageElapsedMs;
534
+ NSTimer *_imageTimer;
270
535
  NSColor *_imageTint;
271
536
  RnAppKitImageFit _imageFit;
537
+ CGFloat _imageBlur;
538
+ // The blurred copy of _image, at the device pixel size it was last drawn at,
539
+ // or NULL when there is none to reuse. See -rnBlurredImageForSize:scale:.
540
+ CGImageRef _imageBlurred;
272
541
  BOOL _hasBackgroundColor;
273
542
  CGFloat _backgroundComponents[4];
274
543
  CGFloat _opacity;
544
+ // The `opacity()` out of the filter list, 1 when there is none.
545
+ CGFloat _filterOpacity;
275
546
  BOOL _clipsChildren;
276
547
  CGFloat _cornerRadius;
277
548
  // Eight floats: a horizontal and a vertical radius per corner, in the order
@@ -285,6 +556,39 @@ static void RnAppKitClipToHalfPlane(CGContextRef context,
285
556
  // Four RGBA quadruples, in the same edge order.
286
557
  CGFloat _borderColors[16];
287
558
  BOOL _hasBorders;
559
+ // CSS's outline, and the layer it became. Zero width is none.
560
+ CGFloat _outlineWidth;
561
+ CGFloat _outlineOffset;
562
+ CGFloat _outlineColor[4];
563
+ RnAppKitBorderStyle _outlineStyle;
564
+ CAShapeLayer *_outlineLayer;
565
+ // The resolved `filter`, as the Core Image filters it became, or nil.
566
+ NSArray<CIFilter *> *_filters;
567
+ // `hitSlop`, top, right, bottom, left. Read only by the hit test.
568
+ CGFloat _hitSlop[4];
569
+ BOOL _hasHitSlop;
570
+ RnAppKitBorderStyle _borderStyle;
571
+ std::vector<RnAppKitBoxShadow> _boxShadows;
572
+ // The `backgroundImage` gradients, each with its own stops.
573
+ std::vector<RnAppKitGradientRecord> _gradients;
574
+ NSMutableArray<CALayer *> *_boxShadowLayers;
575
+ // The second and later `dropShadow()` entries, a layer each. See
576
+ // RnAppKitFilterShadowLayer.
577
+ NSMutableArray<CALayer *> *_filterShadowLayers;
578
+ std::vector<RnAppKitFilterShadow> _extraFilterShadows;
579
+ NSString *_cursorName;
580
+ // `mixBlendMode`: the keyword asked for, and the Core Image filter it became.
581
+ // The `dropShadow()` from `filter`, as the layer's own shadow.
582
+ RnAppKitFilterShadow _filterShadow;
583
+ NSString *_blendModeName;
584
+ NSString *_blendFilterName;
585
+ // The last text this view announced as a live region. See -rnAnnounce:.
586
+ NSString *_lastAnnouncement;
587
+ // The tags of the views in this one's labelled-by relation, for the tree dump.
588
+ std::vector<NSInteger> _labelledBy;
589
+ // The resolved `experimental_accessibilityOrder`, by tag, for the dump.
590
+ std::vector<NSInteger> _accessibilityOrder;
591
+ NSCursor *_cursor;
288
592
  CATransform3D _transform;
289
593
  BOOL _hasTransform;
290
594
  BOOL _hidesBackFace;
@@ -295,6 +599,13 @@ static void RnAppKitClipToHalfPlane(CGContextRef context,
295
599
  NSTrackingArea *_rnHoverTrackingArea;
296
600
  // Only a ScrollView with something to indicate has one.
297
601
  RnAppKitScrollIndicatorView *_indicators;
602
+ // `indicatorStyle`'s colour, held here because the overlay above is made and
603
+ // thrown away as the content grows past the viewport and shrinks back inside
604
+ // it. Nil until a prop says otherwise, which the drawing reads as the
605
+ // default.
606
+ NSColor *_rnIndicatorColour;
607
+ // The hairline around the thumb, which only `indicatorStyle: 'default'` has.
608
+ NSColor *_rnIndicatorBorderColour;
298
609
  }
299
610
 
300
611
  + (instancetype)viewWithTag:(NSInteger)tag {
@@ -305,12 +616,28 @@ static void RnAppKitClipToHalfPlane(CGContextRef context,
305
616
 
306
617
  - (void)dealloc {
307
618
  CGImageRelease(_image);
619
+ CGImageRelease(_imageBlurred);
620
+ // A scheduled timer holds its target; this one holds `self` weakly so a view
621
+ // released mid-animation is not kept alive by it, and the timer still has to
622
+ // go or it fires into nothing once a tenth of a second.
623
+ [_imageTimer invalidate];
624
+ // An outset shadow's layer lives in the superlayer, so it outlives this view
625
+ // unless it is taken out. Ordinarily -viewDidMoveToSuperview has already done
626
+ // it; this is for a view released without being unmounted first.
627
+ for (CALayer *layer in _boxShadowLayers) {
628
+ [layer removeFromSuperlayer];
629
+ }
630
+ // The outline can be in the parent's layer for the same reason, so it has to
631
+ // come out too.
632
+ [_outlineLayer removeFromSuperlayer];
308
633
  }
309
634
 
310
635
  - (instancetype)initWithFrame:(NSRect)frame {
311
636
  self = [super initWithFrame:frame];
312
637
  if (self != nil) {
638
+ _rnOrigin = frame.origin;
313
639
  _opacity = 1.0;
640
+ _filterOpacity = 1.0;
314
641
  _imageFit = RnAppKitImageFitCover;
315
642
  _transform = CATransform3DIdentity;
316
643
  // Layer-backed from the start rather than on demand: a view that acquires a
@@ -330,7 +657,15 @@ static void RnAppKitClipToHalfPlane(CGContextRef context,
330
657
  }
331
658
 
332
659
  - (void)setRnFrameX:(CGFloat)x y:(CGFloat)y width:(CGFloat)width height:(CGFloat)height {
333
- self.frame = NSMakeRect(x, y, width, height);
660
+ // What React Native decided, kept because the view may not sit there: a view
661
+ // inline in a paragraph is moved by whatever `textAlignVertical` did to the
662
+ // text, and when that changes it has to be placed again from this rather
663
+ // than from where it currently is. See rnPlaceInlineChildren.
664
+ _rnOrigin = NSMakePoint(x, y);
665
+ self.frame = NSMakeRect(x, y + [self rnParentTextOffset], width, height);
666
+ // A box that changed height changes where a bottom- or middle-aligned
667
+ // paragraph sits, and therefore where the views inline in it sit.
668
+ [self rnPlaceInlineChildren];
334
669
  // A mask layer is built against the bounds, so a resize invalidates it. The
335
670
  // uniform case is a plain cornerRadius and needs nothing.
336
671
  if (self.layer.mask != nil) {
@@ -340,6 +675,16 @@ static void RnAppKitClipToHalfPlane(CGContextRef context,
340
675
  if (_hasBorders) {
341
676
  self.needsDisplay = YES;
342
677
  }
678
+ // And a box shadow's path is in the view's own coordinates, so every one of
679
+ // them has to be rebuilt: a card that grew would otherwise keep the shadow of
680
+ // the size it used to be. The outline is in the same position.
681
+ if (!_boxShadows.empty()) {
682
+ [self rnRebuildBoxShadowLayers];
683
+ [self rnRebuildFilterShadowLayers];
684
+ }
685
+ if (_outlineWidth > 0) {
686
+ [self rnRebuildOutlineLayer];
687
+ }
343
688
  }
344
689
 
345
690
  - (void)setRnBackgroundColorRed:(CGFloat)red
@@ -431,11 +776,43 @@ static NSAccessibilityRole RnAccessibilityRoleFor(NSString *name) {
431
776
  return role != nil ? role : NSAccessibilityGroupRole;
432
777
  }
433
778
 
779
+ // The subrole for a React Native role, or nil.
780
+ //
781
+ // macOS says some things with a role and the rest with a *subrole*, and four of
782
+ // React Native's roles are in the second group: the role above reports a search
783
+ // field as a text field, a switch and a toggle button as check boxes, and a tab
784
+ // as a radio button, each of which loses the part that says what it is. GTK
785
+ // needs none of this -- `GTK_ACCESSIBLE_ROLE_SEARCH_BOX`, `_SWITCH` and `_TAB`
786
+ // are roles there -- which is why this was a macOS entry.
787
+ //
788
+ // Nothing else gets one. A subrole that is not one of AppKit's own tells
789
+ // VoiceOver less than no subrole, and a view with no subrole is read by its
790
+ // role, which is already the nearest thing.
791
+ static NSAccessibilitySubrole RnAccessibilitySubroleFor(NSString *name) {
792
+ static NSDictionary<NSString *, NSAccessibilitySubrole> *subroles = nil;
793
+ static dispatch_once_t once;
794
+ dispatch_once(&once, ^{
795
+ subroles = @{
796
+ @"search" : NSAccessibilitySearchFieldSubrole,
797
+ // A switch and a toggle button are both check boxes by role, and these
798
+ // are what tell them apart: "switch" and "toggle".
799
+ @"switch" : NSAccessibilitySwitchSubrole,
800
+ @"togglebutton" : NSAccessibilityToggleSubrole,
801
+ // A tab is a radio button by role, which reads oddly on its own.
802
+ @"tab" : NSAccessibilityTabButtonSubrole,
803
+ };
804
+ });
805
+ return subroles[name];
806
+ }
807
+
434
808
  - (void)setRnAccessibleRole:(NSString *)role {
435
809
  _roleName = role.length > 0 ? [role copy] : nil;
436
810
 
437
811
  if (_roleName == nil) {
438
812
  self.accessibilityRole = NSAccessibilityGroupRole;
813
+ // Cleared as well as set: a view whose role prop goes away must not keep
814
+ // the subrole its last role carried.
815
+ self.accessibilitySubrole = nil;
439
816
  // A plain <View> is scenery. Leaving every one of them in the tree would
440
817
  // bury the handful that mean something under hundreds that do not.
441
818
  self.accessibilityElement = NO;
@@ -444,9 +821,207 @@ static NSAccessibilityRole RnAccessibilityRoleFor(NSString *name) {
444
821
 
445
822
  const NSAccessibilityRole mapped = RnAccessibilityRoleFor(_roleName);
446
823
  self.accessibilityRole = mapped;
824
+ self.accessibilitySubrole = RnAccessibilitySubroleFor(_roleName);
447
825
  self.accessibilityElement = mapped != NSAccessibilityUnknownRole;
448
826
  }
449
827
 
828
+ // The text of one view and everything under it, appended to `out`.
829
+ //
830
+ // Paragraphs only. A label can stand in for text, and the mounting manager
831
+ // prefers one before asking for this, as the GTK host does -- which keeps the
832
+ // rule in one place per host rather than in the view layer, where the props are
833
+ // not.
834
+ static void RnAppKitCollectText(RnAppKitView *view, NSMutableString *out) {
835
+ NSString *own = view.rnOwnText;
836
+ if (own.length > 0) {
837
+ if (out.length > 0) {
838
+ [out appendString:@" "];
839
+ }
840
+ [out appendString:own];
841
+ }
842
+ for (RnAppKitView *child in [view rnChildrenInPaintOrder]) {
843
+ RnAppKitCollectText(child, out);
844
+ }
845
+ }
846
+
847
+ - (NSString *)rnOwnText {
848
+ return _textLayout.attributedString.string;
849
+ }
850
+
851
+ - (NSString *)rnCollectedText {
852
+ NSMutableString *out = [NSMutableString string];
853
+ RnAppKitCollectText(self, out);
854
+ return out;
855
+ }
856
+
857
+ - (void)rnAnnounce:(NSString *)text assertive:(BOOL)assertive {
858
+ if (text.length == 0) {
859
+ return;
860
+ }
861
+ _lastAnnouncement = [text copy];
862
+ // Posted against the application rather than this view: an announcement is not
863
+ // about an element, and AppKit documents the app as the element to post it on.
864
+ NSAccessibilityPostNotificationWithUserInfo(
865
+ NSApp,
866
+ NSAccessibilityAnnouncementRequestedNotification,
867
+ @{
868
+ NSAccessibilityAnnouncementKey : text,
869
+ NSAccessibilityPriorityKey : @(assertive ? NSAccessibilityPriorityHigh
870
+ : NSAccessibilityPriorityMedium),
871
+ });
872
+ }
873
+
874
+ - (NSString *)rnLastAnnouncement {
875
+ return _lastAnnouncement;
876
+ }
877
+
878
+ // --- What a screen reader reads out of a paragraph ---------------------------
879
+ //
880
+ // A `<Text>` here is an `RnTextLayout` drawn into a view, not an NSTextField,
881
+ // so VoiceOver saw an element with a label and nothing to move through: no
882
+ // caret, no lines, and no sign of a selection the person had just dragged out
883
+ // with the mouse. backlog/text.md's selection entry records that half as the
884
+ // one left after the selecting worked, and these are the AppKit side of it.
885
+ //
886
+ // Every index is a UTF-16 code unit, which is what `NSAttributedString`
887
+ // counts, what `RnTextLayout.selection` already holds, and what NSAccessibility
888
+ // asks in -- so unlike the GTK half, which converts bytes to characters,
889
+ // nothing here converts. The ranges are clamped rather than trusted:
890
+ // VoiceOver asks about ranges a paragraph has shrunk out of.
891
+ //
892
+ // `accessibilityValue` is deliberately left alone. React Native models it as a
893
+ // range for a slider and as the checked state for a checkbox, both of which
894
+ // -applyAccessibility already puts there; a paragraph's string is its
895
+ // accessibilityLabel, which is set from the same place.
896
+ - (NSString *)rnAccessibilityText {
897
+ NSString *text = _textLayout.attributedString.string;
898
+ return text != nil ? text : @"";
899
+ }
900
+
901
+ - (NSRange)rnClampedRange:(NSRange)range {
902
+ const NSUInteger length = [self rnAccessibilityText].length;
903
+ if (range.location >= length) {
904
+ return NSMakeRange(length, 0);
905
+ }
906
+ return NSMakeRange(range.location, MIN(range.length, length - range.location));
907
+ }
908
+
909
+ - (NSInteger)accessibilityNumberOfCharacters {
910
+ return (NSInteger)[self rnAccessibilityText].length;
911
+ }
912
+
913
+ - (NSRange)accessibilityVisibleCharacterRange {
914
+ // All of it: a paragraph here is drawn whole or clipped by `numberOfLines`,
915
+ // and the clipped case already reports the shown lines through
916
+ // -lineRangesForWidth:, which is what the line queries below use.
917
+ return NSMakeRange(0, [self rnAccessibilityText].length);
918
+ }
919
+
920
+ - (NSString *)accessibilityStringForRange:(NSRange)range {
921
+ const NSRange wanted = [self rnClampedRange:range];
922
+ if (wanted.length == 0) {
923
+ return @"";
924
+ }
925
+ return [[self rnAccessibilityText] substringWithRange:wanted];
926
+ }
927
+
928
+ - (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)range {
929
+ const NSRange wanted = [self rnClampedRange:range];
930
+ if (_textLayout == nil || wanted.length == 0) {
931
+ return [[NSAttributedString alloc] initWithString:@""];
932
+ }
933
+ // The real attributed string, so the font and colour of each run reach
934
+ // VoiceOver rather than a plain copy of the characters.
935
+ return [_textLayout.attributedString attributedSubstringFromRange:wanted];
936
+ }
937
+
938
+ - (NSString *)accessibilitySelectedText {
939
+ NSString *selected = [_textLayout selectedText];
940
+ return selected != nil ? selected : @"";
941
+ }
942
+
943
+ - (NSRange)accessibilitySelectedTextRange {
944
+ if (_textLayout == nil) {
945
+ return NSMakeRange(0, 0);
946
+ }
947
+ return [self rnClampedRange:_textLayout.selection];
948
+ }
949
+
950
+ - (NSArray<NSValue *> *)accessibilitySelectedTextRanges {
951
+ return @[ [NSValue valueWithRange:[self accessibilitySelectedTextRange]] ];
952
+ }
953
+
954
+ // The lines are Core Text's own, at the width this view is, so they are the
955
+ // ones on the screen rather than a second guess at where the text wrapped.
956
+ - (NSArray<NSValue *> *)rnAccessibilityLineRanges {
957
+ if (_textLayout == nil) {
958
+ return @[];
959
+ }
960
+ return [_textLayout lineRangesForWidth:self.bounds.size.width];
961
+ }
962
+
963
+ - (NSInteger)accessibilityLineForIndex:(NSInteger)index {
964
+ NSArray<NSValue *> *lines = [self rnAccessibilityLineRanges];
965
+ for (NSUInteger line = 0; line < lines.count; line++) {
966
+ const NSRange range = lines[line].rangeValue;
967
+ if ((NSUInteger)index < NSMaxRange(range)) {
968
+ return (NSInteger)line;
969
+ }
970
+ }
971
+ // Past the end, which is where a caret after the last character sits.
972
+ return lines.count > 0 ? (NSInteger)lines.count - 1 : 0;
973
+ }
974
+
975
+ - (NSRange)accessibilityRangeForLine:(NSInteger)line {
976
+ NSArray<NSValue *> *lines = [self rnAccessibilityLineRanges];
977
+ if (line < 0 || (NSUInteger)line >= lines.count) {
978
+ return NSMakeRange(NSNotFound, 0);
979
+ }
980
+ return lines[(NSUInteger)line].rangeValue;
981
+ }
982
+
983
+ - (NSRange)accessibilityRangeForIndex:(NSInteger)index {
984
+ const NSUInteger length = [self rnAccessibilityText].length;
985
+ if (index < 0 || (NSUInteger)index >= length) {
986
+ return NSMakeRange(NSNotFound, 0);
987
+ }
988
+ // One composed character, so an emoji or a flag is one step rather than two
989
+ // halves of a surrogate pair.
990
+ return [[self rnAccessibilityText] rangeOfComposedCharacterSequenceAtIndex:(NSUInteger)index];
991
+ }
992
+
993
+ - (void)setRnLabelledBy:(NSArray<RnAppKitView *> *)labels {
994
+ _labelledBy.clear();
995
+ for (RnAppKitView *label in labels) {
996
+ _labelledBy.push_back(label.rnTag);
997
+ }
998
+ // The first, AppKit's property being one element rather than a list. Nil when
999
+ // there is none, which is how an element says it has no such relation.
1000
+ self.accessibilityTitleUIElement = labels.firstObject;
1001
+ // And a view named by another is worth announcing: the label is on the other
1002
+ // view, so without this a field whose only name comes from its caption stays
1003
+ // out of the tree.
1004
+ if (labels.count > 0) {
1005
+ self.accessibilityElement = YES;
1006
+ }
1007
+ }
1008
+
1009
+ - (void)setRnAccessibilityOrder:(NSArray<RnAppKitView *> *)children {
1010
+ _accessibilityOrder.clear();
1011
+ for (RnAppKitView *child in children) {
1012
+ _accessibilityOrder.push_back(child.rnTag);
1013
+ }
1014
+ // `accessibilityChildren` replaces the children AppKit would have found by
1015
+ // walking the view hierarchy, which is exactly what the prop asks for: a
1016
+ // reading order that is not the mount order. Nil puts the hierarchy back,
1017
+ // which is what a view that stopped asking means.
1018
+ //
1019
+ // GTK says the same thing as a relation -- `aria-flowto` from this view to
1020
+ // each child -- rather than by replacing the children, because GTK's
1021
+ // accessible tree follows the widget tree and has no equivalent override.
1022
+ self.accessibilityChildren = children.count > 0 ? children : nil;
1023
+ }
1024
+
450
1025
  - (void)setRnAccessibleLabel:(NSString *)label hint:(NSString *)hint {
451
1026
  self.accessibilityLabel = label.length > 0 ? label : nil;
452
1027
  // `accessibilityHelp`, not `accessibilityValue`: React Native's hint is the
@@ -460,6 +1035,45 @@ static NSAccessibilityRole RnAccessibilityRoleFor(NSString *name) {
460
1035
  }
461
1036
  }
462
1037
 
1038
+ // `accessibilityValue`, which React Native models as a range (min, max, now) and
1039
+ // a text form that a screen reader prefers over the number.
1040
+ //
1041
+ // AppKit has `accessibilityMinValue` and `accessibilityMaxValue` for the range,
1042
+ // and one `accessibilityValue` that carries either the number or the text.
1043
+ // Setting nil is how an element says it has no such value, which matters because
1044
+ // an absent part must stay absent: a view that never said what its range is must
1045
+ // not be announced as sitting at the bottom of one.
1046
+ //
1047
+ // **This shares `accessibilityValue` with the checked state**, which AppKit
1048
+ // expresses as an element's value the way a checkbox does. An explicit
1049
+ // accessibilityValue wins, the app having said it outright, and the order below
1050
+ // is what decides that: state is applied first and this second. Both reaching
1051
+ // for the same property is an AppKit fact rather than a choice here.
1052
+ - (void)setRnAccessibleValueMin:(NSNumber *)min
1053
+ max:(NSNumber *)max
1054
+ now:(NSNumber *)now
1055
+ text:(NSString *)text {
1056
+ self.accessibilityMinValue = min;
1057
+ self.accessibilityMaxValue = max;
1058
+
1059
+ // The text form first: "Tuesday" reads better than "3", which is the whole
1060
+ // reason React Native carries both.
1061
+ if (text.length > 0) {
1062
+ self.accessibilityValue = text;
1063
+ } else if (now != nil) {
1064
+ self.accessibilityValue = now;
1065
+ } else if (min != nil || max != nil) {
1066
+ // A range with no position in it. Leaving a stale value here would be worse
1067
+ // than leaving none.
1068
+ self.accessibilityValue = nil;
1069
+ }
1070
+
1071
+ if (min != nil || max != nil || now != nil || text.length > 0) {
1072
+ // Worth announcing, the same reason a label alone makes a view an element.
1073
+ self.accessibilityElement = YES;
1074
+ }
1075
+ }
1076
+
463
1077
  - (void)setRnAccessibleStateDisabled:(RnAppKitAccessibleFlag)disabled
464
1078
  checked:(RnAppKitAccessibleFlag)checked
465
1079
  selected:(RnAppKitAccessibleFlag)selected
@@ -487,6 +1101,26 @@ static NSAccessibilityRole RnAccessibilityRoleFor(NSString *name) {
487
1101
  (void)busy;
488
1102
  }
489
1103
 
1104
+ - (void)setRnTestId:(NSString *)testId {
1105
+ if (_rnTestId == testId || [_rnTestId isEqualToString:testId]) {
1106
+ return;
1107
+ }
1108
+ _rnTestId = [testId copy];
1109
+ // The platform half, and the reason this is a setter rather than a plain
1110
+ // property: `accessibilityIdentifier` is what XCTest and the Accessibility
1111
+ // Inspector look a view up by, and it is the nearest thing macOS has to
1112
+ // UIA's automation id. An empty `testID` sets nil rather than an empty
1113
+ // string, which is what AppKit means by "no identifier".
1114
+ self.accessibilityIdentifier = _rnTestId.length > 0 ? _rnTestId : nil;
1115
+ }
1116
+
1117
+ - (void)setRnAccessibleModal:(BOOL)modal {
1118
+ _rnAccessibleModal = modal;
1119
+ // AppKit's own flag, which VoiceOver reads: inside a modal element it stops
1120
+ // offering the siblings behind it.
1121
+ self.accessibilityModal = modal;
1122
+ }
1123
+
490
1124
  - (void)setRnAccessibleHidden:(BOOL)hidden {
491
1125
  if (hidden) {
492
1126
  self.accessibilityElement = NO;
@@ -523,6 +1157,8 @@ static NSAccessibilityRole RnAccessibilityRoleFor(NSString *name) {
523
1157
 
524
1158
  if (_indicators == nil) {
525
1159
  _indicators = [[RnAppKitScrollIndicatorView alloc] initWithFrame:self.bounds];
1160
+ _indicators.rnThumbColour = _rnIndicatorColour;
1161
+ _indicators.rnThumbBorderColour = _rnIndicatorBorderColour;
526
1162
  [self addSubview:_indicators positioned:NSWindowAbove relativeTo:nil];
527
1163
  }
528
1164
 
@@ -544,6 +1180,42 @@ static NSAccessibilityRole RnAccessibilityRoleFor(NSString *name) {
544
1180
  _indicators.needsDisplay = YES;
545
1181
  }
546
1182
 
1183
+ - (void)setRnScrollIndicatorColourRed:(CGFloat)red
1184
+ green:(CGFloat)green
1185
+ blue:(CGFloat)blue
1186
+ alpha:(CGFloat)alpha {
1187
+ // Held on this view rather than only on the overlay, because the overlay is
1188
+ // made and thrown away as the content grows past the viewport and shrinks
1189
+ // back inside it -- a colour kept only there would be lost each time.
1190
+ _rnIndicatorColour = [NSColor colorWithSRGBRed:red green:green blue:blue alpha:alpha];
1191
+ if (_indicators != nil && ![_indicators.rnThumbColour isEqual:_rnIndicatorColour]) {
1192
+ _indicators.rnThumbColour = _rnIndicatorColour;
1193
+ _indicators.needsDisplay = YES;
1194
+ }
1195
+ }
1196
+
1197
+ - (NSColor *)rnScrollIndicatorColour {
1198
+ return _rnIndicatorColour;
1199
+ }
1200
+
1201
+ - (void)setRnScrollIndicatorBorderColourRed:(CGFloat)red
1202
+ green:(CGFloat)green
1203
+ blue:(CGFloat)blue
1204
+ alpha:(CGFloat)alpha {
1205
+ // Held here for the reason the fill is: the overlay is made and thrown away
1206
+ // as the content grows past the viewport and shrinks back inside it, so a
1207
+ // colour kept only there would be lost each time.
1208
+ _rnIndicatorBorderColour = [NSColor colorWithSRGBRed:red green:green blue:blue alpha:alpha];
1209
+ if (_indicators != nil && ![_indicators.rnThumbBorderColour isEqual:_rnIndicatorBorderColour]) {
1210
+ _indicators.rnThumbBorderColour = _rnIndicatorBorderColour;
1211
+ _indicators.needsDisplay = YES;
1212
+ }
1213
+ }
1214
+
1215
+ - (NSColor *)rnScrollIndicatorBorderColour {
1216
+ return _rnIndicatorBorderColour;
1217
+ }
1218
+
547
1219
  static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
548
1220
  switch (fit) {
549
1221
  case RnAppKitImageFitContain:
@@ -568,7 +1240,54 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
568
1240
  [self setNeedsDisplay:YES];
569
1241
  }
570
1242
 
1243
+ - (void)setRnImageBlur:(CGFloat)radius {
1244
+ const CGFloat wanted = radius > 0 ? radius : 0;
1245
+ if (_imageBlur == wanted) {
1246
+ return;
1247
+ }
1248
+ _imageBlur = wanted;
1249
+ CGImageRelease(_imageBlurred);
1250
+ _imageBlurred = nullptr;
1251
+ [self setNeedsDisplay:YES];
1252
+ }
1253
+
1254
+ // The blurred copy to draw in place of the image, or NULL when there is no blur
1255
+ // or Core Image would not produce one.
1256
+ //
1257
+ // Kept on the view because `drawRect:` runs on every frame of a live resize and
1258
+ // a Core Image round trip per frame is not free, where the GTK side's blur is a
1259
+ // render node the GPU applies for nothing. The cache is keyed by the pixel size
1260
+ // it was made at, and thrown away whenever the image or the radius changes.
1261
+ - (CGImageRef)rnBlurredImageForSize:(CGSize)size scale:(CGFloat)scale {
1262
+ if (_image == nullptr || _imageBlur <= 0) {
1263
+ return nullptr;
1264
+ }
1265
+ const size_t width = (size_t)lround(size.width * scale);
1266
+ const size_t height = (size_t)lround(size.height * scale);
1267
+ if (width == 0 || height == 0) {
1268
+ return nullptr;
1269
+ }
1270
+ if (_imageBlurred != nullptr && CGImageGetWidth(_imageBlurred) == width &&
1271
+ CGImageGetHeight(_imageBlurred) == height) {
1272
+ return _imageBlurred;
1273
+ }
1274
+ CGImageRelease(_imageBlurred);
1275
+ _imageBlurred = RnBlurredImageCreate(_image, width, height, _imageBlur * scale / 2.0);
1276
+ return _imageBlurred;
1277
+ }
1278
+
571
1279
  - (void)setRnImage:(CGImageRef)image fit:(RnAppKitImageFit)fit {
1280
+ // An animation owns `_image`, and the mounting manager re-applies the first
1281
+ // frame on every mutation that touched this view -- a layout-only one
1282
+ // included. Taking it would restart the GIF on every resize, so what is
1283
+ // taken here is the fit alone.
1284
+ if (_imageFrames.count > 1 && image == (__bridge CGImageRef)_imageFrames.firstObject) {
1285
+ if (_imageFit != fit) {
1286
+ _imageFit = fit;
1287
+ self.needsDisplay = YES;
1288
+ }
1289
+ return;
1290
+ }
572
1291
  if (_image == image && _imageFit == fit) {
573
1292
  return;
574
1293
  }
@@ -577,10 +1296,176 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
577
1296
  CGImageRef previous = _image;
578
1297
  _image = image != nullptr ? CGImageRetain(image) : nullptr;
579
1298
  CGImageRelease(previous);
1299
+ CGImageRelease(_imageBlurred);
1300
+ _imageBlurred = nullptr;
580
1301
  _imageFit = fit;
581
1302
  self.needsDisplay = YES;
582
1303
  }
583
1304
 
1305
+ // The frames of an animated image. See the header.
1306
+ - (void)setRnImageFrames:(NSArray *)frames
1307
+ delaysMs:(NSArray<NSNumber *> *)delaysMs
1308
+ loopCount:(NSUInteger)loopCount {
1309
+ // The same animation again, which is what a layout-only mutation produces:
1310
+ // the loader hands back the arrays it cached, so this is a pointer compare,
1311
+ // and the animation keeps its place rather than starting over on every
1312
+ // resize.
1313
+ if (_imageFrames == frames && _imageLoopCount == loopCount) {
1314
+ return;
1315
+ }
1316
+
1317
+ [_imageTimer invalidate];
1318
+ _imageTimer = nil;
1319
+ _imageFrames = frames.count > 1 ? frames : nil;
1320
+ _imageLoopCount = _imageFrames != nil ? loopCount : 0;
1321
+ _imageFrame = 0;
1322
+ _imageElapsedMs = 0.0;
1323
+ _imageDelays.clear();
1324
+ if (_imageFrames == nil) {
1325
+ return;
1326
+ }
1327
+ for (NSNumber *delay in delaysMs) {
1328
+ _imageDelays.push_back(delay.unsignedIntValue);
1329
+ }
1330
+
1331
+ [self rnShowImageFrame:0];
1332
+ }
1333
+
1334
+ - (void)rnStartImageAnimation {
1335
+ if (_imageFrames.count < 2 || _imageTimer != nil) {
1336
+ return;
1337
+ }
1338
+ [self rnScheduleImageFrameIn:[self rnAdvanceImageAnimationBy:0.0]];
1339
+ }
1340
+
1341
+ - (NSUInteger)rnImageFrameIndex {
1342
+ return _imageFrame;
1343
+ }
1344
+
1345
+ - (double)rnAdvanceImageAnimationBy:(double)milliseconds {
1346
+ if (_imageFrames.count < 2 || _imageDelays.size() != _imageFrames.count) {
1347
+ return 0.0;
1348
+ }
1349
+ if (milliseconds > 0.0) {
1350
+ _imageElapsedMs += milliseconds;
1351
+ }
1352
+
1353
+ const basalt::ImageAnimationStep step =
1354
+ basalt::imageAnimationStep(_imageDelays, static_cast<unsigned>(_imageLoopCount),
1355
+ static_cast<std::uint64_t>(_imageElapsedMs));
1356
+ if (step.frame != _imageFrame) {
1357
+ [self rnShowImageFrame:step.frame];
1358
+ }
1359
+ return step.nextInMs;
1360
+ }
1361
+
1362
+ // Puts one frame in `_image`, so that everything which draws an image draws
1363
+ // this one and nothing else in the view has to know about animation.
1364
+ - (void)rnShowImageFrame:(NSUInteger)index {
1365
+ if (index >= _imageFrames.count) {
1366
+ return;
1367
+ }
1368
+ _imageFrame = index;
1369
+
1370
+ CGImageRef previous = _image;
1371
+ _image = CGImageRetain((__bridge CGImageRef)_imageFrames[index]);
1372
+ CGImageRelease(previous);
1373
+ // The blur is of a frame, so it goes with the frame.
1374
+ CGImageRelease(_imageBlurred);
1375
+ _imageBlurred = nullptr;
1376
+ self.needsDisplay = YES;
1377
+ }
1378
+
1379
+ - (void)rnScheduleImageFrameIn:(double)milliseconds {
1380
+ [_imageTimer invalidate];
1381
+ _imageTimer = nil;
1382
+ if (milliseconds <= 0.0) {
1383
+ return;
1384
+ }
1385
+
1386
+ // An NSTimer rather than a CADisplayLink, which is what the scrolling uses.
1387
+ // A frame is due at a time the file names, so there is nothing to interpolate
1388
+ // and nothing to do on the frames in between; a display link would wake this
1389
+ // view sixty times a second to show the same picture.
1390
+ //
1391
+ // Weakly, so a view released mid-animation is not kept alive by its own
1392
+ // timer.
1393
+ __weak RnAppKitView *weakSelf = self;
1394
+ _imageTimer = [NSTimer scheduledTimerWithTimeInterval:milliseconds / 1000.0
1395
+ repeats:NO
1396
+ block:^(NSTimer *timer) {
1397
+ RnAppKitView *view = weakSelf;
1398
+ if (view == nil) {
1399
+ [timer invalidate];
1400
+ return;
1401
+ }
1402
+ // By the delay asked for
1403
+ // rather than by the wall
1404
+ // clock: a late timer makes
1405
+ // the animation run slow
1406
+ // instead of skipping
1407
+ // frames, which is the
1408
+ // kinder failure for a
1409
+ // four-frame spinner.
1410
+ const double next =
1411
+ [view rnAdvanceImageAnimationBy:milliseconds];
1412
+ [view rnScheduleImageFrameIn:next];
1413
+ }];
1414
+ }
1415
+
1416
+ // `image`, blurred, at `width` by `height` device pixels.
1417
+ //
1418
+ // Blurred at the size it will be drawn rather than in its own pixels, so that
1419
+ // the radius is a distance on screen. Blurring in the source's pixels instead
1420
+ // would make one prop almost invisible on a large photograph and overwhelming
1421
+ // on an icon, and would not agree with the GTK side, which pushes a GSK blur
1422
+ // node in widget coordinates.
1423
+ //
1424
+ // Sigma is half the radius, which is measured rather than chosen. A hard edge
1425
+ // blurred by `gtk_snapshot_push_blur` comes out at sigma = 0.47 * radius under
1426
+ // GTK's GL renderer, measured off a downloaded texture; React Native's own iOS
1427
+ // path blurs with three box convolutions of
1428
+ // `floor((radius * scale * 3 * sqrt(2 * pi) / 4 + 0.5) / 2) | 1`, whose variance
1429
+ // works out to the same 0.47. Half the radius is what both of those agree on,
1430
+ // within the few percent between a true gaussian and three boxes, and it is what
1431
+ // the test here measures on the same kind of edge.
1432
+ static CGImageRef RnBlurredImageCreate(CGImageRef image,
1433
+ size_t width,
1434
+ size_t height,
1435
+ CGFloat sigma) {
1436
+ CIImage *source = [CIImage imageWithCGImage:image];
1437
+ const CGRect extent = source.extent;
1438
+ if (extent.size.width <= 0 || extent.size.height <= 0) {
1439
+ return nullptr;
1440
+ }
1441
+ CIImage *scaled = [source
1442
+ imageByApplyingTransform:CGAffineTransformMakeScale((CGFloat)width / extent.size.width,
1443
+ (CGFloat)height / extent.size.height)];
1444
+ // Clamped before the blur and cropped after it. Without the clamp the edges
1445
+ // blur out into transparency, so a `cover` photograph would gain a soft frame
1446
+ // it has on no other platform: iOS extends its edge pixels instead
1447
+ // (kvImageEdgeExtend) and GSK blurs inside the clip.
1448
+ CIImage *blurred =
1449
+ [[scaled imageByClampingToExtent] imageByApplyingGaussianBlurWithSigma:sigma];
1450
+ const CGRect box = CGRectMake(0, 0, (CGFloat)width, (CGFloat)height);
1451
+
1452
+ // One context for the process: building a CIContext is the expensive part of
1453
+ // Core Image, not running the filter.
1454
+ //
1455
+ // Colour management off, which is the difference between this looking like the
1456
+ // other two platforms and not. Core Image converts to linear light by default
1457
+ // and blurs there; GSK blurs the encoded pixels, and React Native's iOS path
1458
+ // runs vImageBoxConvolve over 8-bit sRGB bytes. Measured on a black-to-white
1459
+ // edge, the managed blur spreads about 1.2 times as far as either. Unmanaged,
1460
+ // the filter works on the sample values as given, like both of them.
1461
+ static CIContext *renderer = nil;
1462
+ static dispatch_once_t once;
1463
+ dispatch_once(&once, ^{
1464
+ renderer = [CIContext contextWithOptions:@{kCIContextWorkingColorSpace : (id)[NSNull null]}];
1465
+ });
1466
+ return [renderer createCGImage:blurred fromRect:box];
1467
+ }
1468
+
584
1469
  // Where the image lands inside the frame.
585
1470
  //
586
1471
  // The same arithmetic as the GTK side's, deliberately -- two platforms
@@ -622,11 +1507,131 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
622
1507
  }
623
1508
 
624
1509
  - (void)setRnTextLayout:(id)layout {
625
- _textLayout = (RnTextLayout *)layout;
1510
+ // A selection survives a rebuild of the same text and not a change to it.
1511
+ // Every mutation that touches a paragraph builds a new layout here -- a
1512
+ // parent re-rendering is enough -- and dropping the selection each time would
1513
+ // make it impossible to keep one in a live app; keeping it across *different*
1514
+ // text would highlight whatever now sits at those offsets. The GTK side draws
1515
+ // the same line in rn_view_set_text_layout.
1516
+ RnTextLayout *replacement = (RnTextLayout *)layout;
1517
+ const BOOL sameText = _textLayout != nil && replacement != nil &&
1518
+ [_textLayout.attributedString.string isEqualToString:replacement.attributedString.string];
1519
+ const NSRange carried = sameText ? _textLayout.selection : NSMakeRange(0, 0);
1520
+
1521
+ _textLayout = replacement;
1522
+ if (_textLayout != nil) {
1523
+ _textLayout.selection = carried;
1524
+ }
626
1525
  // A layer-backed view with a drawRect: gets its contents from that draw, and
627
1526
  // only redraws when told. Without this the first paragraph appears and no
628
1527
  // later one ever does.
629
1528
  self.needsDisplay = YES;
1529
+ // A new layout can be a new text height, and a bottom- or middle-aligned
1530
+ // paragraph then sits somewhere else -- so the views inline in it do too.
1531
+ [self rnPlaceInlineChildren];
1532
+ }
1533
+
1534
+ - (void)setRnTextSelectable:(BOOL)selectable {
1535
+ if (_textSelectable == selectable) {
1536
+ return;
1537
+ }
1538
+ _textSelectable = selectable;
1539
+ // A paragraph that stops being selectable keeps no highlight: the prop going
1540
+ // false is an app saying this text is not to be selected, and leaving the
1541
+ // last selection drawn would be the one state nothing can clear.
1542
+ if (!selectable && _textLayout != nil && _textLayout.selection.length > 0) {
1543
+ _textLayout.selection = NSMakeRange(0, 0);
1544
+ self.needsDisplay = YES;
1545
+ }
1546
+ }
1547
+
1548
+ - (BOOL)rnTextSelectable {
1549
+ return _textSelectable;
1550
+ }
1551
+
1552
+ - (CGFloat)rnTextVerticalOffset {
1553
+ if (_textLayout == nil) {
1554
+ return 0;
1555
+ }
1556
+ const CGSize box = self.bounds.size;
1557
+ return [_textLayout verticalOffsetForSize:CGSizeMake(box.width - _rnTextInset.left -
1558
+ _rnTextInset.right,
1559
+ box.height - _rnTextInset.top -
1560
+ _rnTextInset.bottom)];
1561
+ }
1562
+
1563
+ // How far this view's parent moved its text, which is how far this view has to
1564
+ // move with it. Only the vertical alignment: an attachment's frame already
1565
+ // carries the paragraph's padding, React Native having added the content insets
1566
+ // to it in `ParagraphShadowNode::layout`.
1567
+ - (CGFloat)rnParentTextOffset {
1568
+ RnAppKitView *parent = [self.superview isKindOfClass:[RnAppKitView class]]
1569
+ ? (RnAppKitView *)self.superview
1570
+ : nil;
1571
+ return parent == nil ? 0 : [parent rnTextVerticalOffset];
1572
+ }
1573
+
1574
+ // Puts every inline view back where this paragraph's text now is. Called when
1575
+ // anything that can move the text changes: the layout itself, and the box.
1576
+ - (void)rnPlaceInlineChildren {
1577
+ if (_textLayout == nil || _textLayout.verticalFlush <= 0) {
1578
+ // Nothing to move, and nothing was: a paragraph at the top of its box
1579
+ // leaves its children where React Native put them.
1580
+ return;
1581
+ }
1582
+ const CGFloat offset = [self rnTextVerticalOffset];
1583
+ for (NSView *child in self.subviews) {
1584
+ if (![child isKindOfClass:[RnAppKitView class]]) {
1585
+ continue;
1586
+ }
1587
+ RnAppKitView *inlineChild = (RnAppKitView *)child;
1588
+ const NSRect frame = inlineChild.frame;
1589
+ const CGFloat wanted = inlineChild->_rnOrigin.y + offset;
1590
+ if (frame.origin.y != wanted) {
1591
+ inlineChild.frame =
1592
+ NSMakeRect(frame.origin.x, wanted, frame.size.width, frame.size.height);
1593
+ }
1594
+ }
1595
+ }
1596
+
1597
+ - (NSInteger)rnTextIndexAtPoint:(CGPoint)point {
1598
+ if (_textLayout == nil) {
1599
+ return -1;
1600
+ }
1601
+ // In the paragraph's own coordinates: the view's, minus its padding. The same
1602
+ // move the draw makes, or a drag would select the character beside the one
1603
+ // under the pointer.
1604
+ const CGSize box = self.bounds.size;
1605
+ const CGPoint inText =
1606
+ CGPointMake(point.x - _rnTextInset.left, point.y - _rnTextInset.top);
1607
+ const CGSize content = CGSizeMake(box.width - _rnTextInset.left - _rnTextInset.right,
1608
+ box.height - _rnTextInset.top - _rnTextInset.bottom);
1609
+ return (NSInteger)[_textLayout characterIndexAtPoint:inText size:content];
1610
+ }
1611
+
1612
+ - (void)setRnTextSelectionStart:(NSInteger)start length:(NSInteger)length {
1613
+ if (_textLayout == nil) {
1614
+ return;
1615
+ }
1616
+ const NSRange range =
1617
+ NSMakeRange((NSUInteger)MAX((NSInteger)0, start), (NSUInteger)MAX((NSInteger)0, length));
1618
+ if (NSEqualRanges(_textLayout.selection, range)) {
1619
+ return;
1620
+ }
1621
+ _textLayout.selection = range;
1622
+ self.needsDisplay = YES;
1623
+ }
1624
+
1625
+ - (NSInteger)rnTextSelectionStart {
1626
+ return _textLayout == nil ? 0 : (NSInteger)_textLayout.selection.location;
1627
+ }
1628
+
1629
+ - (NSInteger)rnTextSelectionLength {
1630
+ return _textLayout == nil ? 0 : (NSInteger)_textLayout.selection.length;
1631
+ }
1632
+
1633
+ - (nullable NSString *)rnSelectedText {
1634
+ return _textLayout == nil ? nil : [_textLayout selectedText];
630
1635
  }
631
1636
 
632
1637
  // Only views with text draw anything; the rest are pure CALayer properties and
@@ -651,14 +1656,39 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
651
1656
  (void)dirtyRect;
652
1657
  const BOOL ring = [self rnShowsFocusRing];
653
1658
  if (_textLayout == nil && _image == nullptr && !_hasBorders && !ring &&
654
- _highlightFilled.empty()) {
1659
+ _highlightFilled.empty() && _gradients.empty()) {
655
1660
  return;
656
1661
  }
657
1662
  CGContextRef context = [NSGraphicsContext currentContext].CGContext;
658
1663
  const NSSize size = self.bounds.size;
659
1664
 
1665
+ // The background image, under everything this view draws and over the
1666
+ // background colour, which is a layer property and so is painted below all of
1667
+ // this. That is CSS's order.
1668
+ [self rnDrawGradientsInContext:context size:size];
1669
+
660
1670
  if (_image != nullptr) {
661
1671
  const NSRect destination = [self rnImageRectForSize:size];
1672
+ // A blurred copy stands in for the image below, including as the tint's
1673
+ // mask: blurring an alpha mask and filling it with one solid colour is the
1674
+ // same picture as blurring the fill, because the colour is constant. That
1675
+ // is what makes this match the GTK side, which pushes its blur around the
1676
+ // tint rather than around the silhouette it is masked from.
1677
+ //
1678
+ // Made at the destination's size, which for `repeat` is one tile, so every
1679
+ // tile blurs alike as it does on GTK. The device scale comes from the
1680
+ // context rather than from the window, because a view drawn into a bitmap
1681
+ // -- a test, a snapshot -- has no window to ask.
1682
+ CGImageRef pixels = _image;
1683
+ if (_imageBlur > 0) {
1684
+ const CGFloat deviceScale =
1685
+ CGContextConvertSizeToDeviceSpace(context, CGSizeMake(1, 1)).width;
1686
+ CGImageRef blurred = [self rnBlurredImageForSize:destination.size
1687
+ scale:deviceScale > 0 ? deviceScale : 1];
1688
+ if (blurred != nullptr) {
1689
+ pixels = blurred;
1690
+ }
1691
+ }
662
1692
 
663
1693
  CGContextSaveGState(context);
664
1694
  // cover, center and repeat can all put pixels outside the frame, and an
@@ -689,21 +1719,21 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
689
1719
  destination.size.width,
690
1720
  destination.size.height);
691
1721
  if (_imageTint != nil) {
692
- CGContextClipToMask(context, tile, _image);
1722
+ CGContextClipToMask(context, tile, pixels);
693
1723
  CGContextSetFillColorWithColor(context, _imageTint.CGColor);
694
1724
  CGContextFillRect(context, NSMakeRect(0, 0, size.width, size.height));
695
1725
  } else {
696
- CGContextDrawTiledImage(context, tile, _image);
1726
+ CGContextDrawTiledImage(context, tile, pixels);
697
1727
  }
698
1728
  } else if (_imageTint != nil) {
699
1729
  // The image becomes a stencil and the colour is what is drawn. Clipping
700
1730
  // to the mask uses the image's alpha, which is what `tintColor` means:
701
1731
  // recolour the silhouette rather than blend with the pixels.
702
- CGContextClipToMask(context, drawn, _image);
1732
+ CGContextClipToMask(context, drawn, pixels);
703
1733
  CGContextSetFillColorWithColor(context, _imageTint.CGColor);
704
1734
  CGContextFillRect(context, drawn);
705
1735
  } else {
706
- CGContextDrawImage(context, drawn, _image);
1736
+ CGContextDrawImage(context, drawn, pixels);
707
1737
  }
708
1738
  CGContextRestoreGState(context);
709
1739
  }
@@ -711,7 +1741,16 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
711
1741
  // Text sits above the image and below any children, which is the order the
712
1742
  // GTK side paints in too.
713
1743
  if (_textLayout != nil) {
714
- [_textLayout drawInContext:context size:size];
1744
+ // Inside the padding, in a box the size of the content. See rnTextInset:
1745
+ // the layout draws in a box with a top-left origin, so moving the origin is
1746
+ // the whole of it, and the selection highlight moves with the glyphs
1747
+ // because it is drawn in the same call.
1748
+ const CGSize content = CGSizeMake(size.width - _rnTextInset.left - _rnTextInset.right,
1749
+ size.height - _rnTextInset.top - _rnTextInset.bottom);
1750
+ CGContextSaveGState(context);
1751
+ CGContextTranslateCTM(context, _rnTextInset.left, _rnTextInset.top);
1752
+ [_textLayout drawInContext:context size:content];
1753
+ CGContextRestoreGState(context);
715
1754
  }
716
1755
 
717
1756
  // Borders paint over the content, as they do on every other platform.
@@ -775,6 +1814,57 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
775
1814
  CGPathRelease(path);
776
1815
  }
777
1816
 
1817
+ // A dotted or dashed border: one stroked path instead of four filled edges.
1818
+ //
1819
+ // The same shape of drawing as the GTK side's, down to the dash pattern, because
1820
+ // a dashed border that started its dashes in a different place on each desktop
1821
+ // would be a difference nobody asked for. The top edge's width and colour decide
1822
+ // the stroke: a stroked path has one pen.
1823
+ - (void)rnStrokeDashedBorderInContext:(CGContextRef)context size:(NSSize)size {
1824
+ const CGFloat width = _borderWidths[0] > 0 ? _borderWidths[0] : 1.0;
1825
+
1826
+ // Inset by half the width, because a stroke straddles its path while the
1827
+ // filled edges sit inside the box. Without that a 4pt dashed border would
1828
+ // paint two points outside the view and over its neighbour.
1829
+ //
1830
+ // The corner radii are left as they are rather than shrunk by the inset, which
1831
+ // is what GSK's own inset does on the other host: it pulls the corners in by
1832
+ // about a third of the inset, and matching it keeps the two drawing one shape.
1833
+ const CGRect centred = CGRectInset(CGRectMake(0, 0, size.width, size.height),
1834
+ width / 2.0, width / 2.0);
1835
+ if (centred.size.width <= 0 || centred.size.height <= 0) {
1836
+ return;
1837
+ }
1838
+ CGPathRef path = RnAppKitCreateRoundedPath(centred, _borderRadii);
1839
+
1840
+ CGContextSaveGState(context);
1841
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
1842
+ const CGFloat components[4] = {_borderColors[0], _borderColors[1], _borderColors[2],
1843
+ _borderColors[3]};
1844
+ CGColorRef color = CGColorCreate(space, components);
1845
+ CGContextSetStrokeColorWithColor(context, color);
1846
+ CGContextSetLineWidth(context, width);
1847
+
1848
+ // Scaled to the width, the way a browser does it. Dotted is round caps on a
1849
+ // zero-length dash, which is what makes a dot round rather than a short dash.
1850
+ if (_borderStyle == RnAppKitBorderStyleDotted) {
1851
+ const CGFloat dots[2] = {0, width * 2.0};
1852
+ CGContextSetLineCap(context, kCGLineCapRound);
1853
+ CGContextSetLineDash(context, 0, dots, 2);
1854
+ } else {
1855
+ const CGFloat dashes[2] = {width * 3.0, width * 2.0};
1856
+ CGContextSetLineDash(context, 0, dashes, 2);
1857
+ }
1858
+
1859
+ CGContextAddPath(context, path);
1860
+ CGContextStrokePath(context);
1861
+
1862
+ CGColorRelease(color);
1863
+ CGColorSpaceRelease(space);
1864
+ CGContextRestoreGState(context);
1865
+ CGPathRelease(path);
1866
+ }
1867
+
778
1868
  // The four edges, each its own width and colour.
779
1869
  //
780
1870
  // This is CSS's own algorithm rather than a stroke: an edge is the region
@@ -784,6 +1874,10 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
784
1874
  // cannot do it -- which matters as soon as one edge differs, and is invisible
785
1875
  // until then.
786
1876
  - (void)rnDrawBordersInContext:(CGContextRef)context size:(NSSize)size {
1877
+ if (_borderStyle != RnAppKitBorderStyleSolid) {
1878
+ [self rnStrokeDashedBorderInContext:context size:size];
1879
+ return;
1880
+ }
787
1881
  const CGFloat w = size.width;
788
1882
  const CGFloat h = size.height;
789
1883
  const CGFloat top = _borderWidths[0];
@@ -874,7 +1968,13 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
874
1968
 
875
1969
  - (void)setRnOpacity:(CGFloat)opacity {
876
1970
  _opacity = opacity;
877
- self.layer.opacity = (float)opacity;
1971
+ [self rnApplyOpacity];
1972
+ }
1973
+
1974
+ // The view's own opacity and any `opacity()` in its filter list, which CSS lets
1975
+ // an app ask for in both places at once.
1976
+ - (void)rnApplyOpacity {
1977
+ self.layer.opacity = (float)(_opacity * _filterOpacity);
878
1978
  }
879
1979
 
880
1980
  - (void)setRnHidesBackFace:(BOOL)hides {
@@ -945,6 +2045,15 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
945
2045
  - (void)setRnClipsChildren:(BOOL)clips {
946
2046
  _clipsChildren = clips;
947
2047
  self.layer.masksToBounds = clips;
2048
+ // Which decides where an outset shadow and an outline live, so both move with
2049
+ // it.
2050
+ if (!_boxShadows.empty()) {
2051
+ [self rnRebuildBoxShadowLayers];
2052
+ [self rnRebuildFilterShadowLayers];
2053
+ }
2054
+ if (_outlineWidth > 0) {
2055
+ [self rnRebuildOutlineLayer];
2056
+ }
948
2057
  }
949
2058
 
950
2059
  - (void)setRnZIndex:(NSInteger)zIndex {
@@ -1089,6 +2198,15 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
1089
2198
  }
1090
2199
  // The border is drawn along these radii.
1091
2200
  self.needsDisplay = YES;
2201
+ // And so is every shadow: a shadow follows the box it is cast by, and so does
2202
+ // the outline.
2203
+ if (!_boxShadows.empty()) {
2204
+ [self rnRebuildBoxShadowLayers];
2205
+ [self rnRebuildFilterShadowLayers];
2206
+ }
2207
+ if (_outlineWidth > 0) {
2208
+ [self rnRebuildOutlineLayer];
2209
+ }
1092
2210
  }
1093
2211
 
1094
2212
  - (void)rnUpdateRadiusMask {
@@ -1123,14 +2241,843 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
1123
2241
  self.needsDisplay = YES;
1124
2242
  }
1125
2243
 
2244
+ // The NSCursor for a CSS cursor keyword, or nil for one macOS has no cursor for.
2245
+ //
2246
+ // The mapping React Native's own macOS fork makes, which is worth matching to the
2247
+ // letter: an app moved from react-native-macos to this platform should get the
2248
+ // same pointer over the same view. That fork is also where the holes come from --
2249
+ // "all-scroll", "cell", "help", "move", "progress" and "wait" have no NSCursor,
2250
+ // public or otherwise, and nil here means the view installs no cursor rect, so
2251
+ // the cursor is inherited rather than forced back to an arrow.
2252
+ //
2253
+ // The resize family is the one place macOS 15 made this better: before it, the
2254
+ // only resize cursors were the four axis-aligned ones, so a corner handle had
2255
+ // nothing to show. `frameResizeCursorFromPosition:inDirections:` has the
2256
+ // diagonals, and the fallbacks below are what the older system can do.
2257
+ static NSCursor *RnAppKitCursorNamed(NSString *name) {
2258
+ if ([name isEqualToString:@"default"]) {
2259
+ return NSCursor.arrowCursor;
2260
+ }
2261
+ if ([name isEqualToString:@"pointer"]) {
2262
+ return NSCursor.pointingHandCursor;
2263
+ }
2264
+ if ([name isEqualToString:@"text"]) {
2265
+ return NSCursor.IBeamCursor;
2266
+ }
2267
+ if ([name isEqualToString:@"crosshair"]) {
2268
+ return NSCursor.crosshairCursor;
2269
+ }
2270
+ if ([name isEqualToString:@"grab"]) {
2271
+ return NSCursor.openHandCursor;
2272
+ }
2273
+ if ([name isEqualToString:@"grabbing"]) {
2274
+ return NSCursor.closedHandCursor;
2275
+ }
2276
+ if ([name isEqualToString:@"alias"]) {
2277
+ return NSCursor.dragLinkCursor;
2278
+ }
2279
+ if ([name isEqualToString:@"copy"]) {
2280
+ return NSCursor.dragCopyCursor;
2281
+ }
2282
+ if ([name isEqualToString:@"context-menu"]) {
2283
+ return NSCursor.contextualMenuCursor;
2284
+ }
2285
+ // CSS distinguishes "this is not a drop target" from "this is not allowed at
2286
+ // all"; macOS has one cursor for both, as does react-native-macos.
2287
+ if ([name isEqualToString:@"no-drop"] || [name isEqualToString:@"not-allowed"]) {
2288
+ return NSCursor.operationNotAllowedCursor;
2289
+ }
2290
+ // An empty one-pixel image, which is how macOS hides the pointer over a view.
2291
+ if ([name isEqualToString:@"none"]) {
2292
+ static NSCursor *blank = nil;
2293
+ static dispatch_once_t once;
2294
+ dispatch_once(&once, ^{
2295
+ blank = [[NSCursor alloc] initWithImage:[[NSImage alloc] initWithSize:NSMakeSize(1, 1)]
2296
+ hotSpot:NSZeroPoint];
2297
+ });
2298
+ return blank;
2299
+ }
2300
+ if ([name isEqualToString:@"ew-resize"] || [name isEqualToString:@"e-resize"] ||
2301
+ [name isEqualToString:@"w-resize"]) {
2302
+ return NSCursor.resizeLeftRightCursor;
2303
+ }
2304
+ if ([name isEqualToString:@"ns-resize"] || [name isEqualToString:@"n-resize"] ||
2305
+ [name isEqualToString:@"s-resize"]) {
2306
+ return NSCursor.resizeUpDownCursor;
2307
+ }
2308
+ if ([name isEqualToString:@"col-resize"]) {
2309
+ if (@available(macOS 15.0, *)) {
2310
+ return NSCursor.columnResizeCursor;
2311
+ }
2312
+ return NSCursor.resizeLeftRightCursor;
2313
+ }
2314
+ if ([name isEqualToString:@"row-resize"]) {
2315
+ if (@available(macOS 15.0, *)) {
2316
+ return NSCursor.rowResizeCursor;
2317
+ }
2318
+ return NSCursor.resizeUpDownCursor;
2319
+ }
2320
+ if (@available(macOS 15.0, *)) {
2321
+ // The diagonals, which only exist as frame-resize cursors. "nesw" and "nwse"
2322
+ // are the two-headed pair and the single directions are one-headed, which is
2323
+ // what Outward and All mean here.
2324
+ if ([name isEqualToString:@"nesw-resize"]) {
2325
+ return [NSCursor frameResizeCursorFromPosition:NSCursorFrameResizePositionTopRight
2326
+ inDirections:NSCursorFrameResizeDirectionsAll];
2327
+ }
2328
+ if ([name isEqualToString:@"nwse-resize"]) {
2329
+ return [NSCursor frameResizeCursorFromPosition:NSCursorFrameResizePositionTopLeft
2330
+ inDirections:NSCursorFrameResizeDirectionsAll];
2331
+ }
2332
+ if ([name isEqualToString:@"ne-resize"]) {
2333
+ return [NSCursor frameResizeCursorFromPosition:NSCursorFrameResizePositionTopRight
2334
+ inDirections:NSCursorFrameResizeDirectionsOutward];
2335
+ }
2336
+ if ([name isEqualToString:@"nw-resize"]) {
2337
+ return [NSCursor frameResizeCursorFromPosition:NSCursorFrameResizePositionTopLeft
2338
+ inDirections:NSCursorFrameResizeDirectionsOutward];
2339
+ }
2340
+ if ([name isEqualToString:@"se-resize"]) {
2341
+ return [NSCursor frameResizeCursorFromPosition:NSCursorFrameResizePositionBottomRight
2342
+ inDirections:NSCursorFrameResizeDirectionsOutward];
2343
+ }
2344
+ if ([name isEqualToString:@"sw-resize"]) {
2345
+ return [NSCursor frameResizeCursorFromPosition:NSCursorFrameResizePositionBottomLeft
2346
+ inDirections:NSCursorFrameResizeDirectionsOutward];
2347
+ }
2348
+ if ([name isEqualToString:@"zoom-in"]) {
2349
+ return NSCursor.zoomInCursor;
2350
+ }
2351
+ if ([name isEqualToString:@"zoom-out"]) {
2352
+ return NSCursor.zoomOutCursor;
2353
+ }
2354
+ }
2355
+ return nil;
2356
+ }
2357
+
2358
+ - (void)setRnCursorName:(NSString *)name {
2359
+ NSString *wanted = name.length > 0 ? name : nil;
2360
+ if (_cursorName == wanted || [_cursorName isEqualToString:wanted]) {
2361
+ return;
2362
+ }
2363
+ _cursorName = [wanted copy];
2364
+ _cursor = wanted != nil ? RnAppKitCursorNamed(wanted) : nil;
2365
+ // Cursor rects are cached by the window and only rebuilt when it is told to,
2366
+ // so a prop that changes while the pointer is already inside the view would
2367
+ // otherwise keep the old cursor until the pointer left and came back.
2368
+ [self.window invalidateCursorRectsForView:self];
2369
+ }
2370
+
2371
+ - (NSCursor *)rnResolvedCursor {
2372
+ return _cursor;
2373
+ }
2374
+
2375
+ // `mixBlendMode`, which on macOS is one property: a Core Image blend-mode filter
2376
+ // on the layer, blending it with whatever is already beneath it.
2377
+ //
2378
+ // Public API here, as `CALayer.filters` is, and private on iOS -- React Native's
2379
+ // own iOS half sets `compositingFilter` to a bare string, which is undocumented
2380
+ // there. This sets the `CIFilter` the property is documented to take.
2381
+ //
2382
+ // The CSS names map one to one, `plus-lighter` included: it is linear dodge,
2383
+ // which is clamped addition, and that is what the keyword means.
2384
+ static NSString *RnAppKitBlendFilterNamed(NSString *keyword) {
2385
+ static NSDictionary<NSString *, NSString *> *names = nil;
2386
+ static dispatch_once_t once;
2387
+ dispatch_once(&once, ^{
2388
+ names = @{
2389
+ @"multiply" : @"CIMultiplyBlendMode",
2390
+ @"screen" : @"CIScreenBlendMode",
2391
+ @"overlay" : @"CIOverlayBlendMode",
2392
+ @"darken" : @"CIDarkenBlendMode",
2393
+ @"lighten" : @"CILightenBlendMode",
2394
+ @"color-dodge" : @"CIColorDodgeBlendMode",
2395
+ @"color-burn" : @"CIColorBurnBlendMode",
2396
+ @"hard-light" : @"CIHardLightBlendMode",
2397
+ @"soft-light" : @"CISoftLightBlendMode",
2398
+ @"difference" : @"CIDifferenceBlendMode",
2399
+ @"exclusion" : @"CIExclusionBlendMode",
2400
+ @"hue" : @"CIHueBlendMode",
2401
+ @"saturation" : @"CISaturationBlendMode",
2402
+ @"color" : @"CIColorBlendMode",
2403
+ @"luminosity" : @"CILuminosityBlendMode",
2404
+ @"plus-lighter" : @"CILinearDodgeBlendMode",
2405
+ };
2406
+ });
2407
+ return names[keyword];
2408
+ }
2409
+
2410
+ - (void)setRnBlendModeName:(NSString *)name {
2411
+ NSString *wanted = name.length > 0 ? name : nil;
2412
+ if (_blendModeName == wanted || [_blendModeName isEqualToString:wanted]) {
2413
+ return;
2414
+ }
2415
+ _blendModeName = [wanted copy];
2416
+ _blendFilterName = wanted != nil ? RnAppKitBlendFilterNamed(wanted) : nil;
2417
+ // A keyword Core Image has no filter for leaves the layer unblended rather
2418
+ // than wrongly blended, and the keyword is still reported: the dump says what
2419
+ // the app asked for, which is the rule the cursor name follows too.
2420
+ self.layer.compositingFilter =
2421
+ _blendFilterName != nil ? [CIFilter filterWithName:_blendFilterName] : nil;
2422
+ }
2423
+
2424
+ - (NSString *)rnBlendModeName {
2425
+ return _blendModeName;
2426
+ }
2427
+
2428
+ - (NSString *)rnBlendFilterName {
2429
+ return _blendFilterName;
2430
+ }
2431
+
2432
+ // AppKit asks for the rects rather than being told, which is why the cursor is
2433
+ // held and applied here: one rect over the whole view, discarded and rebuilt
2434
+ // each time, as react-native-macos does it. A child with its own cursor adds its
2435
+ // own rect and wins inside its own bounds, which is what CSS does.
2436
+ - (void)resetCursorRects {
2437
+ [self discardCursorRects];
2438
+ if (_cursor != nil) {
2439
+ [self addCursorRect:self.bounds cursor:_cursor];
2440
+ }
2441
+ }
2442
+
2443
+ - (void)setRnGradients:(const RnAppKitGradient *)gradients count:(NSInteger)count {
2444
+ std::vector<RnAppKitGradientRecord> wanted;
2445
+ if (gradients != nullptr && count > 0) {
2446
+ wanted.reserve((size_t)count);
2447
+ for (NSInteger i = 0; i < count; i++) {
2448
+ RnAppKitGradientRecord record{gradients[i].kind,
2449
+ gradients[i].start,
2450
+ gradients[i].end,
2451
+ gradients[i].center,
2452
+ gradients[i].radiusX,
2453
+ gradients[i].radiusY,
2454
+ gradients[i].area,
2455
+ gradients[i].tile,
2456
+ gradients[i].repeats,
2457
+ {}};
2458
+ if (gradients[i].stops != nullptr && gradients[i].stopCount > 0) {
2459
+ record.stops.assign(gradients[i].stops, gradients[i].stops + gradients[i].stopCount);
2460
+ }
2461
+ wanted.push_back(std::move(record));
2462
+ }
2463
+ }
2464
+
2465
+ // Compared before it is kept, for the reason the shadows are: a view re-sends
2466
+ // identical props on every mutation, and a redraw per mutation is a gradient
2467
+ // rasterised again for nothing.
2468
+ if (wanted.size() == _gradients.size()) {
2469
+ bool same = true;
2470
+ for (size_t i = 0; i < wanted.size() && same; i++) {
2471
+ same = wanted[i].kind == _gradients[i].kind &&
2472
+ CGRectEqualToRect(wanted[i].area, _gradients[i].area) &&
2473
+ CGRectEqualToRect(wanted[i].tile, _gradients[i].tile) &&
2474
+ wanted[i].repeats == _gradients[i].repeats &&
2475
+ CGPointEqualToPoint(wanted[i].start, _gradients[i].start) &&
2476
+ CGPointEqualToPoint(wanted[i].end, _gradients[i].end) &&
2477
+ CGPointEqualToPoint(wanted[i].center, _gradients[i].center) &&
2478
+ wanted[i].radiusX == _gradients[i].radiusX &&
2479
+ wanted[i].radiusY == _gradients[i].radiusY &&
2480
+ wanted[i].stops.size() == _gradients[i].stops.size() &&
2481
+ (wanted[i].stops.empty() ||
2482
+ memcmp(wanted[i].stops.data(),
2483
+ _gradients[i].stops.data(),
2484
+ wanted[i].stops.size() * sizeof(RnAppKitGradientStop)) == 0);
2485
+ }
2486
+ if (same) {
2487
+ return;
2488
+ }
2489
+ }
2490
+ _gradients = std::move(wanted);
2491
+ self.needsDisplay = YES;
2492
+ }
2493
+
2494
+ - (NSInteger)rnGradientCount {
2495
+ return (NSInteger)_gradients.size();
2496
+ }
2497
+
2498
+ // The gradients, back to front, clipped to the view's own rounded box.
2499
+ //
2500
+ // `drawRect:` is already clipped to the bounds, so only the rounded case needs a
2501
+ // path; a gradient with square corners on a rounded card is the kind of
2502
+ // difference a screenshot shows and a tree does not.
2503
+ //
2504
+ // Drawn beyond both ends of its line, which is what CSS does: the first and last
2505
+ // colours extend to the edges of the box rather than leaving it unpainted, and
2506
+ // the gradient line is often shorter than the box's diagonal.
2507
+ - (void)rnDrawGradientsInContext:(CGContextRef)context size:(NSSize)size {
2508
+ if (_gradients.empty()) {
2509
+ return;
2510
+ }
2511
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
2512
+ for (auto gradient = _gradients.rbegin(); gradient != _gradients.rend(); ++gradient) {
2513
+ if (gradient->stops.empty()) {
2514
+ continue;
2515
+ }
2516
+ std::vector<CGFloat> components;
2517
+ std::vector<CGFloat> locations;
2518
+ components.reserve(gradient->stops.size() * 4);
2519
+ locations.reserve(gradient->stops.size());
2520
+ for (const RnAppKitGradientStop &stop : gradient->stops) {
2521
+ components.insert(components.end(), stop.color, stop.color + 4);
2522
+ locations.push_back(stop.offset);
2523
+ }
2524
+ CGGradientRef ramp = CGGradientCreateWithColorComponents(
2525
+ space, components.data(), locations.data(), locations.size());
2526
+ if (ramp == nullptr) {
2527
+ continue;
2528
+ }
2529
+
2530
+ if (gradient->area.size.width <= 0 || gradient->area.size.height <= 0) {
2531
+ CGGradientRelease(ramp);
2532
+ continue;
2533
+ }
2534
+
2535
+ CGContextSaveGState(context);
2536
+ if (_hasBorderRadii || _cornerRadius > 0) {
2537
+ CGFloat radii[8];
2538
+ for (int i = 0; i < 8; i++) {
2539
+ radii[i] = _hasBorderRadii ? _borderRadii[i] : _cornerRadius;
2540
+ }
2541
+ CGPathRef path = RnAppKitCreateRoundedPath(CGRectMake(0, 0, size.width, size.height), radii);
2542
+ CGContextAddPath(context, path);
2543
+ CGContextClip(context);
2544
+ CGPathRelease(path);
2545
+ }
2546
+
2547
+ // Where the tiles go. Core Graphics has no repeat, so this is the loop GSK's
2548
+ // repeat node is on the other host: from the first tile, stepping by the
2549
+ // period until past the painting area. An axis that does not repeat arrives
2550
+ // with the painting area as its tile, so it steps once.
2551
+ const CGRect painting = CGRectMake(0, 0, size.width, size.height);
2552
+ // Nothing repeating is one tile, whatever the tile says: the flag is what
2553
+ // core resolved, and the GTK side reads it the same way by pushing no repeat
2554
+ // node at all. Stepping by a tile that happens to equal the image would draw
2555
+ // a grid for a `no-repeat` background, which is what the test for an image
2556
+ // filling its own rectangle caught.
2557
+ const CGFloat stepX = gradient->repeats && gradient->tile.size.width > 0
2558
+ ? gradient->tile.size.width
2559
+ : painting.size.width;
2560
+ const CGFloat stepY = gradient->repeats && gradient->tile.size.height > 0
2561
+ ? gradient->tile.size.height
2562
+ : painting.size.height;
2563
+ // Capped, because a stylesheet may ask for a one-point tile over a window:
2564
+ // 256 steps per axis is past the point where another tile changes a pixel on
2565
+ // any screen this runs on, and it keeps a pathological style from costing
2566
+ // minutes a frame. GSK's node has its own bound for the same reason.
2567
+ constexpr int kMaxTiles = 256;
2568
+ const CGFloat originX = gradient->repeats ? gradient->tile.origin.x : gradient->area.origin.x;
2569
+ const CGFloat originY = gradient->repeats ? gradient->tile.origin.y : gradient->area.origin.y;
2570
+ const int countX = gradient->repeats && stepX > 0
2571
+ ? (int)std::min<double>(kMaxTiles,
2572
+ std::floor((CGRectGetMaxX(painting) - originX) / stepX) + 1)
2573
+ : 1;
2574
+ const int countY = gradient->repeats && stepY > 0
2575
+ ? (int)std::min<double>(kMaxTiles,
2576
+ std::floor((CGRectGetMaxY(painting) - originY) / stepY) + 1)
2577
+ : 1;
2578
+
2579
+ for (int row = 0; row < std::max(countY, 1); row++) {
2580
+ for (int column = 0; column < std::max(countX, 1); column++) {
2581
+ const CGFloat dx = column * stepX;
2582
+ const CGFloat dy = row * stepY;
2583
+ const CGRect imageRect = CGRectOffset(gradient->area, dx, dy);
2584
+ if (!CGRectIntersectsRect(imageRect, painting)) {
2585
+ continue;
2586
+ }
2587
+
2588
+ CGContextSaveGState(context);
2589
+ // Clipped to the image's own rectangle: the gradient is drawn with
2590
+ // `kCGGradientDrawsBeforeStartLocation` and its `After` twin, which fill
2591
+ // the whole clip with the end colours -- so the clip is what keeps one
2592
+ // tile inside its tile.
2593
+ CGContextClipToRect(context, imageRect);
2594
+ CGContextTranslateCTM(context, dx, dy);
2595
+ [self rnDrawOneGradient:*gradient ramp:ramp context:context];
2596
+ CGContextRestoreGState(context);
2597
+ }
2598
+ }
2599
+ CGContextRestoreGState(context);
2600
+ CGGradientRelease(ramp);
2601
+ }
2602
+ CGColorSpaceRelease(space);
2603
+ }
2604
+
2605
+ // One gradient, at the rectangle its geometry was resolved against. The caller
2606
+ // has clipped and translated to the tile.
2607
+ - (void)rnDrawOneGradient:(const RnAppKitGradientRecord &)gradientRef
2608
+ ramp:(CGGradientRef)ramp
2609
+ context:(CGContextRef)context {
2610
+ const RnAppKitGradientRecord *gradient = &gradientRef;
2611
+ if (gradient->kind == RnAppKitGradientKindRadial) {
2612
+ // Core Graphics draws radial gradients between two *circles*, so an ellipse
2613
+ // is a scaled coordinate system rather than a different call: squash the y
2614
+ // axis around the centre by the ratio of the radii and draw a circle of the
2615
+ // horizontal radius. A host that drew a circle of one radius would be
2616
+ // drawing a plausible gradient of the wrong shape.
2617
+ const CGFloat radiusX = gradient->radiusX;
2618
+ const CGFloat radiusY = gradient->radiusY;
2619
+ if (radiusX <= 0 || radiusY <= 0) {
2620
+ // An ending shape with no size. The caller's clip and state are undone by
2621
+ // its own restore.
2622
+ return;
2623
+ }
2624
+ // Scaled *about the centre*, so the centre itself does not move and the
2625
+ // circle of the horizontal radius comes out as an ellipse with the vertical
2626
+ // one. Scaling about the origin instead would need the centre divided by the
2627
+ // same ratio, which is the mistake this comment exists to stop: it draws an
2628
+ // ellipse of the right shape in the wrong place.
2629
+ CGContextTranslateCTM(context, gradient->center.x, gradient->center.y);
2630
+ CGContextScaleCTM(context, 1.0, radiusY / radiusX);
2631
+ CGContextTranslateCTM(context, -gradient->center.x, -gradient->center.y);
2632
+ CGContextDrawRadialGradient(context,
2633
+ ramp,
2634
+ gradient->center,
2635
+ 0.0,
2636
+ gradient->center,
2637
+ radiusX,
2638
+ kCGGradientDrawsBeforeStartLocation |
2639
+ kCGGradientDrawsAfterEndLocation);
2640
+ } else {
2641
+ CGContextDrawLinearGradient(context,
2642
+ ramp,
2643
+ gradient->start,
2644
+ gradient->end,
2645
+ kCGGradientDrawsBeforeStartLocation |
2646
+ kCGGradientDrawsAfterEndLocation);
2647
+ }
2648
+ }
2649
+
2650
+ - (void)setRnBoxShadows:(const RnAppKitBoxShadow *)shadows count:(NSInteger)count {
2651
+ std::vector<RnAppKitBoxShadow> wanted;
2652
+ if (shadows != nullptr && count > 0) {
2653
+ wanted.assign(shadows, shadows + count);
2654
+ }
2655
+ // Compared rather than rebuilt blindly: a view re-sends identical props on
2656
+ // every mutation, and a layer rebuilt per mutation is a shadow recomputed for
2657
+ // nothing on every keystroke.
2658
+ if (wanted.size() == _boxShadows.size() &&
2659
+ (wanted.empty() || memcmp(wanted.data(), _boxShadows.data(),
2660
+ wanted.size() * sizeof(RnAppKitBoxShadow)) == 0)) {
2661
+ return;
2662
+ }
2663
+ _boxShadows = std::move(wanted);
2664
+ [self rnRebuildBoxShadowLayers];
2665
+ [self rnRebuildFilterShadowLayers];
2666
+ }
2667
+
2668
+ - (NSInteger)rnBoxShadowCount {
2669
+ return (NSInteger)_boxShadows.size();
2670
+ }
2671
+
2672
+ - (NSArray<CALayer *> *)rnBoxShadowLayers {
2673
+ return _boxShadowLayers != nil ? [_boxShadowLayers copy] : @[];
2674
+ }
2675
+
2676
+ // One layer per shadow, rebuilt whenever the list, the view's size, its radii or
2677
+ // its place in the tree changes -- a shadow's path is in the view's own
2678
+ // coordinates and an outset one lives in its parent, so all four invalidate it.
2679
+ //
2680
+ // Added back to front, because CSS says the first shadow in the list is the one
2681
+ // on top and sublayers composite in order.
2682
+ //
2683
+ // **Where an outset shadow goes depends on whether this view clips itself.**
2684
+ //
2685
+ // Normally it is a sublayer of the view, which is what React Native's iOS half
2686
+ // does: the mask cuts the box out of the shadow, so it only ever paints outside,
2687
+ // and being inside the view's own layer it composites exactly where the view
2688
+ // does -- above earlier siblings, below later ones.
2689
+ //
2690
+ // That arrangement loses the shadow entirely when the view clips its own layer,
2691
+ // which `overflow: 'hidden'` does through `masksToBounds` and non-uniform radii
2692
+ // do through a mask layer. CSS clips neither: an element's own shadow is outside
2693
+ // its box and is not subject to its overflow. So a clipping view casts its
2694
+ // shadow into the *parent's* layer instead, below its own.
2695
+ //
2696
+ // The cost of that path is z-order among siblings: AppKit attaches a subview's
2697
+ // layer lazily, at the first display, and these are built while mounting -- so
2698
+ // `below:self.layer` has nothing to aim at yet and the shadow lands at the
2699
+ // bottom of the parent, below every sibling rather than below this view alone.
2700
+ // Visible only where a sibling overlaps the shadow of a clipping view, which is
2701
+ // the narrower case of the two.
2702
+ //
2703
+ // Inset shadows are always sublayers of this view, where clipping is harmless
2704
+ // and right. They do paint above this view's own drawn text rather than under
2705
+ // it, which the GTK host gets right for free; backlog/correctness.md records
2706
+ // both of these.
2707
+ // Whether this view's own layer clips what is inside it, which decides where an
2708
+ // outset shadow can live. Both answers come from props: `overflow: 'hidden'` and
2709
+ // a set of radii too elliptical for CALayer's cornerRadius.
2710
+ - (BOOL)rnClipsItsOwnLayer {
2711
+ return _clipsChildren || self.layer.mask != nil;
2712
+ }
2713
+
2714
+ // The drop shadows a CALayer cannot hold, one layer each.
2715
+ //
2716
+ // Rebuilt rather than updated, the way the box shadows are: the geometry
2717
+ // depends on the view's size and corner radii, both of which change under it.
2718
+ - (void)rnRebuildFilterShadowLayers {
2719
+ for (CALayer *layer in _filterShadowLayers) {
2720
+ [layer removeFromSuperlayer];
2721
+ }
2722
+ [_filterShadowLayers removeAllObjects];
2723
+ if (_extraFilterShadows.empty()) {
2724
+ return;
2725
+ }
2726
+ if (_filterShadowLayers == nil) {
2727
+ _filterShadowLayers = [NSMutableArray array];
2728
+ }
2729
+
2730
+ const CGSize size = self.bounds.size;
2731
+ if (size.width <= 0 || size.height <= 0) {
2732
+ return;
2733
+ }
2734
+ CGFloat radii[8];
2735
+ for (int i = 0; i < 8; i++) {
2736
+ radii[i] = _hasBorderRadii ? _borderRadii[i] : 0;
2737
+ }
2738
+
2739
+ // Back to front: the last function in the list is the outermost, so it is
2740
+ // added first and the earlier ones are drawn over it.
2741
+ for (auto shadow = _extraFilterShadows.rbegin(); shadow != _extraFilterShadows.rend();
2742
+ ++shadow) {
2743
+ if (shadow->color[3] <= 0) {
2744
+ continue;
2745
+ }
2746
+ CALayer *layer = RnAppKitFilterShadowLayer(*shadow, radii, size);
2747
+ [self.layer insertSublayer:layer atIndex:0];
2748
+ [_filterShadowLayers addObject:layer];
2749
+ }
2750
+ }
2751
+
2752
+ - (void)rnRebuildBoxShadowLayers {
2753
+ for (CALayer *layer in _boxShadowLayers) {
2754
+ [layer removeFromSuperlayer];
2755
+ }
2756
+ [_boxShadowLayers removeAllObjects];
2757
+ if (_boxShadows.empty()) {
2758
+ return;
2759
+ }
2760
+ if (_boxShadowLayers == nil) {
2761
+ _boxShadowLayers = [NSMutableArray array];
2762
+ }
2763
+
2764
+ const CGSize size = self.bounds.size;
2765
+ if (size.width <= 0 || size.height <= 0) {
2766
+ return;
2767
+ }
2768
+ CGFloat radii[8];
2769
+ for (int i = 0; i < 8; i++) {
2770
+ radii[i] = _hasBorderRadii ? _borderRadii[i] : 0;
2771
+ }
2772
+
2773
+ for (auto shadow = _boxShadows.rbegin(); shadow != _boxShadows.rend(); ++shadow) {
2774
+ // A shadow React Native could not parse a colour for arrives with none.
2775
+ // Painting it would put a black shadow under a view that asked for nothing.
2776
+ if (shadow->color[3] <= 0) {
2777
+ continue;
2778
+ }
2779
+ if (shadow->inset) {
2780
+ CALayer *layer = RnAppKitInsetShadowLayer(*shadow, radii, size);
2781
+ [self.layer addSublayer:layer];
2782
+ [_boxShadowLayers addObject:layer];
2783
+ continue;
2784
+ }
2785
+ CALayer *layer = RnAppKitOutsetShadowLayer(*shadow, radii, size);
2786
+ if (![self rnClipsItsOwnLayer]) {
2787
+ [self.layer addSublayer:layer];
2788
+ [_boxShadowLayers addObject:layer];
2789
+ continue;
2790
+ }
2791
+ CALayer *parent = self.superview.layer;
2792
+ if (parent == nil) {
2793
+ // A clipping surface root, or one not yet mounted. Nothing to cast onto;
2794
+ // the next move into a superview rebuilds this.
2795
+ continue;
2796
+ }
2797
+ // In the parent's coordinates, which are this view's frame. Both are flipped
2798
+ // the same way, so the frame carries over as it stands.
2799
+ layer.frame = self.frame;
2800
+ [parent insertSublayer:layer below:self.layer];
2801
+ [_boxShadowLayers addObject:layer];
2802
+ }
2803
+ }
2804
+
2805
+ // A view that moves, or is removed, takes its shadows with it. Without this an
2806
+ // unmounted card leaves its shadow behind in the parent, which is the one bug
2807
+ // this arrangement can have that the simpler one cannot.
2808
+ - (void)viewDidMoveToSuperview {
2809
+ [super viewDidMoveToSuperview];
2810
+ if (!_boxShadows.empty()) {
2811
+ [self rnRebuildBoxShadowLayers];
2812
+ [self rnRebuildFilterShadowLayers];
2813
+ }
2814
+ if (_outlineWidth > 0) {
2815
+ [self rnRebuildOutlineLayer];
2816
+ }
2817
+ }
2818
+
2819
+ - (void)setRnOutlineWidth:(CGFloat)width
2820
+ offset:(CGFloat)offset
2821
+ color:(const CGFloat *)color
2822
+ style:(RnAppKitBorderStyle)style {
2823
+ const CGFloat wanted = width > 0 ? width : 0;
2824
+ CGFloat components[4] = {0, 0, 0, 0};
2825
+ for (int i = 0; i < 4; i++) {
2826
+ components[i] = color != nullptr ? color[i] : 0;
2827
+ }
2828
+ if (_outlineWidth == wanted && _outlineOffset == offset && _outlineStyle == style &&
2829
+ memcmp(_outlineColor, components, sizeof(components)) == 0) {
2830
+ return;
2831
+ }
2832
+ _outlineWidth = wanted;
2833
+ _outlineOffset = offset;
2834
+ memcpy(_outlineColor, components, sizeof(components));
2835
+ _outlineStyle = style;
2836
+ [self rnRebuildOutlineLayer];
2837
+ }
2838
+
2839
+ - (CAShapeLayer *)rnOutlineLayer {
2840
+ return _outlineLayer;
2841
+ }
2842
+
2843
+ // The outline, as a stroked shape outside the box.
2844
+ //
2845
+ // Stroked rather than a layer border, which is what React Native's iOS half uses
2846
+ // for the solid case: a stroke takes a dash pattern, so dotted and dashed need no
2847
+ // second mechanism, and it takes an arbitrary path, so elliptical radii need no
2848
+ // special case either. The path sits half a width outside the offset, a stroke
2849
+ // straddling its path.
2850
+ //
2851
+ // Placed by the same rule the box shadows use -- inside the view, or in the
2852
+ // parent when the view clips its own layer -- so an outline on a card with
2853
+ // `overflow: 'hidden'` is not quietly clipped away.
2854
+ - (void)rnRebuildOutlineLayer {
2855
+ [_outlineLayer removeFromSuperlayer];
2856
+ _outlineLayer = nil;
2857
+ if (_outlineWidth <= 0 || _outlineColor[3] <= 0) {
2858
+ return;
2859
+ }
2860
+ const NSRect bounds = self.bounds;
2861
+ if (bounds.size.width <= 0 || bounds.size.height <= 0) {
2862
+ return;
2863
+ }
2864
+
2865
+ const CGFloat grow = _outlineOffset + _outlineWidth / 2;
2866
+ CGFloat radii[8];
2867
+ for (int i = 0; i < 8; i++) {
2868
+ // Each non-zero radius grows with the ring so it stays concentric; a corner
2869
+ // that was square stays square, as it does on the GTK side.
2870
+ radii[i] = _hasBorderRadii ? (_borderRadii[i] > 0 ? _borderRadii[i] + grow : 0)
2871
+ : (_cornerRadius > 0 ? _cornerRadius + grow : 0);
2872
+ }
2873
+ CGPathRef path =
2874
+ RnAppKitCreateRoundedPath(CGRectInset(bounds, -grow, -grow), radii);
2875
+
2876
+ CAShapeLayer *layer = [CAShapeLayer layer];
2877
+ layer.path = path;
2878
+ layer.fillColor = nil;
2879
+ layer.lineWidth = _outlineWidth;
2880
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
2881
+ CGColorRef colour = CGColorCreate(space, _outlineColor);
2882
+ layer.strokeColor = colour;
2883
+ CGColorRelease(colour);
2884
+ CGColorSpaceRelease(space);
2885
+
2886
+ if (_outlineStyle == RnAppKitBorderStyleDotted) {
2887
+ layer.lineCap = kCALineCapRound;
2888
+ layer.lineDashPattern = @[ @0, @(_outlineWidth * 2) ];
2889
+ } else if (_outlineStyle == RnAppKitBorderStyleDashed) {
2890
+ layer.lineDashPattern = @[ @(_outlineWidth * 3), @(_outlineWidth * 2) ];
2891
+ }
2892
+
2893
+ if ([self rnClipsItsOwnLayer]) {
2894
+ CALayer *parent = self.superview.layer;
2895
+ if (parent != nil) {
2896
+ layer.frame = self.frame;
2897
+ [parent insertSublayer:layer below:self.layer];
2898
+ _outlineLayer = layer;
2899
+ }
2900
+ } else {
2901
+ layer.frame = bounds;
2902
+ [self.layer addSublayer:layer];
2903
+ _outlineLayer = layer;
2904
+ }
2905
+ CGPathRelease(path);
2906
+ }
2907
+
2908
+ - (void)setRnFilters:(const RnAppKitFilters *)filters {
2909
+ if (filters == nullptr) {
2910
+ // Every half has to come off, and there are three. A list of nothing but
2911
+ // `opacity()` leaves no Core Image filter behind, and so does a list of
2912
+ // nothing but `dropShadow()` -- so an early return on `_filters` alone keeps
2913
+ // the view faded, or shadowed, for good. The first of those two was caught
2914
+ // by the test for taking a filter away; the second was caught by the same
2915
+ // test written again for the shadow, which is the argument for writing it.
2916
+ if (_filters == nil && _filterOpacity == 1 && _filterShadow.color[3] == 0 &&
2917
+ _filterShadow.standardDeviation == 0 && _extraFilterShadows.empty()) {
2918
+ return;
2919
+ }
2920
+ _filters = nil;
2921
+ self.layer.filters = nil;
2922
+ _filterOpacity = 1;
2923
+ [self rnApplyOpacity];
2924
+ [self rnApplyFilterShadow:nullptr];
2925
+ _extraFilterShadows.clear();
2926
+ [self rnRebuildFilterShadowLayers];
2927
+ return;
2928
+ }
2929
+
2930
+ NSMutableArray<CIFilter *> *built = [NSMutableArray array];
2931
+ if (filters->hasMatrix) {
2932
+ // CIColorMatrix takes the matrix as four input vectors, one per *output*
2933
+ // channel's weights, plus a bias -- which is this matrix row by row.
2934
+ CIFilter *matrix = [CIFilter filterWithName:@"CIColorMatrix"];
2935
+ const auto row = [filters](int index) {
2936
+ return [CIVector vectorWithX:filters->matrix[index * 4 + 0]
2937
+ Y:filters->matrix[index * 4 + 1]
2938
+ Z:filters->matrix[index * 4 + 2]
2939
+ W:filters->matrix[index * 4 + 3]];
2940
+ };
2941
+ [matrix setValue:row(0) forKey:@"inputRVector"];
2942
+ [matrix setValue:row(1) forKey:@"inputGVector"];
2943
+ [matrix setValue:row(2) forKey:@"inputBVector"];
2944
+ [matrix setValue:row(3) forKey:@"inputAVector"];
2945
+ [matrix setValue:[CIVector vectorWithX:filters->offset[0]
2946
+ Y:filters->offset[1]
2947
+ Z:filters->offset[2]
2948
+ W:filters->offset[3]]
2949
+ forKey:@"inputBiasVector"];
2950
+ [built addObject:matrix];
2951
+ }
2952
+ if (filters->blurRadius > 0) {
2953
+ CIFilter *blur = [CIFilter filterWithName:@"CIGaussianBlur"];
2954
+ // Sigma is half the radius, the same conversion the image blur and the box
2955
+ // shadows use.
2956
+ [blur setValue:@(filters->blurRadius / 2) forKey:@"inputRadius"];
2957
+ [built addObject:blur];
2958
+ }
2959
+ _filters = built.count > 0 ? built : nil;
2960
+ self.layer.filters = _filters;
2961
+
2962
+ // `opacity()` is not a Core Image filter here: it multiplies the view's own
2963
+ // opacity, which is a layer property and cheaper than a filter pass. The GTK
2964
+ // side folds it into its opacity node for the same reason.
2965
+ _filterOpacity = filters->opacity;
2966
+ [self rnApplyOpacity];
2967
+
2968
+ // The first is the view's own layer's, where it is cast from the content's
2969
+ // alpha. The rest are layers of their own; see RnAppKitFilterShadowLayer.
2970
+ [self rnApplyFilterShadow:filters->shadowCount > 0 ? filters->shadows : nullptr];
2971
+ _extraFilterShadows.assign(filters->shadows + (filters->shadowCount > 0 ? 1 : 0),
2972
+ filters->shadows + MAX((NSInteger)0, filters->shadowCount));
2973
+ [self rnRebuildFilterShadowLayers];
2974
+ }
2975
+
2976
+ // `dropShadow()`, which is the layer's own shadow rather than a Core Image
2977
+ // filter: with no `shadowPath`, Core Animation casts the shadow from the
2978
+ // layer's *alpha*, which is what the filter function means and what a box shadow
2979
+ // cannot do. The box shadows beside it are separate layers with a path each, so
2980
+ // the two mechanisms do not collide.
2981
+ //
2982
+ // `CALayer.shadowRadius` is the gaussian's standard deviation, so what React
2983
+ // Native parsed crosses unchanged -- where the GTK side doubles it, GSK's radius
2984
+ // being CSS's. React Native's own iOS half passes the same number to SwiftUI's
2985
+ // `.shadow(radius:)`.
2986
+ //
2987
+ // One shadow, because a layer has one. A list of several needs a wrapper layer
2988
+ // each and is recorded in backlog/platform-macos.md rather than half done.
2989
+ - (void)rnApplyFilterShadow:(const RnAppKitFilterShadow *)shadow {
2990
+ if (shadow == nullptr) {
2991
+ if (_filterShadow.color[3] == 0 && _filterShadow.standardDeviation == 0) {
2992
+ return;
2993
+ }
2994
+ _filterShadow = RnAppKitFilterShadow{};
2995
+ self.layer.shadowOpacity = 0;
2996
+ self.layer.shadowColor = nil;
2997
+ self.layer.shadowRadius = 0;
2998
+ self.layer.shadowOffset = CGSizeZero;
2999
+ return;
3000
+ }
3001
+ if (memcmp(&_filterShadow, shadow, sizeof(RnAppKitFilterShadow)) == 0) {
3002
+ return;
3003
+ }
3004
+ _filterShadow = *shadow;
3005
+
3006
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
3007
+ const CGFloat opaque[4] = {shadow->color[0], shadow->color[1], shadow->color[2], 1};
3008
+ CGColorRef colour = CGColorCreate(space, opaque);
3009
+ self.layer.shadowColor = colour;
3010
+ CGColorRelease(colour);
3011
+ CGColorSpaceRelease(space);
3012
+ // The colour's alpha is the layer's shadowOpacity, which is where Core
3013
+ // Animation keeps it: a shadowColor with an alpha and an opacity of 1 would
3014
+ // multiply the two.
3015
+ self.layer.shadowOpacity = (float)shadow->color[3];
3016
+ self.layer.shadowRadius = shadow->standardDeviation;
3017
+ // This view is flipped, so a positive dy is down, as CSS means it. The layer
3018
+ // is not, which is why the sign is flipped here and not in the shadow path the
3019
+ // box shadows build.
3020
+ self.layer.shadowOffset = CGSizeMake(shadow->dx, -shadow->dy);
3021
+ // No path: the shadow is cast from the alpha, which is the whole point.
3022
+ self.layer.shadowPath = nil;
3023
+ }
3024
+
3025
+ - (RnAppKitFilterShadow)rnFilterShadow {
3026
+ return _filterShadow;
3027
+ }
3028
+
3029
+ - (NSArray<CIFilter *> *)rnFilters {
3030
+ return _filters != nil ? _filters : @[];
3031
+ }
3032
+
3033
+ - (void)setRnHitSlop:(const CGFloat *)insets {
3034
+ BOOL any = NO;
3035
+ for (int edge = 0; edge < 4; edge++) {
3036
+ _hitSlop[edge] = insets != nullptr ? insets[edge] : 0;
3037
+ if (_hitSlop[edge] != 0) {
3038
+ any = YES;
3039
+ }
3040
+ }
3041
+ _hasHitSlop = any;
3042
+ // Nothing to redraw: the prop moves no pixels.
3043
+ }
3044
+
3045
+ // The box a press has to land in, which is the bounds unless `hitSlop` says
3046
+ // otherwise. In the view's own coordinates, so a scrolled view's offset is
3047
+ // already in `bounds`.
3048
+ - (NSRect)rnHitArea {
3049
+ const NSRect bounds = self.bounds;
3050
+ if (!_hasHitSlop) {
3051
+ return bounds;
3052
+ }
3053
+ return NSMakeRect(bounds.origin.x - _hitSlop[3],
3054
+ bounds.origin.y - _hitSlop[0],
3055
+ bounds.size.width + _hitSlop[1] + _hitSlop[3],
3056
+ bounds.size.height + _hitSlop[0] + _hitSlop[2]);
3057
+ }
3058
+
3059
+ - (void)setRnBorderStyle:(RnAppKitBorderStyle)style {
3060
+ if (_borderStyle == style) {
3061
+ return;
3062
+ }
3063
+ _borderStyle = style;
3064
+ self.needsDisplay = YES;
3065
+ }
3066
+
1126
3067
  - (void)insertRnChild:(RnAppKitView *)child atIndex:(NSInteger)index {
1127
3068
  NSArray<NSView *> *children = self.subviews;
1128
3069
  if (index >= (NSInteger)children.count) {
1129
3070
  [self addSubview:child];
1130
3071
  [self rnRaiseIndicators];
3072
+ [self rnPlaceInlineChildren];
1131
3073
  return;
1132
3074
  }
1133
3075
  [self addSubview:child positioned:NSWindowBelow relativeTo:children[(NSUInteger)index]];
3076
+ // A view inline in a paragraph is given its frame before it is inserted --
3077
+ // the mounting walk creates a view, applies its props and only then attaches
3078
+ // it -- so at that moment it has no parent to ask where the text went. This
3079
+ // is where it finds out.
3080
+ [self rnPlaceInlineChildren];
1134
3081
  }
1135
3082
 
1136
3083
  // A child added at or past the end lands above the overlay, so put it back on
@@ -1156,6 +3103,10 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
1156
3103
  [out appendString:@" "];
1157
3104
  }
1158
3105
  const NSRect frame = self.frame;
3106
+ // Where the view sits, which for one inline in a paragraph is not the origin
3107
+ // React Native gave it: `textAlignVertical` is the host's own answer to a box
3108
+ // taller than its text, and the other two hosts print the same number. See
3109
+ // rnPlaceInlineChildren.
1159
3110
  [out appendFormat:@"view tag=%ld frame=(%g,%g %gx%g)",
1160
3111
  (long)self.rnTag,
1161
3112
  frame.origin.x,
@@ -1212,6 +3163,69 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
1212
3163
  (unsigned)(_borderColors[edge * 4 + 3] * 255.0 + 0.5)];
1213
3164
  }
1214
3165
  [out appendString:@")"];
3166
+ // The style, when it is not solid. Printed for the same reason the widths
3167
+ // are: a dashed border and a solid one are the same four widths and the same
3168
+ // four colours, and this is the only thing that can say the prop arrived.
3169
+ if (_borderStyle != RnAppKitBorderStyleSolid) {
3170
+ [out appendFormat:@" border-style=%s",
3171
+ _borderStyle == RnAppKitBorderStyleDotted ? "dotted" : "dashed"];
3172
+ }
3173
+ }
3174
+ // Gradients: how many, each one's line and stop count, where the image goes
3175
+ // and the tile it repeats in, spelled as GTK spells them. Not every stop,
3176
+ // which a transition hint can turn into eleven of: what a cross-host diff
3177
+ // needs is that the same gradient arrived with the same geometry, and the stop
3178
+ // fixup is asserted in core's own tests.
3179
+ //
3180
+ // `at=` is the rectangle the image fills and `tile=` is the period, printed
3181
+ // only when it repeats, so a `no-repeat` background is the line without a
3182
+ // tile. The three background props are invisible in every other line here.
3183
+ for (const RnAppKitGradientRecord &gradient : _gradients) {
3184
+ if (gradient.kind == RnAppKitGradientKindRadial) {
3185
+ [out appendFormat:@" gradient=(radial (%g,%g) %gx%g,%lu stops",
3186
+ (double)gradient.center.x,
3187
+ (double)gradient.center.y,
3188
+ (double)gradient.radiusX,
3189
+ (double)gradient.radiusY,
3190
+ (unsigned long)gradient.stops.size()];
3191
+ } else {
3192
+ [out appendFormat:@" gradient=((%g,%g)-(%g,%g),%lu stops",
3193
+ (double)gradient.start.x,
3194
+ (double)gradient.start.y,
3195
+ (double)gradient.end.x,
3196
+ (double)gradient.end.y,
3197
+ (unsigned long)gradient.stops.size()];
3198
+ }
3199
+ [out appendFormat:@",at=(%g,%g %gx%g)",
3200
+ (double)gradient.area.origin.x,
3201
+ (double)gradient.area.origin.y,
3202
+ (double)gradient.area.size.width,
3203
+ (double)gradient.area.size.height];
3204
+ if (gradient.repeats) {
3205
+ [out appendFormat:@",tile=(%g,%g %gx%g)",
3206
+ (double)gradient.tile.origin.x,
3207
+ (double)gradient.tile.origin.y,
3208
+ (double)gradient.tile.size.width,
3209
+ (double)gradient.tile.size.height];
3210
+ }
3211
+ [out appendString:@")"];
3212
+ }
3213
+
3214
+ // Box shadows, each in full and spelled as GTK spells them. Nothing else in
3215
+ // this dump can say a shadow is there, and the numbers are the whole feature:
3216
+ // an offset that went to the wrong axis or a spread read as a blur still draws
3217
+ // a plausible shadow.
3218
+ for (const RnAppKitBoxShadow &shadow : _boxShadows) {
3219
+ [out appendFormat:@" shadow=(%s%g,%g,%g,%g,#%02x%02x%02x%02x)",
3220
+ shadow.inset ? "inset " : "",
3221
+ (double)shadow.dx,
3222
+ (double)shadow.dy,
3223
+ (double)shadow.blur,
3224
+ (double)shadow.spread,
3225
+ (unsigned)(shadow.color[0] * 255.0 + 0.5),
3226
+ (unsigned)(shadow.color[1] * 255.0 + 0.5),
3227
+ (unsigned)(shadow.color[2] * 255.0 + 0.5),
3228
+ (unsigned)(shadow.color[3] * 255.0 + 0.5)];
1215
3229
  }
1216
3230
  if (_hasTransform) {
1217
3231
  // The 2D affine part, which is all either platform draws, in the order
@@ -1232,6 +3246,12 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
1232
3246
  if (scroll.x != 0 || scroll.y != 0) {
1233
3247
  [out appendFormat:@" scroll=(%g,%g)", scroll.x, scroll.y];
1234
3248
  }
3249
+ // `pagingEnabled`, `snapToInterval`, `snapToOffsets` and the rest, which
3250
+ // decide where a fling ends and so are invisible to a dump taken while
3251
+ // nothing moves. Formatted in core, so all three hosts print the same words.
3252
+ if (self.rnScrollSnap.length > 0) {
3253
+ [out appendString:self.rnScrollSnap];
3254
+ }
1235
3255
  // The overlay scrollbars, which are otherwise pure paint and so invisible to
1236
3256
  // every test this project has. Printed only when there is one, so a view that
1237
3257
  // does not scroll stays as short as it was.
@@ -1245,6 +3265,18 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
1245
3265
  _indicators.rnHorizontalOffset,
1246
3266
  _indicators.rnHorizontalLength];
1247
3267
  }
3268
+ // The thumb's colour, printed only when it is not the default black: a white
3269
+ // one is `indicatorStyle` having arrived, and the colour is the only part of
3270
+ // that a tree dump can see.
3271
+ if (_rnIndicatorColour != nil
3272
+ && (_rnIndicatorColour.redComponent != 0.0 || _rnIndicatorColour.greenComponent != 0.0
3273
+ || _rnIndicatorColour.blueComponent != 0.0)) {
3274
+ [out appendFormat:@" scrollbar-colour=(%g,%g,%g,%g)",
3275
+ _rnIndicatorColour.redComponent,
3276
+ _rnIndicatorColour.greenComponent,
3277
+ _rnIndicatorColour.blueComponent,
3278
+ _rnIndicatorColour.alphaComponent];
3279
+ }
1248
3280
  // Printed only when it is not the default, like every other field here.
1249
3281
  // Worth printing at all because it is invisible: a view with
1250
3282
  // `pointerEvents: none` is drawn exactly like one without, and the only way
@@ -1253,6 +3285,117 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
1253
3285
  if (self.rnPointerEvents != RnAppKitPointerEventsAuto) {
1254
3286
  [out appendFormat:@" pe=%s", RnAppKitPointerEventsName(self.rnPointerEvents)];
1255
3287
  }
3288
+ // Printed for the reason pointerEvents is, and spelled as GTK spells it: the
3289
+ // prop is invisible in a still picture, so a dump is the only thing that can
3290
+ // say it arrived on both hosts.
3291
+ if (_cursorName != nil) {
3292
+ [out appendFormat:@" cursor=%@", _cursorName];
3293
+ }
3294
+ // The blend mode the app asked for, spelled as GTK spells it: the keyword,
3295
+ // not the Core Image filter it became.
3296
+ if (_blendModeName != nil) {
3297
+ [out appendFormat:@" blend=%@", _blendModeName];
3298
+ }
3299
+ // The outline, which is invisible in every other line: it is not a border and
3300
+ // a view with one has the same frame and the same colours without it. Spelled
3301
+ // as GTK spells it.
3302
+ if (_outlineWidth > 0) {
3303
+ [out appendFormat:@" outline=(%g,%g,#%02x%02x%02x%02x",
3304
+ (double)_outlineWidth,
3305
+ (double)_outlineOffset,
3306
+ (unsigned)(_outlineColor[0] * 255.0 + 0.5),
3307
+ (unsigned)(_outlineColor[1] * 255.0 + 0.5),
3308
+ (unsigned)(_outlineColor[2] * 255.0 + 0.5),
3309
+ (unsigned)(_outlineColor[3] * 255.0 + 0.5)];
3310
+ if (_outlineStyle != RnAppKitBorderStyleSolid) {
3311
+ [out appendFormat:@",%s",
3312
+ _outlineStyle == RnAppKitBorderStyleDotted ? "dotted" : "dashed"];
3313
+ }
3314
+ [out appendString:@")"];
3315
+ }
3316
+
3317
+ // `filter`, as the pieces it came to plus what its matrix makes of one probe
3318
+ // colour, spelled exactly as GTK spells it: sixteen numbers would drown the
3319
+ // line, and one colour still fails when a matrix is wrong. The probe is
3320
+ // (1, 0.5, 0.25) so no two channels can be swapped unnoticed.
3321
+ if (_filters != nil || _filterOpacity < 1 || _filterShadow.color[3] > 0) {
3322
+ [out appendString:@" filter=("];
3323
+ BOOL first = YES;
3324
+ for (CIFilter *filter in _filters) {
3325
+ if ([filter.name isEqualToString:@"CIColorMatrix"]) {
3326
+ const CGFloat probe[4] = {1.0, 0.5, 0.25, 1.0};
3327
+ CGFloat result[4] = {0, 0, 0, 0};
3328
+ NSArray<NSString *> *keys =
3329
+ @[ @"inputRVector", @"inputGVector", @"inputBVector", @"inputAVector" ];
3330
+ CIVector *bias = [filter valueForKey:@"inputBiasVector"];
3331
+ for (int row = 0; row < 4; row++) {
3332
+ CIVector *weights = [filter valueForKey:keys[(NSUInteger)row]];
3333
+ result[row] = [bias valueAtIndex:(size_t)row] + weights.X * probe[0] +
3334
+ weights.Y * probe[1] + weights.Z * probe[2] + weights.W * probe[3];
3335
+ }
3336
+ const auto byte = [](CGFloat value) {
3337
+ const CGFloat clamped = value < 0 ? 0 : (value > 1 ? 1 : value);
3338
+ return (unsigned)(clamped * 255.0 + 0.5);
3339
+ };
3340
+ [out appendFormat:@"probe=#%02x%02x%02x%02x",
3341
+ byte(result[0]),
3342
+ byte(result[1]),
3343
+ byte(result[2]),
3344
+ byte(result[3])];
3345
+ first = NO;
3346
+ } else if ([filter.name isEqualToString:@"CIGaussianBlur"]) {
3347
+ const double sigma = [[filter valueForKey:@"inputRadius"] doubleValue];
3348
+ [out appendFormat:@"%sblur=%g", first ? "" : ",", sigma * 2.0];
3349
+ first = NO;
3350
+ }
3351
+ }
3352
+ if (_filterOpacity < 1) {
3353
+ [out appendFormat:@"%sopacity=%g", first ? "" : ",", (double)_filterOpacity];
3354
+ first = NO;
3355
+ }
3356
+ // The drop shadow, with the standard deviation React Native parsed, which is
3357
+ // also what this layer was given. GTK prints the same number from a radius
3358
+ // it doubled.
3359
+ if (_filterShadow.color[3] > 0) {
3360
+ [out appendFormat:@"%sshadow=(%g,%g,%g,#%02x%02x%02x%02x)",
3361
+ first ? "" : ",",
3362
+ (double)_filterShadow.dx,
3363
+ (double)_filterShadow.dy,
3364
+ (double)_filterShadow.standardDeviation,
3365
+ (unsigned)(_filterShadow.color[0] * 255.0 + 0.5),
3366
+ (unsigned)(_filterShadow.color[1] * 255.0 + 0.5),
3367
+ (unsigned)(_filterShadow.color[2] * 255.0 + 0.5),
3368
+ (unsigned)(_filterShadow.color[3] * 255.0 + 0.5)];
3369
+ }
3370
+ [out appendString:@")"];
3371
+ }
3372
+
3373
+ // `hitSlop`, which is invisible in every other line of this dump: a view with
3374
+ // a bigger target is drawn exactly like one without.
3375
+ if (_hasHitSlop) {
3376
+ [out appendFormat:@" hit-slop=(%g,%g,%g,%g)",
3377
+ (double)_hitSlop[0],
3378
+ (double)_hitSlop[1],
3379
+ (double)_hitSlop[2],
3380
+ (double)_hitSlop[3]];
3381
+ }
3382
+ // The resolved labelled-by relation, by tag, spelled as GTK spells it. Every
3383
+ // tag that resolved, not only the one AppKit could use, so the two hosts print
3384
+ // the same line and the difference stays in what each platform does with it.
3385
+ if (!_labelledBy.empty()) {
3386
+ [out appendString:@" labelled-by="];
3387
+ for (size_t i = 0; i < _labelledBy.size(); i++) {
3388
+ [out appendFormat:@"%s%ld", i == 0 ? "" : ",", (long)_labelledBy[i]];
3389
+ }
3390
+ }
3391
+ // The resolved reading order, by tag and in the order the app asked for,
3392
+ // spelled as GTK spells it so the two dumps compare.
3393
+ if (!_accessibilityOrder.empty()) {
3394
+ [out appendString:@" a11y-order="];
3395
+ for (size_t i = 0; i < _accessibilityOrder.size(); i++) {
3396
+ [out appendFormat:@"%s%ld", i == 0 ? "" : ",", (long)_accessibilityOrder[i]];
3397
+ }
3398
+ }
1256
3399
  if (_image != nullptr) {
1257
3400
  // The same `texture=WxH fit=<name>` the GTK side emits, so an <Image> shows
1258
3401
  // up in the cross-platform diff and the end-to-end suite can assert on it.
@@ -1261,6 +3404,14 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
1261
3404
  // identical in every other line of this dump and different on screen.
1262
3405
  [out appendFormat:@" texture=%zux%zu", CGImageGetWidth(_image), CGImageGetHeight(_image)];
1263
3406
  [out appendFormat:@" fit=%s", RnAppKitImageFitName(_imageFit)];
3407
+ // That this image moves, which no other line can show: an animated GIF and
3408
+ // its first frame are the same size and the same picture in a snapshot.
3409
+ // Not which frame, deliberately -- the two hosts tick on their own clocks
3410
+ // and a cross-host diff would then be a race. Each suite asserts the frames
3411
+ // itself.
3412
+ if (_imageFrames.count > 1) {
3413
+ [out appendString:@" animated=1"];
3414
+ }
1264
3415
  // Printed for the same reason the fit is: a tinted image and an untinted
1265
3416
  // one are identical in every other line of this dump and different on
1266
3417
  // screen. Formatted exactly as GTK formats a colour, so the cross-host diff
@@ -1273,6 +3424,13 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
1273
3424
  (unsigned)(rgb.blueComponent * 255.0 + 0.5),
1274
3425
  (unsigned)(rgb.alphaComponent * 255.0 + 0.5)];
1275
3426
  }
3427
+ // And the blur, spelled as GTK spells it. Invisible in this dump for the
3428
+ // reason the tint is, and in one more: the prop is read in a branch that
3429
+ // knows ImageProps, and a line here is the only thing that can say it got
3430
+ // out of that branch and onto the view.
3431
+ if (_imageBlur > 0) {
3432
+ [out appendFormat:@" blur=%g", (double)_imageBlur];
3433
+ }
1276
3434
  }
1277
3435
  if (_textLayout != nil) {
1278
3436
  NSString *text = _textLayout.attributedString.string;
@@ -1289,6 +3447,80 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
1289
3447
  [out appendFormat:@" text=\"%@\"", escaped];
1290
3448
  }
1291
3449
  }
3450
+ // The paragraph's writing direction, when an app asked for one. Nothing else
3451
+ // here shows it: a right-to-left paragraph of Latin text has the same box and
3452
+ // the same string, and only the pixels differ. `natural` is printed too,
3453
+ // asking for the algorithm's answer not being the same as saying nothing.
3454
+ if (self.rnTextAlign.length > 0) {
3455
+ [out appendFormat:@" text-align=%@", self.rnTextAlign];
3456
+ }
3457
+ if (self.rnTextVerticalAlign.length > 0) {
3458
+ [out appendFormat:@" text-valign=%@", self.rnTextVerticalAlign];
3459
+ }
3460
+ // A <Text>'s own padding and border. Invisible in every other line here --
3461
+ // the frame is the box including the padding, and the string is the same
3462
+ // string -- and it decides where the text is drawn and how wide it wraps.
3463
+ if (_rnTextInset.left != 0 || _rnTextInset.top != 0 || _rnTextInset.right != 0 ||
3464
+ _rnTextInset.bottom != 0) {
3465
+ [out appendFormat:@" text-inset=%g,%g,%g,%g", _rnTextInset.left, _rnTextInset.top,
3466
+ _rnTextInset.right, _rnTextInset.bottom];
3467
+ }
3468
+ // `<Text selectable>`, and what is selected in it. Neither can be seen any
3469
+ // other way in a dump: the prop changes no box and the highlight is a wash
3470
+ // under the glyphs. The range is in this host's own unit -- UTF-16 code
3471
+ // units, where GTK counts bytes -- so an end-to-end scenario compares
3472
+ // "something is selected" rather than the numbers.
3473
+ if (self.rnTextSelectable) {
3474
+ [out appendString:@" selectable"];
3475
+ }
3476
+ if ([self rnTextSelectionLength] > 0) {
3477
+ [out appendFormat:@" selection=%ld,%ld", (long)[self rnTextSelectionStart],
3478
+ (long)[self rnTextSelectionLength]];
3479
+ }
3480
+ if (self.rnWritingDirection.length > 0) {
3481
+ [out appendFormat:@" writing-dir=%@", self.rnWritingDirection];
3482
+ }
3483
+ // What a field asked for about spelling, in the same words GTK prints.
3484
+ // Neither appears for a field that said nothing.
3485
+ if (self.rnSpellCheck.length > 0) {
3486
+ [out appendFormat:@" spellcheck=%@", self.rnSpellCheck];
3487
+ }
3488
+ if (self.rnAutoCorrect.length > 0) {
3489
+ [out appendFormat:@" autocorrect=%@", self.rnAutoCorrect];
3490
+ }
3491
+ // What the app asked for about capitalisation and the keyboard, neither of
3492
+ // which this platform can act on. The words are GTK's so the dumps compare.
3493
+ if (self.rnAutoCapitalize.length > 0) {
3494
+ [out appendFormat:@" autocapitalize=%@", self.rnAutoCapitalize];
3495
+ }
3496
+ if (self.rnKeyboardType.length > 0) {
3497
+ [out appendFormat:@" keyboard=%@", self.rnKeyboardType];
3498
+ }
3499
+ // `caretHidden` and `contextMenuHidden`, printed only when asked for. Each is
3500
+ // the absence of something -- a blink, a menu -- so there is nothing else for
3501
+ // a test or the cross-host diff to look at.
3502
+ if (self.rnCaretHidden) {
3503
+ [out appendString:@" caret=hidden"];
3504
+ }
3505
+ if (self.rnContextMenuHidden) {
3506
+ [out appendString:@" context-menu=hidden"];
3507
+ }
3508
+ // The paragraph's text shadow, spelled as GTK spells it: no other line can
3509
+ // show it, a shadowed paragraph having the same text, colour and box. The
3510
+ // standard deviation React Native parsed, which is also what this context was
3511
+ // given.
3512
+ if (_textLayout != nil && _textLayout.shadowColor != nil) {
3513
+ NSColor *colour = [_textLayout.shadowColor
3514
+ colorUsingColorSpace:[NSColorSpace sRGBColorSpace]];
3515
+ [out appendFormat:@" text-shadow=(%g,%g,%g,#%02x%02x%02x%02x)",
3516
+ (double)_textLayout.shadowOffset.width,
3517
+ (double)_textLayout.shadowOffset.height,
3518
+ (double)_textLayout.shadowStandardDeviation,
3519
+ (unsigned)(colour.redComponent * 255.0 + 0.5),
3520
+ (unsigned)(colour.greenComponent * 255.0 + 0.5),
3521
+ (unsigned)(colour.blueComponent * 255.0 + 0.5),
3522
+ (unsigned)(colour.alphaComponent * 255.0 + 0.5)];
3523
+ }
1292
3524
 
1293
3525
  // A text field's content lives in its NSTextField peer, not in anything this
1294
3526
  // view draws, so it would otherwise be invisible to every test that reads
@@ -1339,6 +3571,12 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
1339
3571
  if (_roleName != nil) {
1340
3572
  [out appendFormat:@" role=%@", _roleName];
1341
3573
  }
3574
+ // `testID`, as React Native spelled it rather than as AppKit publishes it,
3575
+ // which is the same division as the role: that this platform really set
3576
+ // `accessibilityIdentifier` is asserted in tests/test_appkit_accessibility.mm.
3577
+ if (self.rnTestId.length > 0) {
3578
+ [out appendFormat:@" testid=%@", self.rnTestId];
3579
+ }
1342
3580
  // Whether Tab stops here, after `role=` because that is where the GTK side
1343
3581
  // prints it and this dump is diffed line by line. Whether it is focused *now*
1344
3582
  // is deliberately not printed: that depends on what the window server did
@@ -1347,6 +3585,11 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
1347
3585
  if (self.rnFocusable) {
1348
3586
  [out appendString:@" focusable"];
1349
3587
  }
3588
+ // `accessibilityViewIsModal`, after `focusable` because that is the order the
3589
+ // GTK side prints and this dump is diffed line by line.
3590
+ if (self.rnAccessibleModal) {
3591
+ [out appendString:@" modal"];
3592
+ }
1350
3593
  [out appendString:@"\n"];
1351
3594
 
1352
3595
  for (NSView *child in self.subviews) {