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