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
@@ -6,16 +6,29 @@
6
6
  #import <CoreImage/CoreImage.h>
7
7
 
8
8
  #include "ComponentRegistry.h"
9
+ #include "BackgroundLayers.h"
10
+ #include "TextShadows.h"
11
+ #include "TextAlignments.h"
12
+ #include "WritingDirections.h"
13
+ #include "BlendModes.h"
14
+ #include "LegacyShadow.h"
15
+ #include "CursorNames.h"
16
+ #include "Filters.h"
17
+ #include "Gradients.h"
9
18
  #include "ExpoImageComponent.h"
19
+ #include "ImageProgress.h"
10
20
  #ifdef BASALT_HAS_SKIA
11
21
  #include "AppKitSkiaPeer.h"
12
22
  #endif
13
23
  #include "UIManagerAccess.h"
14
24
 
25
+ #include <cstdint>
26
+
15
27
  #include <react/renderer/components/view/AccessibilityProps.h>
16
28
  #include <react/renderer/components/image/ImageEventEmitter.h>
17
29
  #include <react/renderer/components/image/ImageProps.h>
18
30
  #include <react/renderer/components/scrollview/ScrollViewProps.h>
31
+ #include <react/renderer/components/text/ParagraphProps.h>
19
32
  #include <react/renderer/components/text/ParagraphState.h>
20
33
  #include <react/renderer/components/view/ViewProps.h>
21
34
  #include <react/renderer/core/ConcreteState.h>
@@ -107,10 +120,22 @@ struct PendingMount {
107
120
  AppKitMountingManager *manager;
108
121
  SurfaceId surfaceId;
109
122
  MountingTransaction transaction;
123
+ // Whether `manager` is still there, and which instance this came from. See
124
+ // MountingWalk::mountGuard.
125
+ std::weak_ptr<bool> alive;
126
+ std::uint64_t epoch;
110
127
  };
111
128
 
112
129
  void applyPendingMount(void *data) {
113
130
  std::unique_ptr<PendingMount> pending{static_cast<PendingMount *>(data)};
131
+ // The guard first: dereferencing `manager` to read its epoch is itself the
132
+ // use-after-free when the host has already torn it down.
133
+ if (pending->alive.expired()) {
134
+ return;
135
+ }
136
+ if (pending->manager->mountEpoch() != pending->epoch) {
137
+ return;
138
+ }
114
139
  pending->manager->applyTransaction(pending->surfaceId, std::move(pending->transaction));
115
140
  }
116
141
 
@@ -127,15 +152,30 @@ void AppKitMountingManager::executeMount(SurfaceId surfaceId, MountingTransactio
127
152
  // The main queue is FIFO, which is what keeps mutation ordering intact, and
128
153
  // running one transaction inline while another is queued would break it. iOS
129
154
  // and Android marshal here too.
130
- auto *pending = new PendingMount{this, surfaceId, std::move(transaction)};
155
+ auto *pending =
156
+ new PendingMount{this, surfaceId, std::move(transaction), mountGuard(), mountEpoch()};
131
157
  dispatch_async_f(dispatch_get_main_queue(), pending, applyPendingMount);
132
158
  }
133
159
 
134
160
  void AppKitMountingManager::applyTransaction(SurfaceId surfaceId, MountingTransaction &&transaction) {
161
+ // `maintainVisibleContentPosition` is measured before the mutations and acted
162
+ // on after them, which is the only place either half can go: the prop is
163
+ // about how far a child moved *during* a transaction. iOS hangs the same pair
164
+ // off `mountingTransactionWillMount` and `...DidMount`.
165
+ scrollViews_.prepareMaintainVisiblePosition();
166
+
135
167
  // The walk itself is in core/MountingWalk.h and is shared with GTK; what is
136
168
  // AppKit about mounting is below, in the operations it calls back into.
137
169
  applyMutations(transaction.getMutations());
138
170
 
171
+ // And the other half, before anything repaints: the offset moves so that the
172
+ // child the list was looking at is still under the eye.
173
+ scrollViews_.adjustForMaintainVisiblePosition();
174
+
175
+ // Now that the tree is on screen, anything `autoFocus` asked for can be given
176
+ // focus. Not before: see AppKitTextInput.h.
177
+ textInputs_.flushAutoFocus();
178
+
139
179
  // Tell the UIManager the transaction is on screen. Anything registered as a
140
180
  // mount hook -- Reanimated's is the one that matters here -- is waiting for
141
181
  // this, and without it an animated style is computed every frame, committed
@@ -145,8 +185,81 @@ void AppKitMountingManager::applyTransaction(SurfaceId surfaceId, MountingTransa
145
185
  // manager. ReactCxxPlatform does it nowhere, which is a gap in the shared
146
186
  // platform rather than in either host: nothing in it had a mount hook until
147
187
  // a third-party library brought one.
148
- if (auto uiManager = sharedUIManager()) {
149
- uiManager->reportMount(surfaceId);
188
+ // Refuses a surface the UIManager no longer has, which is what a reload's
189
+ // teardown leaves behind. See core/UIManagerAccess.h.
190
+ reportMountedSurface(surfaceId);
191
+
192
+ // After the mutations, because a view can be labelled by one that mounts after
193
+ // it: Fabric mounts in tree order, and the label of a field often follows it.
194
+ applyLabelRelations();
195
+
196
+ // And after those, because an announcement is the last word on a transaction:
197
+ // the text a live region says now is what every mutation in this batch has left
198
+ // it saying.
199
+ announceLiveRegions();
200
+ }
201
+
202
+ void AppKitMountingManager::announceLiveRegions() {
203
+ if (liveRegions_.empty()) {
204
+ return;
205
+ }
206
+ for (const facebook::react::Tag tag : liveRegions_.tags()) {
207
+ RnAppKitView *view = viewForTag(tag);
208
+ if (view == nil) {
209
+ continue;
210
+ }
211
+ // A label stands in for the text when the app set one: an icon-only status
212
+ // has no paragraph of its own, and the label is what would be read. The GTK
213
+ // host makes the same choice in the same order.
214
+ const auto label = liveRegionLabels_.find(tag);
215
+ std::string text = label != liveRegionLabels_.end() ? label->second : std::string();
216
+ if (text.empty()) {
217
+ text = view.rnCollectedText.UTF8String != nullptr ? view.rnCollectedText.UTF8String : "";
218
+ }
219
+ const auto politeness = liveRegions_.noticed(tag, text);
220
+ if (!politeness.has_value()) {
221
+ continue;
222
+ }
223
+ const BOOL assertive = *politeness == basalt::LiveRegionPoliteness::Assertive ? YES : NO;
224
+ [view rnAnnounce:[NSString stringWithUTF8String:text.c_str()] assertive:assertive];
225
+ // Logged as well as announced: nothing in an automated run is running
226
+ // VoiceOver, so this line is how an end-to-end test sees that it happened.
227
+ LOG(INFO) << "announced" << (assertive ? " (assertive): " : ": ") << text;
228
+ }
229
+ }
230
+
231
+ void AppKitMountingManager::applyLabelRelations() {
232
+ if (labels_.empty()) {
233
+ return;
234
+ }
235
+ for (const auto &change : labels_.changes()) {
236
+ RnAppKitView *view = viewForTag(change.tag);
237
+ if (view == nil) {
238
+ continue;
239
+ }
240
+ NSMutableArray<RnAppKitView *> *resolved = [NSMutableArray array];
241
+ for (const facebook::react::Tag tag : change.labels) {
242
+ if (RnAppKitView *label = viewForTag(tag); label != nil) {
243
+ [resolved addObject:label];
244
+ }
245
+ }
246
+ [view setRnLabelledBy:resolved];
247
+ }
248
+
249
+ // The reading order, from the same registry and on the same terms: only what
250
+ // changed, and an empty list is a view that stopped asking.
251
+ for (const auto &change : labels_.orderChanges()) {
252
+ RnAppKitView *view = viewForTag(change.tag);
253
+ if (view == nil) {
254
+ continue;
255
+ }
256
+ NSMutableArray<RnAppKitView *> *children = [NSMutableArray array];
257
+ for (const facebook::react::Tag tag : change.labels) {
258
+ if (RnAppKitView *child = viewForTag(tag); child != nil) {
259
+ [children addObject:child];
260
+ }
261
+ }
262
+ [view setRnAccessibilityOrder:children];
150
263
  }
151
264
  }
152
265
 
@@ -204,6 +317,17 @@ void AppKitMountingManager::setUIManager(std::weak_ptr<facebook::react::UIManage
204
317
  setSharedUIManager(std::move(uiManager));
205
318
  }
206
319
 
320
+ void AppKitMountingManager::setSchedulerTaskExecutor(
321
+ facebook::react::SchedulerTaskExecutor &&schedulerTaskExecutor) noexcept {
322
+ // A null executor means `destroyReactInstance`: the Scheduler and the
323
+ // SurfaceManager are about to go, and anything this already queued to the main
324
+ // queue would be applied against them. Non-null means a new instance, which
325
+ // needs nothing from here.
326
+ if (!schedulerTaskExecutor) {
327
+ invalidatePendingMounts();
328
+ }
329
+ }
330
+
207
331
  ComponentRegistryFactory AppKitMountingManager::getComponentRegistryFactory() {
208
332
  return facebook::react::getDefaultComponentRegistryFactory();
209
333
  }
@@ -261,6 +385,12 @@ void AppKitMountingManager::removeChild(RnAppKitView *parent, RnAppKitView *chil
261
385
  }
262
386
 
263
387
  void AppKitMountingManager::forgetTag(Tag tag) {
388
+ liveRegions_.forget(tag);
389
+ liveRegionLabels_.erase(tag);
390
+ // Both sides of a label relation: a view that goes away stops naming anything
391
+ // and stops being nameable, and a relation left pointing at it would have
392
+ // VoiceOver read a view that is no longer on screen.
393
+ labels_.forget(tag);
264
394
  scrollViews_.remove(tag);
265
395
  textInputs_.remove(tag);
266
396
  imageUris_.erase(tag);
@@ -276,6 +406,31 @@ void AppKitMountingManager::forgetTag(Tag tag) {
276
406
 
277
407
  namespace {
278
408
 
409
+ // Hands a view the frames of an animated image, or clears any it had.
410
+ //
411
+ // Asked of the loader rather than carried in the load callback, because being
412
+ // animated is a property of the file and the callback's job is the pixels. The
413
+ // arrays are the loader's and are the same arrays each time, which is what lets
414
+ // the view tell a re-mount from a new animation.
415
+ void applyImageAnimation(AppKitImageLoader *loader, RnAppKitView *view, const std::string &uri) {
416
+ if (view == nil) {
417
+ return;
418
+ }
419
+ const AppKitImageLoader::Animation *animation =
420
+ uri.empty() ? nullptr : loader->animation(uri);
421
+ if (animation == nullptr) {
422
+ [view setRnImageFrames:nil delaysMs:nil loopCount:0];
423
+ return;
424
+ }
425
+ [view setRnImageFrames:animation->frames
426
+ delaysMs:animation->delaysMs
427
+ loopCount:animation->loopCount];
428
+ // And drive it, which the view deliberately does not do for itself; see
429
+ // rnStartImageAnimation. Idempotent, so the re-apply on every mutation costs
430
+ // nothing.
431
+ [view rnStartImageAnimation];
432
+ }
433
+
279
434
  RnAppKitImageFit toImageFit(ImageResizeMode mode) {
280
435
  switch (mode) {
281
436
  case ImageResizeMode::Contain:
@@ -319,6 +474,10 @@ void AppKitMountingManager::applyImage(RnAppKitView *view, const ShadowView &sha
319
474
  // unqualified ImageSource resolves to that one.
320
475
  facebook::react::ImageSource source{};
321
476
  facebook::react::SharedColor tint{};
477
+ // blurRadius alongside the tint, captured the same way: each branch below
478
+ // knows its own props type, and both props are applied together after them.
479
+ // expo-image has no blur prop, so that branch leaves this at zero.
480
+ CGFloat blurRadius = 0;
322
481
 
323
482
  if (isExpoImage) {
324
483
  const auto props = std::dynamic_pointer_cast<const facebook::react::ExpoImageProps>(shadowView.props);
@@ -344,13 +503,14 @@ void AppKitMountingManager::applyImage(RnAppKitView *view, const ShadowView &sha
344
503
  if (props->tintColor) {
345
504
  tint = *props->tintColor;
346
505
  }
506
+ blurRadius = (CGFloat)props->blurRadius;
347
507
  }
348
508
 
349
509
  const std::string uri = source.uri;
350
510
  const Tag tag = shadowView.tag;
351
511
 
352
- // Applied before the load rather than in its callback: the tint is a prop and
353
- // the image is a loader's answer, and either can arrive first.
512
+ // Applied before the load rather than in its callback: the tint and the blur
513
+ // are props where the image is a loader's answer, and either can arrive first.
354
514
  if (RnAppKitView *tinted = viewForTag(tag); tinted != nil) {
355
515
  if (tint) {
356
516
  const ColorComponents components = colorComponentsFromColor(tint);
@@ -361,6 +521,7 @@ void AppKitMountingManager::applyImage(RnAppKitView *view, const ShadowView &sha
361
521
  } else {
362
522
  [tinted setRnImageTint:nil];
363
523
  }
524
+ [tinted setRnImageBlur:blurRadius];
364
525
  }
365
526
 
366
527
  // A mutation that changed only layout must not restart the load, or an
@@ -370,9 +531,10 @@ void AppKitMountingManager::applyImage(RnAppKitView *view, const ShadowView &sha
370
531
  const auto known = imageUris_.find(tag);
371
532
  if (known != imageUris_.end() && known->second == uri) {
372
533
  if (!uri.empty()) {
373
- imageLoader_->load(uri, [this, tag, fit](CGImageRef image, const std::string &) {
534
+ imageLoader_->load(uri, [this, tag, fit, uri](CGImageRef image, const std::string &) {
374
535
  if (RnAppKitView *target = viewForTag(tag); target != nil) {
375
536
  [target setRnImage:image fit:fit];
537
+ applyImageAnimation(imageLoader_.get(), target, uri);
376
538
  }
377
539
  });
378
540
  }
@@ -383,6 +545,7 @@ void AppKitMountingManager::applyImage(RnAppKitView *view, const ShadowView &sha
383
545
 
384
546
  if (uri.empty()) {
385
547
  [view setRnImage:nullptr fit:fit];
548
+ applyImageAnimation(imageLoader_.get(), view, uri);
386
549
  return;
387
550
  }
388
551
 
@@ -411,12 +574,34 @@ void AppKitMountingManager::applyImage(RnAppKitView *view, const ShadowView &sha
411
574
  emitter->onLoadStart();
412
575
  }
413
576
 
577
+ // `<Image onProgress>`, for an http source: the bytes as they arrive, from
578
+ // the fetching thread. The emitter is looked up *here*, on the main thread,
579
+ // and captured -- `eventEmitterForTag` reads a map only this thread may touch
580
+ // (see core/MountingWalk.h), and a progress tick arrives on another.
581
+ //
582
+ // Which ticks become events is core/ImageProgress.h's: all three hosts gate
583
+ // them the same way, and the arithmetic is testable where the lambda is not.
584
+ // expo-image has no progress event, so its branch reports nothing.
585
+ basalt::ImageProgress reportProgress;
586
+ if (!isExpoImage) {
587
+ if (auto emitter =
588
+ std::dynamic_pointer_cast<const ImageEventEmitter>(eventEmitterForTag(tag))) {
589
+ auto ticker = std::make_shared<basalt::ImageProgressTicker>();
590
+ reportProgress = [emitter, ticker](long long loaded, long long total) {
591
+ if (const auto progress = ticker->tick(loaded, total)) {
592
+ emitter->onProgress(*progress, loaded, total);
593
+ }
594
+ };
595
+ }
596
+ }
597
+
414
598
  imageLoader_->load(uri, [this, tag, fit, source, isExpoImage](CGImageRef image,
415
599
  const std::string &error) {
416
600
  // The view may have been deleted while the image was in flight, which is
417
601
  // why this looks the tag up again rather than capturing the view.
418
602
  if (RnAppKitView *target = viewForTag(tag); target != nil) {
419
603
  [target setRnImage:image fit:fit];
604
+ applyImageAnimation(imageLoader_.get(), target, source.uri);
420
605
  }
421
606
 
422
607
  if (image == nullptr) {
@@ -446,12 +631,26 @@ void AppKitMountingManager::applyImage(RnAppKitView *view, const ShadowView &sha
446
631
  return;
447
632
  }
448
633
  if (image != nullptr) {
449
- emitter->onLoad(source);
634
+ // The decoded size, which the props do not carry for a remote image:
635
+ // `onLoad`'s payload is `source.size` times `source.scale`, so a source
636
+ // that arrived as `{uri}` and nothing else reported zero by zero on
637
+ // every host. iOS fills it in the same place and the same way, from the
638
+ // image it has just decoded -- `RCTImageComponentView`'s
639
+ // `didReceiveImage:` assigns `imageSource.size` before emitting.
640
+ //
641
+ // Divided by the scale because the payload multiplies by it: the event
642
+ // reports pixels, and a 2x asset is half as many points.
643
+ facebook::react::ImageSource loaded = source;
644
+ const double scale = loaded.scale > 0 ? loaded.scale : 1.0;
645
+ loaded.size = {static_cast<facebook::react::Float>(CGImageGetWidth(image) / scale),
646
+ static_cast<facebook::react::Float>(CGImageGetHeight(image) / scale)};
647
+ emitter->onLoad(loaded);
450
648
  } else {
451
649
  emitter->onError(facebook::react::ImageErrorInfo{.error = error});
452
650
  }
453
651
  emitter->onLoadEnd();
454
- });
652
+ },
653
+ std::move(reportProgress));
455
654
  }
456
655
 
457
656
  void AppKitMountingManager::updateView(RnAppKitView *view, const ShadowView &shadowView) {
@@ -658,7 +857,63 @@ void AppKitMountingManager::applyText(RnAppKitView *view, const ShadowView &shad
658
857
  // Built through the same function the measurement seam uses, which is what
659
858
  // makes the painted lines break where the measured ones did. The width is not
660
859
  // baked in -- the view draws at whatever size Yoga gave it.
661
- [view setRnTextLayout:basalt::buildTextLayout(data.attributedString, data.paragraphAttributes)];
860
+ // `adjustsFontSizeToFit` again, against the frame rather than the constraint:
861
+ // the text is painted into the box Yoga assigned, which is the box it has to
862
+ // fit. Measuring and painting run the same search through the same builder,
863
+ // which is what keeps the painted size the measured one.
864
+ // The *content* box, not the frame: a <Text> may carry padding and a border,
865
+ // and Yoga measured the paragraph against the size inside them. See
866
+ // RnAppKitView's rnTextInset.
867
+ const auto &insets = shadowView.layoutMetrics.contentInsets;
868
+ const basalt::FontFit fit =
869
+ basalt::textFitScale(data.attributedString,
870
+ data.paragraphAttributes,
871
+ static_cast<float>(shadowView.layoutMetrics.frame.size.width -
872
+ insets.left - insets.right),
873
+ static_cast<float>(shadowView.layoutMetrics.frame.size.height -
874
+ insets.top - insets.bottom));
875
+ RnTextLayout *layout =
876
+ basalt::buildTextLayout(data.attributedString, data.paragraphAttributes, fit);
877
+
878
+ // The paragraph's text shadow, resolved in core/TextShadows.h: the props are
879
+ // per fragment and one Core Text frame draws them all, so the first fragment
880
+ // that asks for a shadow decides it. The standard deviation crosses unchanged,
881
+ // `CGContextSetShadowWithColor`'s blur being one, where the GTK side doubles
882
+ // it for GSK.
883
+ // The paragraph's writing direction, for the dump: the paragraph style the
884
+ // layout carries is what actually decides it.
885
+ const char *const direction =
886
+ basalt::writingDirectionName(basalt::writingDirection(data.attributedString));
887
+ view.rnWritingDirection = direction != nullptr ? @(direction) : nil;
888
+ // And the edge its text sits against, resolved: `textAlign` is relative in
889
+ // two of its five spellings, so the prop is not the answer. See
890
+ // core/TextAlignments.h.
891
+ view.rnTextAlign = @(basalt::paragraphTextAlignmentName(data.attributedString));
892
+ const char *const verticalAlign =
893
+ basalt::textAlignVerticalName(data.paragraphAttributes.textAlignVertical);
894
+ view.rnTextVerticalAlign = verticalAlign != nullptr ? @(verticalAlign) : nil;
895
+ // The padding and border the paragraph sits inside, which the view draws at
896
+ // and hit-tests against. Yoga has already laid the box out with them; this is
897
+ // what stops the text being drawn underneath its own padding.
898
+ view.rnTextInset = NSEdgeInsetsMake(insets.top, insets.left, insets.bottom, insets.right);
899
+
900
+ // `<Text selectable>`, which `userSelect` also arrives in: React Native's own
901
+ // `Text.js` maps the style onto the prop. From the props rather than the
902
+ // state, unlike the text itself.
903
+ const auto paragraphProps =
904
+ std::dynamic_pointer_cast<const facebook::react::ParagraphProps>(shadowView.props);
905
+ [view setRnTextSelectable:paragraphProps != nullptr && paragraphProps->isSelectable ? YES : NO];
906
+
907
+ if (const auto shadow = basalt::textShadow(data.attributedString)) {
908
+ layout.shadowOffset = CGSizeMake(shadow->dx, shadow->dy);
909
+ layout.shadowStandardDeviation = shadow->standardDeviation;
910
+ layout.shadowColor = [NSColor colorWithSRGBRed:shadow->red
911
+ green:shadow->green
912
+ blue:shadow->blue
913
+ alpha:shadow->alpha];
914
+ }
915
+
916
+ [view setRnTextLayout:layout];
662
917
  }
663
918
 
664
919
  void AppKitMountingManager::applyProps(RnAppKitView *view, const ShadowView &shadowView) {
@@ -752,6 +1007,24 @@ void AppKitMountingManager::applyProps(RnAppKitView *view, const ShadowView &sha
752
1007
  // The app's nativeID, which marks a view for a behaviour React Native has
753
1008
  // no prop for -- a drop target today. See core/DragAndDrop.h, and
754
1009
  // core/TitleBarRegions.h for the older use of the same idea.
1010
+ // Which view this nativeID names, which is what `accessibilityLabelledBy`
1011
+ // needs: that prop names other views by their nativeID and nothing else here
1012
+ // can look one up. See core/LabelRegistry.h.
1013
+ labels_.setNativeId(shadowView.tag, props->nativeId);
1014
+ // `experimental_accessibilityOrder`, which names children by their nativeID
1015
+ // the way `accessibilityLabelledBy` names labels, and is resolved by the same
1016
+ // registry for the same reason: a view can be named before it mounts.
1017
+ labels_.setAccessibilityOrder(shadowView.tag, props->accessibilityOrder);
1018
+
1019
+ // `testID`, which is not `nativeID`: one is how an app names a view to
1020
+ // itself, the other is how something outside the process finds it.
1021
+ view.rnTestId = props->testId.empty()
1022
+ ? nil
1023
+ : [NSString stringWithUTF8String:props->testId.c_str()];
1024
+
1025
+ // `accessibilityViewIsModal`, which keeps VoiceOver inside a <Modal>.
1026
+ view.rnAccessibleModal = props->accessibilityViewIsModal ? YES : NO;
1027
+
755
1028
  view.rnNativeId = props->nativeId.empty()
756
1029
  ? nil
757
1030
  : [NSString stringWithUTF8String:props->nativeId.c_str()];
@@ -774,6 +1047,269 @@ void AppKitMountingManager::applyProps(RnAppKitView *view, const ShadowView &sha
774
1047
  view.rnPointerEvents = RnAppKitPointerEventsAuto;
775
1048
  break;
776
1049
  }
1050
+
1051
+ // backgroundImage: linear and radial gradients. The angle or the ending shape,
1052
+ // the box size and CSS's colour-stop fixup are resolved here through
1053
+ // core/Gradients.h, shared with the GTK host, and the view is handed points
1054
+ // and radii and a list of stops.
1055
+ //
1056
+ // One list rather than two, because `background-image` is one list and the
1057
+ // first in it is the one on top: two lists would lose the order between a
1058
+ // radial gradient and a linear one.
1059
+ //
1060
+ // Against this mutation's own frame, and applyProps runs on every update, so a
1061
+ // resized view gets a resized gradient without the view layer knowing anything
1062
+ // about angles or corners.
1063
+ {
1064
+ const auto &metrics = shadowView.layoutMetrics;
1065
+ // The two areas CSS gives a background: the painting area is the border box,
1066
+ // which it is clipped to, and the positioning area is the padding box, which
1067
+ // sizes and positions it. getPaddingFrame's origin is already relative to
1068
+ // the view, being the border widths.
1069
+ const basalt::BackgroundArea painting{
1070
+ 0.0F, 0.0F, (float)metrics.frame.size.width, (float)metrics.frame.size.height};
1071
+ const auto paddingFrame = metrics.getPaddingFrame();
1072
+ const basalt::BackgroundArea positioning{(float)paddingFrame.origin.x,
1073
+ (float)paddingFrame.origin.y,
1074
+ (float)paddingFrame.size.width,
1075
+ (float)paddingFrame.size.height};
1076
+
1077
+ std::vector<RnAppKitGradient> gradients;
1078
+ std::vector<std::vector<RnAppKitGradientStop>> stops;
1079
+ gradients.reserve(props->backgroundImage.size());
1080
+ stops.reserve(props->backgroundImage.size());
1081
+ for (size_t index = 0; index < props->backgroundImage.size(); index++) {
1082
+ const auto &image = props->backgroundImage[index];
1083
+ // Each list is indexed modulo its own length, which is CSS's rule for a
1084
+ // list shorter than the image list and is what iOS does.
1085
+ const basalt::BackgroundLayer layer = basalt::resolveBackgroundLayer(
1086
+ positioning,
1087
+ painting,
1088
+ basalt::backgroundSizeAt(props->backgroundSize, index),
1089
+ basalt::backgroundPositionAt(props->backgroundPosition, index),
1090
+ basalt::backgroundRepeatAt(props->backgroundRepeat, index));
1091
+ if (layer.empty()) {
1092
+ continue;
1093
+ }
1094
+ // The gradient is resolved against the image's size and then offset to
1095
+ // where the image goes: resolving against the view would ignore
1096
+ // `backgroundSize` even with the rectangle right.
1097
+ const auto width = layer.width;
1098
+ const auto height = layer.height;
1099
+
1100
+ RnAppKitGradient converted{};
1101
+ converted.area = CGRectMake(layer.x, layer.y, layer.width, layer.height);
1102
+ converted.tile = CGRectMake(layer.tileX, layer.tileY, layer.tileWidth, layer.tileHeight);
1103
+ converted.repeats = layer.repeats();
1104
+ const std::vector<facebook::react::ColorStop> *colorStops = nullptr;
1105
+ float rayLength = 0.0F;
1106
+
1107
+ if (std::holds_alternative<facebook::react::LinearGradient>(image)) {
1108
+ const auto &gradient = std::get<facebook::react::LinearGradient>(image);
1109
+ const basalt::GradientLine line = basalt::linearGradientLine(gradient, width, height);
1110
+ converted.kind = RnAppKitGradientKindLinear;
1111
+ converted.start = CGPointMake(line.startX + layer.x, line.startY + layer.y);
1112
+ converted.end = CGPointMake(line.endX + layer.x, line.endY + layer.y);
1113
+ colorStops = &gradient.colorStops;
1114
+ rayLength = line.length();
1115
+ } else {
1116
+ const auto &gradient = std::get<facebook::react::RadialGradient>(image);
1117
+ const basalt::GradientEllipse shape =
1118
+ basalt::radialGradientEllipse(gradient, width, height);
1119
+ converted.kind = RnAppKitGradientKindRadial;
1120
+ converted.center = CGPointMake(shape.centerX + layer.x, shape.centerY + layer.y);
1121
+ converted.radiusX = shape.radiusX;
1122
+ converted.radiusY = shape.radiusY;
1123
+ colorStops = &gradient.colorStops;
1124
+ rayLength = shape.rayLength();
1125
+ }
1126
+
1127
+ const auto resolved = basalt::resolveGradientStops(*colorStops, rayLength);
1128
+ if (resolved.empty()) {
1129
+ continue;
1130
+ }
1131
+ std::vector<RnAppKitGradientStop> stopList;
1132
+ stopList.reserve(resolved.size());
1133
+ for (const auto &stop : resolved) {
1134
+ RnAppKitGradientStop one{};
1135
+ one.offset = stop.offset;
1136
+ one.color[0] = stop.red;
1137
+ one.color[1] = stop.green;
1138
+ one.color[2] = stop.blue;
1139
+ one.color[3] = stop.alpha;
1140
+ stopList.push_back(one);
1141
+ }
1142
+ // Reserved above, so pushing cannot reallocate and the pointer handed over
1143
+ // below stays good for this block. The view copies what it is given.
1144
+ stops.push_back(std::move(stopList));
1145
+ converted.stops = stops.back().data();
1146
+ converted.stopCount = (NSInteger)stops.back().size();
1147
+ gradients.push_back(converted);
1148
+ }
1149
+ [view setRnGradients:gradients.data() count:(NSInteger)gradients.size()];
1150
+ }
1151
+
1152
+ // boxShadow. React Native's BoxShadow carries the six CSS fields and the view
1153
+ // layer takes the same six, so nothing is converted here: it decides what a
1154
+ // shadow is made of on this platform, and this decides nothing.
1155
+ //
1156
+ // The list also carries the older iOS shadow props, converted in
1157
+ // core/LegacyShadow.h: one mechanism from here down, so a view with
1158
+ // `shadowOpacity` and a view with `boxShadow` take the same path.
1159
+ {
1160
+ const std::vector<facebook::react::BoxShadow> all = basalt::allShadows(*props);
1161
+ std::vector<RnAppKitBoxShadow> shadows;
1162
+ shadows.reserve(all.size());
1163
+ for (const auto &shadow : all) {
1164
+ RnAppKitBoxShadow converted{};
1165
+ converted.dx = (CGFloat)shadow.offsetX;
1166
+ converted.dy = (CGFloat)shadow.offsetY;
1167
+ converted.blur = (CGFloat)shadow.blurRadius;
1168
+ converted.spread = (CGFloat)shadow.spreadDistance;
1169
+ converted.inset = shadow.inset;
1170
+ if (shadow.color) {
1171
+ const ColorComponents components = colorComponentsFromColor(shadow.color);
1172
+ converted.color[0] = components.red;
1173
+ converted.color[1] = components.green;
1174
+ converted.color[2] = components.blue;
1175
+ converted.color[3] = components.alpha;
1176
+ }
1177
+ shadows.push_back(converted);
1178
+ }
1179
+ [view setRnBoxShadows:shadows.data() count:(NSInteger)shadows.size()];
1180
+ }
1181
+
1182
+ // The outline: CSS's, drawn outside the box and taking no layout space, so
1183
+ // nothing about it touches the frame. One width, one colour, one offset and one
1184
+ // style for the whole ring, unlike the border's four of each.
1185
+ {
1186
+ CGFloat colour[4] = {0, 0, 0, 0};
1187
+ if (props->outlineColor) {
1188
+ const ColorComponents components = colorComponentsFromColor(props->outlineColor);
1189
+ colour[0] = components.red;
1190
+ colour[1] = components.green;
1191
+ colour[2] = components.blue;
1192
+ colour[3] = components.alpha;
1193
+ }
1194
+ RnAppKitBorderStyle style = RnAppKitBorderStyleSolid;
1195
+ switch (props->outlineStyle) {
1196
+ case facebook::react::OutlineStyle::Dotted:
1197
+ style = RnAppKitBorderStyleDotted;
1198
+ break;
1199
+ case facebook::react::OutlineStyle::Dashed:
1200
+ style = RnAppKitBorderStyleDashed;
1201
+ break;
1202
+ case facebook::react::OutlineStyle::Solid:
1203
+ break;
1204
+ }
1205
+ [view setRnOutlineWidth:(CGFloat)props->outlineWidth
1206
+ offset:(CGFloat)props->outlineOffset
1207
+ color:colour
1208
+ style:style];
1209
+ }
1210
+
1211
+ // filter. The arithmetic is core/Filters.h's and is shared with the GTK host:
1212
+ // the list of CSS functions comes to one colour matrix, one blur and one
1213
+ // opacity, and the view layer turns the first two into Core Image filters.
1214
+ //
1215
+ // A dropShadow() in the list is reported rather than applied -- it is the one
1216
+ // function that does not commute with the others. Logged once per view that
1217
+ // asks, because silence here looks like a working filter.
1218
+ {
1219
+ const basalt::ResolvedFilters resolved = basalt::resolveFilters(props->filter);
1220
+ if (resolved.empty()) {
1221
+ [view setRnFilters:nullptr];
1222
+ } else {
1223
+ RnAppKitFilters filters{};
1224
+ filters.hasMatrix = resolved.hasMatrix;
1225
+ for (int i = 0; i < 16; i++) {
1226
+ filters.matrix[i] = resolved.matrix.m[i];
1227
+ }
1228
+ for (int i = 0; i < 4; i++) {
1229
+ filters.offset[i] = resolved.matrix.offset[i];
1230
+ }
1231
+ filters.blurRadius = resolved.blurRadius;
1232
+ filters.opacity = resolved.opacity;
1233
+
1234
+ // `dropShadow()`, which this host draws as the layer's own shadow. The
1235
+ // standard deviation crosses unchanged, `CALayer.shadowRadius` being one,
1236
+ // where the GTK side doubles it for GSK.
1237
+ std::vector<RnAppKitFilterShadow> shadows;
1238
+ shadows.reserve(resolved.dropShadows.size());
1239
+ for (const auto &shadow : resolved.dropShadows) {
1240
+ RnAppKitFilterShadow converted{};
1241
+ converted.dx = (CGFloat)shadow.dx;
1242
+ converted.dy = (CGFloat)shadow.dy;
1243
+ converted.standardDeviation = (CGFloat)shadow.standardDeviation;
1244
+ converted.color[0] = shadow.red;
1245
+ converted.color[1] = shadow.green;
1246
+ converted.color[2] = shadow.blue;
1247
+ converted.color[3] = shadow.alpha;
1248
+ shadows.push_back(converted);
1249
+ }
1250
+ filters.shadows = shadows.data();
1251
+ filters.shadowCount = (NSInteger)shadows.size();
1252
+ // The view copies what it keeps, so the vector may go out of scope.
1253
+ [view setRnFilters:&filters];
1254
+
1255
+ }
1256
+ }
1257
+
1258
+ // hitSlop, which grows what a press can land on without moving a pixel.
1259
+ // RnAppKitHitTest is the only reader; see -rnHitArea.
1260
+ {
1261
+ const CGFloat insets[4] = {
1262
+ (CGFloat)props->hitSlop.top,
1263
+ (CGFloat)props->hitSlop.right,
1264
+ (CGFloat)props->hitSlop.bottom,
1265
+ (CGFloat)props->hitSlop.left,
1266
+ };
1267
+ [view setRnHitSlop:insets];
1268
+ }
1269
+
1270
+ // The `cursor` style property, as a CSS keyword. core/CursorNames.h is shared
1271
+ // with the GTK host, which hands the same keyword to GDK; what macOS has no
1272
+ // cursor for is decided in the view layer, beside the NSCursors.
1273
+ {
1274
+ const char *name = basalt::cursorName(props->cursor);
1275
+ [view setRnCursorName:name != nullptr ? [NSString stringWithUTF8String:name] : nil];
1276
+ }
1277
+
1278
+ // `mixBlendMode`, also as a CSS keyword and also shared: Core Image has a
1279
+ // blend-mode filter per CSS mode, so the view layer maps the keyword onto one
1280
+ // and Core Animation does the blending.
1281
+ {
1282
+ const char *name = basalt::blendModeName(props->mixBlendMode);
1283
+ [view setRnBlendModeName:name != nullptr ? [NSString stringWithUTF8String:name] : nil];
1284
+ }
1285
+
1286
+ // borderStyle. React Native carries one per side and a stroked path carries
1287
+ // one dash pattern, so the first side that asks for something other than solid
1288
+ // decides the whole outline, exactly as on GTK. A border with different styles
1289
+ // per side is rare enough to be worth that; see backlog/correctness.md.
1290
+ //
1291
+ // From the *resolved* metrics, like the widths and the colours: the raw
1292
+ // `props->borderStyles` is a cascade of optionals with a slot per spelling, and
1293
+ // `borderStyle: 'dashed'` sets the `all` slot rather than the four sides. The
1294
+ // GTK side read the sides and so drew every dashed border solid.
1295
+ {
1296
+ const auto &styles =
1297
+ props->resolveBorderMetrics(shadowView.layoutMetrics).borderStyles;
1298
+ RnAppKitBorderStyle style = RnAppKitBorderStyleSolid;
1299
+ const facebook::react::BorderStyle sides[4] = {
1300
+ styles.top, styles.right, styles.bottom, styles.left};
1301
+ for (const auto &side : sides) {
1302
+ if (side == facebook::react::BorderStyle::Dotted) {
1303
+ style = RnAppKitBorderStyleDotted;
1304
+ break;
1305
+ }
1306
+ if (side == facebook::react::BorderStyle::Dashed) {
1307
+ style = RnAppKitBorderStyleDashed;
1308
+ break;
1309
+ }
1310
+ }
1311
+ [view setRnBorderStyle:style];
1312
+ }
777
1313
  }
778
1314
 
779
1315
  namespace {
@@ -821,6 +1357,21 @@ void AppKitMountingManager::applyAccessibility(RnAppKitView *view, const ShadowV
821
1357
  const std::string role = effectiveRole(shadowView);
822
1358
  [view setRnAccessibleRole:role.empty() ? nil : [NSString stringWithUTF8String:role.c_str()]];
823
1359
 
1360
+ // `accessibilityLiveRegion`: a status message to read out when it changes.
1361
+ // Recorded here and acted on after the transaction, the text being whatever the
1362
+ // region says once every mutation in the batch has landed.
1363
+ liveRegions_.setPoliteness(shadowView.tag, props->accessibilityLiveRegion);
1364
+ if (props->accessibilityLiveRegion == facebook::react::AccessibilityLiveRegion::None) {
1365
+ liveRegionLabels_.erase(shadowView.tag);
1366
+ } else {
1367
+ liveRegionLabels_[shadowView.tag] = props->accessibilityLabel;
1368
+ }
1369
+
1370
+ // `accessibilityLabelledBy`: other views, named by their nativeID, whose text
1371
+ // names this one. Only recorded here -- the resolution needs every view in the
1372
+ // transaction to have been seen, so it happens once at the end of the mount.
1373
+ labels_.setLabelledBy(shadowView.tag, props->accessibilityLabelledBy.value);
1374
+
824
1375
  // A label given in props wins. Falling back to a Paragraph's own text means a
825
1376
  // plain <Text> announces itself without the app having to repeat the string
826
1377
  // in an accessibilityLabel.
@@ -898,6 +1449,22 @@ void AppKitMountingManager::applyAccessibility(RnAppKitView *view, const ShadowV
898
1449
  busy:RnAppKitAccessibleUnset];
899
1450
  }
900
1451
 
1452
+ // After the state, deliberately: AppKit carries both `checked` and
1453
+ // accessibilityValue in one property, and an explicit value is the app's own
1454
+ // word so it wins over one derived from a checkbox. See the setter.
1455
+ //
1456
+ // Each part passed as nil when absent, so a view that gave only `now` is not
1457
+ // announced as sitting at the bottom of a range it never mentioned.
1458
+ {
1459
+ const auto &value = props->accessibilityValue;
1460
+ [view setRnAccessibleValueMin:value.min.has_value() ? @(*value.min) : nil
1461
+ max:value.max.has_value() ? @(*value.max) : nil
1462
+ now:value.now.has_value() ? @(*value.now) : nil
1463
+ text:value.text.has_value()
1464
+ ? [NSString stringWithUTF8String:value.text->c_str()]
1465
+ : nil];
1466
+ }
1467
+
901
1468
  // Keyboard focus. `accessible` is the signal because React Native's
902
1469
  // `focusable` prop never reaches this platform -- see AppKitFocus.h -- and
903
1470
  // because it is what <Pressable> sets on everything it renders. A hidden view