basalt-appkit 0.0.0-rc5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/cli/runMacos.js +33 -0
  3. package/cli/shared.js +75 -0
  4. package/native/CMakeLists.txt +298 -0
  5. package/native/appkit/AppKitAnimationChoreographer.h +54 -0
  6. package/native/appkit/AppKitAnimationChoreographer.mm +125 -0
  7. package/native/appkit/AppKitDropTarget.h +34 -0
  8. package/native/appkit/AppKitDropTarget.mm +292 -0
  9. package/native/appkit/AppKitFocus.h +120 -0
  10. package/native/appkit/AppKitFocus.mm +293 -0
  11. package/native/appkit/AppKitImageLoader.h +80 -0
  12. package/native/appkit/AppKitImageLoader.mm +156 -0
  13. package/native/appkit/AppKitKeyEvents.h +26 -0
  14. package/native/appkit/AppKitKeyEvents.mm +97 -0
  15. package/native/appkit/AppKitMenuBar.mm +424 -0
  16. package/native/appkit/AppKitMountingManager.h +135 -0
  17. package/native/appkit/AppKitMountingManager.mm +946 -0
  18. package/native/appkit/AppKitRunLoopObserver.h +34 -0
  19. package/native/appkit/AppKitRunLoopObserver.mm +42 -0
  20. package/native/appkit/AppKitScrollView.h +168 -0
  21. package/native/appkit/AppKitScrollView.mm +550 -0
  22. package/native/appkit/AppKitSkiaContext.h +33 -0
  23. package/native/appkit/AppKitSkiaContext.mm +255 -0
  24. package/native/appkit/AppKitSkiaModule.h +53 -0
  25. package/native/appkit/AppKitSkiaModule.mm +81 -0
  26. package/native/appkit/AppKitSkiaPeer.h +37 -0
  27. package/native/appkit/AppKitSkiaPeer.mm +123 -0
  28. package/native/appkit/AppKitSkiaView.h +62 -0
  29. package/native/appkit/AppKitSnapshot.h +21 -0
  30. package/native/appkit/AppKitSnapshot.mm +91 -0
  31. package/native/appkit/AppKitTextInput.h +140 -0
  32. package/native/appkit/AppKitTextInput.mm +588 -0
  33. package/native/appkit/AppKitTextPeer.h +74 -0
  34. package/native/appkit/AppKitTextPeer.mm +353 -0
  35. package/native/appkit/AppKitTitleBar.h +90 -0
  36. package/native/appkit/AppKitTitleBar.mm +186 -0
  37. package/native/appkit/AppKitTouchDispatcher.h +138 -0
  38. package/native/appkit/AppKitTouchDispatcher.mm +514 -0
  39. package/native/appkit/AppKitWindowControl.mm +222 -0
  40. package/native/appkit/AppKitWindowModule.h +62 -0
  41. package/native/appkit/AppKitWindowModule.mm +194 -0
  42. package/native/appkit/ColorSchemeAppKit.mm +71 -0
  43. package/native/appkit/ComponentRegistryAppKit.mm +121 -0
  44. package/native/appkit/CoreTextLayout.h +43 -0
  45. package/native/appkit/CoreTextLayout.mm +252 -0
  46. package/native/appkit/CoreTextLayoutManager.mm +138 -0
  47. package/native/appkit/FontRegistryCoreText.mm +148 -0
  48. package/native/appkit/PlatformServicesAppKit.mm +540 -0
  49. package/native/appkit/RnAppKitView.h +417 -0
  50. package/native/appkit/RnAppKitView.mm +1605 -0
  51. package/native/appkit/RnTextLayout.h +38 -0
  52. package/native/appkit/RnTextLayout.mm +168 -0
  53. package/native/appkit/demo_layout_appkit.mm +93 -0
  54. package/native/appkit/main_appkit.mm +1860 -0
  55. package/native/appkit/mount_harness_appkit.mm +197 -0
  56. package/native/tests/test_appkit_accessibility.mm +170 -0
  57. package/native/tests/test_appkit_image.mm +237 -0
  58. package/native/tests/test_appkit_input.mm +852 -0
  59. package/native/tests/test_appkit_key_events.mm +150 -0
  60. package/native/tests/test_appkit_mounting.mm +430 -0
  61. package/native/tests/test_appkit_scrollbar.mm +132 -0
  62. package/native/tests/test_appkit_services.mm +57 -0
  63. package/native/tests/test_appkit_skia.mm +212 -0
  64. package/native/tests/test_appkit_text.mm +180 -0
  65. package/native/tests/test_appkit_textinput.mm +748 -0
  66. package/native/tests/test_appkit_titlebar.mm +107 -0
  67. package/native/tests/test_appkit_view.mm +237 -0
  68. package/package.json +26 -0
  69. package/react-native.config.js +22 -0
@@ -0,0 +1,946 @@
1
+ #import "AppKitMountingManager.h"
2
+
3
+ #import "CoreTextLayout.h"
4
+
5
+ // CIFilter, which is the only way to tint an NSProgressIndicator.
6
+ #import <CoreImage/CoreImage.h>
7
+
8
+ #include "ComponentRegistry.h"
9
+ #include "ExpoImageComponent.h"
10
+ #ifdef BASALT_HAS_SKIA
11
+ #include "AppKitSkiaPeer.h"
12
+ #endif
13
+ #include "UIManagerAccess.h"
14
+
15
+ #include <react/renderer/components/view/AccessibilityProps.h>
16
+ #include <react/renderer/components/image/ImageEventEmitter.h>
17
+ #include <react/renderer/components/image/ImageProps.h>
18
+ #include <react/renderer/components/scrollview/ScrollViewProps.h>
19
+ #include <react/renderer/components/text/ParagraphState.h>
20
+ #include <react/renderer/components/view/ViewProps.h>
21
+ #include <react/renderer/core/ConcreteState.h>
22
+ #include <react/renderer/graphics/Color.h>
23
+
24
+ #include <glog/logging.h>
25
+
26
+ #include <algorithm>
27
+ #include <cassert>
28
+ #include <cmath>
29
+ #include <memory>
30
+ #include <string_view>
31
+ #include <type_traits>
32
+
33
+
34
+ // An NSSwitch that never takes a click.
35
+ //
36
+ // A <Switch> is toggled from React Native's own touch path -- see
37
+ // `pressedView` -- rather than by the control's own input, so that a press
38
+ // means the same thing on three desktops: the Windows one is painted and has
39
+ // no widget to click at all. Refusing the hit is what lets the press fall
40
+ // through to the RnAppKitView behind it, which is where the touch dispatcher
41
+ // picks it up. The switch still draws, animates and reports itself to
42
+ // accessibility exactly as it would.
43
+ @interface RnAppKitSwitch : NSSwitch
44
+ @end
45
+
46
+ @implementation RnAppKitSwitch
47
+ - (NSView *)hitTest:(NSPoint)point {
48
+ (void)point;
49
+ return nil;
50
+ }
51
+ @end
52
+
53
+ namespace basalt {
54
+
55
+ // `override` proves each signature matches the interface, but not that every
56
+ // pure virtual is implemented -- nothing here instantiates the class. This
57
+ // catches an IMountingManager method going unimplemented as the interface
58
+ // evolves upstream.
59
+ static_assert(!std::is_abstract_v<AppKitMountingManager>,
60
+ "AppKitMountingManager must implement all of IMountingManager");
61
+
62
+ using facebook::react::ColorComponents;
63
+ using facebook::react::ComponentRegistryFactory;
64
+ using facebook::react::MountingTransaction;
65
+ using facebook::react::ImageEventEmitter;
66
+ using facebook::react::ImageProps;
67
+ using facebook::react::ImageResizeMode;
68
+ using facebook::react::ParagraphState;
69
+ using facebook::react::ShadowView;
70
+ using facebook::react::SurfaceId;
71
+ using facebook::react::Tag;
72
+ using facebook::react::ViewProps;
73
+
74
+ AppKitMountingManager::AppKitMountingManager()
75
+ : scrollViews_([this](Tag tag) { return eventEmitterForTag(tag); }),
76
+ textInputs_([this](Tag tag) { return eventEmitterForTag(tag); }) {
77
+ // The pull past the top of a list, counted in core/PullToRefresh.h and fired
78
+ // at whichever <RefreshControl> that scroll view has. Identical on all three
79
+ // desktops, which is the point of the two of them being portable.
80
+ scrollViews_.setOverscrollTopHandler([this](Tag tag, double amount) {
81
+ if (amount <= 0.0) {
82
+ pullToRefresh().release(tag);
83
+ return;
84
+ }
85
+ if (pullToRefresh().pull(tag, amount)) {
86
+ fireRefresh(tag);
87
+ }
88
+ });
89
+ }
90
+
91
+ AppKitMountingManager::~AppKitMountingManager() noexcept {
92
+ // MountingWalk cannot do this itself: by the time a base destructor runs, the
93
+ // AppKit half that knows how to release a view is already gone.
94
+ releaseAllViews();
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Getting onto the main thread
99
+ // ---------------------------------------------------------------------------
100
+
101
+ namespace {
102
+
103
+ // Carries a transaction from the JS thread to the main thread. A heap struct
104
+ // rather than a captured lambda because MountingTransaction is move-only, and
105
+ // this is the shape the GTK side uses for the same trip.
106
+ struct PendingMount {
107
+ AppKitMountingManager *manager;
108
+ SurfaceId surfaceId;
109
+ MountingTransaction transaction;
110
+ };
111
+
112
+ void applyPendingMount(void *data) {
113
+ std::unique_ptr<PendingMount> pending{static_cast<PendingMount *>(data)};
114
+ pending->manager->applyTransaction(pending->surfaceId, std::move(pending->transaction));
115
+ }
116
+
117
+ } // namespace
118
+
119
+ void AppKitMountingManager::executeMount(SurfaceId surfaceId, MountingTransaction &&transaction) {
120
+ // This runs on the JS thread: Scheduler::uiManagerDidFinishTransaction queues
121
+ // the mount via RuntimeScheduler::scheduleRenderingUpdate, which drains in the
122
+ // event loop's "update the rendering" step with the jsi::Runtime live. AppKit
123
+ // is main-thread-only -- and not politely so: it asserts and traps the process
124
+ // -- so nothing here may touch a view.
125
+ //
126
+ // Always queue, never invoke directly even when already on the main thread.
127
+ // The main queue is FIFO, which is what keeps mutation ordering intact, and
128
+ // running one transaction inline while another is queued would break it. iOS
129
+ // and Android marshal here too.
130
+ auto *pending = new PendingMount{this, surfaceId, std::move(transaction)};
131
+ dispatch_async_f(dispatch_get_main_queue(), pending, applyPendingMount);
132
+ }
133
+
134
+ void AppKitMountingManager::applyTransaction(SurfaceId surfaceId, MountingTransaction &&transaction) {
135
+ // The walk itself is in core/MountingWalk.h and is shared with GTK; what is
136
+ // AppKit about mounting is below, in the operations it calls back into.
137
+ applyMutations(transaction.getMutations());
138
+
139
+ // Tell the UIManager the transaction is on screen. Anything registered as a
140
+ // mount hook -- Reanimated's is the one that matters here -- is waiting for
141
+ // this, and without it an animated style is computed every frame, committed
142
+ // to the shadow tree and never resumed, so nothing moves.
143
+ //
144
+ // iOS does this from RCTSurfacePresenter and Android from its mounting
145
+ // manager. ReactCxxPlatform does it nowhere, which is a gap in the shared
146
+ // platform rather than in either host: nothing in it had a mount hook until
147
+ // a third-party library brought one.
148
+ if (auto uiManager = sharedUIManager()) {
149
+ uiManager->reportMount(surfaceId);
150
+ }
151
+ }
152
+
153
+ namespace {
154
+
155
+ // The same trip an executeMount takes, for the same reason: this arrives on the
156
+ // JS thread, inside the event loop's rendering update, so it may not touch a
157
+ // view either.
158
+ //
159
+ // Queued on the same queue as a mount, so it stays behind the transaction that
160
+ // created the view it names -- the main queue is FIFO, and a scrollTo that
161
+ // arrived before its ScrollView was mounted would find no entry and be dropped.
162
+ struct PendingCommand {
163
+ AppKitMountingManager *manager;
164
+ Tag tag;
165
+ std::string name;
166
+ folly::dynamic args;
167
+ };
168
+
169
+ void applyPendingCommand(void *data) {
170
+ std::unique_ptr<PendingCommand> pending{static_cast<PendingCommand *>(data)};
171
+ pending->manager->applyCommand(pending->tag, pending->name, pending->args);
172
+ }
173
+
174
+ } // namespace
175
+
176
+ void AppKitMountingManager::dispatchCommand(const ShadowView &shadowView,
177
+ const std::string &commandName,
178
+ const folly::dynamic &args) {
179
+ auto *pending = new PendingCommand{this, shadowView.tag, commandName, args};
180
+ dispatch_async_f(dispatch_get_main_queue(), pending, applyPendingCommand);
181
+ }
182
+
183
+ void AppKitMountingManager::applyCommand(Tag tag,
184
+ const std::string &commandName,
185
+ const folly::dynamic &args) {
186
+ assert(onMainThread() && "applyCommand must run on the main thread");
187
+
188
+ if (scrollViews_.dispatchCommand(tag, commandName, args)) {
189
+ return;
190
+ }
191
+ if (textInputs_.dispatchCommand(tag, commandName, args)) {
192
+ return;
193
+ }
194
+ // React DevTools' overlay, answered the same way on all three: see
195
+ // core/DebuggingOverlay.h.
196
+ if (applyOverlayCommand(tag, commandName, args)) {
197
+ return;
198
+ }
199
+ LOG(INFO) << "dispatchCommand '" << commandName << "' on tag " << tag
200
+ << " is not implemented on macOS";
201
+ }
202
+
203
+ void AppKitMountingManager::setUIManager(std::weak_ptr<facebook::react::UIManager> uiManager) noexcept {
204
+ setSharedUIManager(std::move(uiManager));
205
+ }
206
+
207
+ ComponentRegistryFactory AppKitMountingManager::getComponentRegistryFactory() {
208
+ return facebook::react::getDefaultComponentRegistryFactory();
209
+ }
210
+
211
+ bool AppKitMountingManager::hasComponent(const std::string &name) {
212
+ // Only what actually mounts. Claiming more would be worse than admitting the
213
+ // gap: the registry would build shadow nodes nothing can put on screen, and
214
+ // the app would render blank rectangles instead of failing somewhere legible.
215
+ //
216
+ // Paragraph is the mountable half of <Text>; Text and RawText exist only in
217
+ // the shadow tree, folded into the Paragraph's AttributedString.
218
+ //
219
+ // ScrollView's content child arrives as "ScrollContentView", which the
220
+ // registry rewrites to "View" before it reaches here, so it needs no entry.
221
+ //
222
+ // The components an ordinary app is built from, plus the root; and below
223
+ // them the controls, each of which is a real AppKit control rather than a
224
+ // box this host draws. UnimplementedNativeView is here so that a component
225
+ // no platform registered mounts as a view that says so rather than as
226
+ // nothing at all.
227
+ return name == "View" || name == "RootView" || name == "Paragraph" ||
228
+ name == "ScrollView" || name == "Image" || name == "TextInput" ||
229
+ name == "ActivityIndicatorView" || name == "Switch" || name == "ModalHostView" ||
230
+ name == "PullToRefreshView" || name == "UnimplementedNativeView" ||
231
+ name == "DebuggingOverlay";
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // What MountingWalk asks of a platform
236
+ // ---------------------------------------------------------------------------
237
+
238
+ RnAppKitView *AppKitMountingManager::createView(const ShadowView &shadowView) {
239
+ return [RnAppKitView viewWithTag:static_cast<NSInteger>(shadowView.tag)];
240
+ }
241
+
242
+ RnAppKitView *AppKitMountingManager::createRootView(Tag tag) {
243
+ return [RnAppKitView viewWithTag:static_cast<NSInteger>(tag)];
244
+ }
245
+
246
+ void AppKitMountingManager::destroyView(RnAppKitView *view) {
247
+ // Nothing to do: ARC owns these. The registry holds the only strong reference
248
+ // between a Remove and its Delete, exactly as on GTK, but erasing the entry
249
+ // is what releases it rather than an explicit unref. This hook stays because
250
+ // the walk has to say *when* a view stops being owned, even on a platform
251
+ // where saying it costs nothing.
252
+ (void)view;
253
+ }
254
+
255
+ void AppKitMountingManager::insertChild(RnAppKitView *parent, RnAppKitView *child, int index) {
256
+ [parent insertRnChild:child atIndex:index];
257
+ }
258
+
259
+ void AppKitMountingManager::removeChild(RnAppKitView *parent, RnAppKitView *child) {
260
+ [parent removeRnChild:child];
261
+ }
262
+
263
+ void AppKitMountingManager::forgetTag(Tag tag) {
264
+ scrollViews_.remove(tag);
265
+ textInputs_.remove(tag);
266
+ imageUris_.erase(tag);
267
+ switchValues_.erase(tag);
268
+ #ifdef BASALT_HAS_SKIA
269
+ // Unregisters the canvas and takes its layer off the view. This is the one
270
+ // moment a view is known to be finished with, which matters more here than
271
+ // elsewhere: a canvas left registered keeps a Metal layer and a GPU surface
272
+ // alive for a tag nothing will draw again.
273
+ forgetSkiaCanvas(tag);
274
+ #endif
275
+ }
276
+
277
+ namespace {
278
+
279
+ RnAppKitImageFit toImageFit(ImageResizeMode mode) {
280
+ switch (mode) {
281
+ case ImageResizeMode::Contain:
282
+ return RnAppKitImageFitContain;
283
+ case ImageResizeMode::Stretch:
284
+ return RnAppKitImageFitStretch;
285
+ case ImageResizeMode::Center:
286
+ case ImageResizeMode::None:
287
+ return RnAppKitImageFitCenter;
288
+ case ImageResizeMode::Repeat:
289
+ return RnAppKitImageFitRepeat;
290
+ case ImageResizeMode::Cover:
291
+ break;
292
+ }
293
+ return RnAppKitImageFitCover;
294
+ }
295
+
296
+ } // namespace
297
+
298
+ // React Native's cxx ImageManager is a stub that never produces an
299
+ // ImageResponse, so nothing arrives through ImageState. The URI is read off the
300
+ // props and loaded here instead, which is also how Android does it.
301
+ void AppKitMountingManager::applyImage(RnAppKitView *view, const ShadowView &shadowView) {
302
+ if (shadowView.componentName == nullptr) {
303
+ return;
304
+ }
305
+
306
+ // Two components draw an image here: React Native's <Image> and expo-image's
307
+ // view, which is an ordinary Fabric component with its own props (see
308
+ // core/ExpoImageComponent.h). Everything past reading the source and the fit
309
+ // off the props is identical, and the only other difference is which emitter
310
+ // reports the result, because the two carry different payloads.
311
+ const std::string_view componentName(shadowView.componentName);
312
+ const bool isExpoImage = componentName == facebook::react::ExpoImageComponentName;
313
+ if (componentName != "Image" && !isExpoImage) {
314
+ return;
315
+ }
316
+
317
+ RnAppKitImageFit fit = RnAppKitImageFitCover;
318
+ // Qualified: Carbon's headers put a CGImageSource in scope, and an
319
+ // unqualified ImageSource resolves to that one.
320
+ facebook::react::ImageSource source{};
321
+ facebook::react::SharedColor tint{};
322
+
323
+ if (isExpoImage) {
324
+ const auto props = std::dynamic_pointer_cast<const facebook::react::ExpoImageProps>(shadowView.props);
325
+ if (props == nullptr) {
326
+ return;
327
+ }
328
+ fit = toImageFit(props->contentFit);
329
+ if (!props->sources.empty()) {
330
+ source = props->sources.front();
331
+ }
332
+ tint = props->tintColor;
333
+ } else {
334
+ const auto props = std::dynamic_pointer_cast<const ImageProps>(shadowView.props);
335
+ if (props == nullptr) {
336
+ return;
337
+ }
338
+ fit = toImageFit(props->resizeMode);
339
+ if (!props->sources.empty()) {
340
+ source = props->sources.front();
341
+ }
342
+ // React Native's is optional where expo-image's is a plain SharedColor;
343
+ // both spell "no tint" as a falsy SharedColor once unwrapped.
344
+ if (props->tintColor) {
345
+ tint = *props->tintColor;
346
+ }
347
+ }
348
+
349
+ const std::string uri = source.uri;
350
+ const Tag tag = shadowView.tag;
351
+
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.
354
+ if (RnAppKitView *tinted = viewForTag(tag); tinted != nil) {
355
+ if (tint) {
356
+ const ColorComponents components = colorComponentsFromColor(tint);
357
+ [tinted setRnImageTint:[NSColor colorWithSRGBRed:components.red
358
+ green:components.green
359
+ blue:components.blue
360
+ alpha:components.alpha]];
361
+ } else {
362
+ [tinted setRnImageTint:nil];
363
+ }
364
+ }
365
+
366
+ // A mutation that changed only layout must not restart the load, or an
367
+ // <Image> would flicker every time its parent resized. Re-requesting the same
368
+ // URI is cheap -- the loader answers from its cache on this thread -- and it
369
+ // reapplies the fit, which is the only thing that can have changed.
370
+ const auto known = imageUris_.find(tag);
371
+ if (known != imageUris_.end() && known->second == uri) {
372
+ if (!uri.empty()) {
373
+ imageLoader_->load(uri, [this, tag, fit](CGImageRef image, const std::string &) {
374
+ if (RnAppKitView *target = viewForTag(tag); target != nil) {
375
+ [target setRnImage:image fit:fit];
376
+ }
377
+ });
378
+ }
379
+ return;
380
+ }
381
+
382
+ imageUris_[tag] = uri;
383
+
384
+ if (uri.empty()) {
385
+ [view setRnImage:nullptr fit:fit];
386
+ return;
387
+ }
388
+
389
+ // Always, rather than only when `shouldNotifyLoadEvents` is set.
390
+ //
391
+ // That prop is Android's signal, and it never arrives here.
392
+ // `Image.android.js` sets it -- which is the Image.js this platform resolves
393
+ // to -- but `ImageViewNativeComponent`'s view config branches on
394
+ // `Platform.OS === 'android'`, and a platform that is neither takes the iOS
395
+ // branch, whose `validAttributes` has no `shouldNotifyLoadEvents` in it. So
396
+ // the prop is filtered out before it reaches C++ and onLoad/onError never
397
+ // fire, silently, on both desktops.
398
+ //
399
+ // iOS does not use the prop at all: RCTImageComponentView emits load events
400
+ // unconditionally and lets the emitter be the thing that knows whether
401
+ // anybody is listening. Doing the same is both correct and cheaper than
402
+ // forking a two-hundred-line view config to change one ternary -- and the
403
+ // emitter lookup below already returns null when nothing is listening.
404
+ if (isExpoImage) {
405
+ if (auto emitter =
406
+ std::dynamic_pointer_cast<const facebook::react::ExpoImageEventEmitter>(eventEmitterForTag(tag))) {
407
+ emitter->onLoadStart();
408
+ }
409
+ } else if (auto emitter =
410
+ std::dynamic_pointer_cast<const ImageEventEmitter>(eventEmitterForTag(tag))) {
411
+ emitter->onLoadStart();
412
+ }
413
+
414
+ imageLoader_->load(uri, [this, tag, fit, source, isExpoImage](CGImageRef image,
415
+ const std::string &error) {
416
+ // The view may have been deleted while the image was in flight, which is
417
+ // why this looks the tag up again rather than capturing the view.
418
+ if (RnAppKitView *target = viewForTag(tag); target != nil) {
419
+ [target setRnImage:image fit:fit];
420
+ }
421
+
422
+ if (image == nullptr) {
423
+ LOG(WARNING) << "image failed to load: " << source.uri << " (" << error << ")";
424
+ }
425
+
426
+ if (isExpoImage) {
427
+ auto emitter =
428
+ std::dynamic_pointer_cast<const facebook::react::ExpoImageEventEmitter>(eventEmitterForTag(tag));
429
+ if (emitter == nullptr) {
430
+ return;
431
+ }
432
+ if (image != nullptr) {
433
+ // The pixel dimensions, which is what expo-image's onLoad reports and
434
+ // what an app sizing itself to an image reads.
435
+ emitter->onLoad(source,
436
+ static_cast<double>(CGImageGetWidth(image)),
437
+ static_cast<double>(CGImageGetHeight(image)));
438
+ } else {
439
+ emitter->onError(error);
440
+ }
441
+ return;
442
+ }
443
+
444
+ auto emitter = std::dynamic_pointer_cast<const ImageEventEmitter>(eventEmitterForTag(tag));
445
+ if (emitter == nullptr) {
446
+ return;
447
+ }
448
+ if (image != nullptr) {
449
+ emitter->onLoad(source);
450
+ } else {
451
+ emitter->onError(facebook::react::ImageErrorInfo{.error = error});
452
+ }
453
+ emitter->onLoadEnd();
454
+ });
455
+ }
456
+
457
+ void AppKitMountingManager::updateView(RnAppKitView *view, const ShadowView &shadowView) {
458
+ applyProps(view, shadowView);
459
+ applyText(view, shadowView);
460
+ applyImage(view, shadowView);
461
+ // The peer first, then accessibility. A <TextInput>'s label belongs on its
462
+ // peer and applyAccessibility can only put it there if the peer exists --
463
+ // and on the mount that creates it, in this order it does. The GTK side
464
+ // needed the same swap for the same reason.
465
+ applyTextInput(view, shadowView);
466
+ applyAccessibility(view, shadowView);
467
+ #ifdef BASALT_HAS_SKIA
468
+ // After applyProps, which is what puts nativeID on the view -- a canvas
469
+ // registers itself by that id and cannot be attached before it is there. And
470
+ // before applyLayoutMetrics only incidentally: the size it reads comes from the
471
+ // shadow view rather than from the frame this is about to set, so the order
472
+ // does not matter here the way it does for a <TextInput>'s peer.
473
+ applySkiaCanvas(view, shadowView);
474
+ #endif
475
+ applyLayoutMetrics(view, shadowView);
476
+ // Last: the scroll manager clamps its offset against the frame it was just
477
+ // given, and iOS documents the same ordering requirement -- layout before
478
+ // state, or the offset is clamped against a stale size.
479
+ applyScrollView(view, shadowView);
480
+ // After layout for the same reason the scroll view is: a control is centred
481
+ // in the frame it was just given, and a <Modal> commits that frame's size
482
+ // back into its own state.
483
+ applyControls(view, shadowView);
484
+ }
485
+
486
+
487
+ // ---------------------------------------------------------------------------
488
+ // Controls
489
+ // ---------------------------------------------------------------------------
490
+
491
+ void AppKitMountingManager::setHighlights(RnAppKitView *view,
492
+ const std::vector<basalt::Highlight> &highlights) {
493
+ // Flattened into the arrays RnAppKitView takes, because that file has no
494
+ // React Native in it and is not about to start.
495
+ std::vector<float> values;
496
+ std::vector<bool> filled;
497
+ values.reserve(highlights.size() * 8);
498
+ filled.reserve(highlights.size());
499
+ for (const basalt::Highlight &highlight : highlights) {
500
+ values.push_back(highlight.x);
501
+ values.push_back(highlight.y);
502
+ values.push_back(highlight.width);
503
+ values.push_back(highlight.height);
504
+ values.insert(values.end(), std::begin(highlight.color), std::end(highlight.color));
505
+ filled.push_back(highlight.filled);
506
+ }
507
+ // `std::vector<bool>` has no `data()`, so the flags are copied into something
508
+ // that does. A handful of rectangles, once a frame at worst.
509
+ std::vector<char> flags(filled.begin(), filled.end());
510
+ [view setRnHighlights:values.empty() ? nullptr : values.data()
511
+ filled:flags.empty() ? nullptr : reinterpret_cast<const bool *>(flags.data())
512
+ count:static_cast<NSInteger>(highlights.size())];
513
+ }
514
+
515
+ void AppKitMountingManager::pressedView(Tag tag) {
516
+ RnAppKitView *view = viewForTag(tag);
517
+ if (view == nil || view.rnControlKind != RnAppKitControlSwitch) {
518
+ return;
519
+ }
520
+ const auto known = switchValues_.find(tag);
521
+ if (known == switchValues_.end()) {
522
+ return;
523
+ }
524
+ if ([view.rnControl isKindOfClass:[NSSwitch class]] && !((NSSwitch *)view.rnControl).enabled) {
525
+ return;
526
+ }
527
+
528
+ // Told, and then left alone. React Native's <Switch> is a controlled
529
+ // component: the app's `value` prop is the only thing that moves it, and a
530
+ // switch that flipped itself would show a state its props do not agree with
531
+ // -- which is exactly what an app that ignores onValueChange is supposed to
532
+ // look like. The NSSwitch never moved, because it never saw the click.
533
+ basalt::emitSwitchChange(eventEmitterForTag(tag), tag, !known->second);
534
+ }
535
+
536
+ void AppKitMountingManager::applyControlPeer(RnAppKitView *view, const basalt::ControlState &state) {
537
+ const RnAppKitControlKind kind =
538
+ state.kind == basalt::ControlKind::Switch ? RnAppKitControlSwitch : RnAppKitControlSpinner;
539
+
540
+ // A view never changes which control it is -- React remounts rather than
541
+ // turning a switch into a spinner -- but a stale NSSwitch inside a spinner's
542
+ // frame is the kind of thing only a screenshot would show.
543
+ if (view.rnControl != nil && view.rnControlKind != kind) {
544
+ [view.rnControl removeFromSuperview];
545
+ view.rnControl = nil;
546
+ }
547
+
548
+ const Tag tag = static_cast<Tag>(view.rnTag);
549
+ NSView *control = view.rnControl;
550
+ if (control == nil) {
551
+ if (kind == RnAppKitControlSwitch) {
552
+ control = [[RnAppKitSwitch alloc] initWithFrame:NSZeroRect];
553
+ } else {
554
+ NSProgressIndicator *spinner = [[NSProgressIndicator alloc] initWithFrame:NSZeroRect];
555
+ spinner.style = NSProgressIndicatorStyleSpinning;
556
+ spinner.indeterminate = YES;
557
+ spinner.wantsLayer = YES;
558
+ control = spinner;
559
+ }
560
+ view.rnControl = control;
561
+ view.rnControlKind = kind;
562
+ [view addSubview:control];
563
+ }
564
+
565
+ if (kind == RnAppKitControlSwitch) {
566
+ switchValues_[tag] = state.on;
567
+ NSSwitch *toggle = (NSSwitch *)control;
568
+ toggle.state = state.on ? NSControlStateValueOn : NSControlStateValueOff;
569
+ toggle.enabled = !state.disabled;
570
+ } else {
571
+ NSProgressIndicator *spinner = (NSProgressIndicator *)control;
572
+ spinner.controlSize = state.large ? NSControlSizeRegular : NSControlSizeSmall;
573
+ // `displayedWhenStopped` is AppKit's spelling of `hidesWhenStopped`, and
574
+ // React Native's default for it is the same as AppKit's.
575
+ spinner.displayedWhenStopped = !state.hidesWhenStopped;
576
+ if (state.on) {
577
+ [spinner startAnimation:nil];
578
+ } else {
579
+ [spinner stopAnimation:nil];
580
+ }
581
+
582
+ // The tint. NSProgressIndicator has no colour property at all -- it draws
583
+ // in the system's -- so the only way through is a Core Image filter over
584
+ // its layer, which is what every macOS app that tints one does. A switch
585
+ // gets no equivalent: NSSwitch follows the system accent colour and
586
+ // exposes nothing per instance, so `trackColor` and `thumbColor` are
587
+ // honoured on GTK and Win32 and not here. The tree dump does not print
588
+ // colours, so this is a difference in pixels rather than in behaviour.
589
+ if (state.hasForeground) {
590
+ CIFilter *filter = [CIFilter filterWithName:@"CIFalseColor"];
591
+ [filter setDefaults];
592
+ CIColor *tint = [CIColor colorWithRed:state.foreground[0]
593
+ green:state.foreground[1]
594
+ blue:state.foreground[2]
595
+ alpha:state.foreground[3]];
596
+ [filter setValue:tint forKey:@"inputColor0"];
597
+ [filter setValue:tint forKey:@"inputColor1"];
598
+ spinner.contentFilters = @[filter];
599
+ } else {
600
+ spinner.contentFilters = @[];
601
+ }
602
+ }
603
+
604
+ // Centred at its natural size rather than filling the frame: a switch
605
+ // stretched to whatever box the app gave it is a rectangle with a circle in
606
+ // it, and neither React Native nor AppKit draws one that way.
607
+ const NSSize natural = control.intrinsicContentSize;
608
+ const NSRect bounds = view.bounds;
609
+ const CGFloat width = std::min(natural.width, bounds.size.width);
610
+ const CGFloat height = std::min(natural.height, bounds.size.height);
611
+ control.frame = NSMakeRect(std::round((bounds.size.width - width) / 2.0),
612
+ std::round((bounds.size.height - height) / 2.0),
613
+ width,
614
+ height);
615
+
616
+ const std::string described = basalt::describeControl(state);
617
+ view.rnControlDescription = described.empty()
618
+ ? nil
619
+ : [NSString stringWithUTF8String:described.c_str()];
620
+ }
621
+
622
+ void AppKitMountingManager::applyTextInput(RnAppKitView *view, const ShadowView &shadowView) {
623
+ if (shadowView.componentName == nullptr ||
624
+ std::string_view(shadowView.componentName) != "TextInput") {
625
+ return;
626
+ }
627
+ textInputs_.update(view, shadowView);
628
+ }
629
+
630
+ void AppKitMountingManager::applyScrollView(RnAppKitView *view, const ShadowView &shadowView) {
631
+ if (shadowView.componentName == nullptr ||
632
+ std::string_view(shadowView.componentName) != "ScrollView") {
633
+ return;
634
+ }
635
+ scrollViews_.update(view, shadowView);
636
+ }
637
+
638
+ // ---------------------------------------------------------------------------
639
+ // Applying a ShadowView to a view
640
+ // ---------------------------------------------------------------------------
641
+
642
+ // A <Paragraph> carries its text in state, not props: ParagraphShadowNode
643
+ // resolves the whole <Text> subtree into one AttributedString and commits it as
644
+ // ParagraphState, which is why nothing here walks child shadow nodes.
645
+ void AppKitMountingManager::applyText(RnAppKitView *view, const ShadowView &shadowView) {
646
+ if (shadowView.componentName == nullptr ||
647
+ std::string_view(shadowView.componentName) != "Paragraph") {
648
+ return;
649
+ }
650
+
651
+ const auto state =
652
+ std::dynamic_pointer_cast<const facebook::react::ConcreteState<ParagraphState>>(shadowView.state);
653
+ if (state == nullptr) {
654
+ return;
655
+ }
656
+
657
+ const auto &data = state->getData();
658
+ // Built through the same function the measurement seam uses, which is what
659
+ // makes the painted lines break where the measured ones did. The width is not
660
+ // baked in -- the view draws at whatever size Yoga gave it.
661
+ [view setRnTextLayout:basalt::buildTextLayout(data.attributedString, data.paragraphAttributes)];
662
+ }
663
+
664
+ void AppKitMountingManager::applyProps(RnAppKitView *view, const ShadowView &shadowView) {
665
+ const auto props = std::dynamic_pointer_cast<const ViewProps>(shadowView.props);
666
+ if (props == nullptr) {
667
+ return;
668
+ }
669
+
670
+ if (props->backgroundColor) {
671
+ const ColorComponents components = colorComponentsFromColor(props->backgroundColor);
672
+ [view setRnBackgroundColorRed:components.red
673
+ green:components.green
674
+ blue:components.blue
675
+ alpha:components.alpha
676
+ hasColor:YES];
677
+ } else {
678
+ [view setRnBackgroundColorRed:0 green:0 blue:0 alpha:0 hasColor:NO];
679
+ }
680
+
681
+ [view setRnOpacity:props->opacity];
682
+
683
+ // overflow: 'hidden'. React Native's default is 'visible'.
684
+ [view setRnClipsChildren:props->getClipsContentToBounds()];
685
+
686
+ // Radii depend on the frame -- percentage radii, and the clamping that stops
687
+ // opposite corners overlapping -- so they are resolved against the layout
688
+ // metrics rather than read raw.
689
+ const auto borders = props->resolveBorderMetrics(shadowView.layoutMetrics);
690
+
691
+ // All four corners, each with its own horizontal and vertical radius. The
692
+ // view layer keeps a single circular set on CALayer's own cornerRadius and
693
+ // puts anything else on a mask layer; see setRnBorderRadii:.
694
+ const CGFloat radii[8] = {
695
+ (CGFloat)borders.borderRadii.topLeft.horizontal,
696
+ (CGFloat)borders.borderRadii.topLeft.vertical,
697
+ (CGFloat)borders.borderRadii.topRight.horizontal,
698
+ (CGFloat)borders.borderRadii.topRight.vertical,
699
+ (CGFloat)borders.borderRadii.bottomRight.horizontal,
700
+ (CGFloat)borders.borderRadii.bottomRight.vertical,
701
+ (CGFloat)borders.borderRadii.bottomLeft.horizontal,
702
+ (CGFloat)borders.borderRadii.bottomLeft.vertical,
703
+ };
704
+ [view setRnBorderRadii:radii];
705
+
706
+ // Widths and colours, top/right/bottom/left -- the order CSS names them and
707
+ // the order the GTK side passes them in, so the two dumps line up.
708
+ const CGFloat widths[4] = {
709
+ (CGFloat)borders.borderWidths.top,
710
+ (CGFloat)borders.borderWidths.right,
711
+ (CGFloat)borders.borderWidths.bottom,
712
+ (CGFloat)borders.borderWidths.left,
713
+ };
714
+ const auto edgeColor = [](const auto &color, CGFloat *out) {
715
+ if (!color) {
716
+ // Transparent, not black -- an edge with a width and no colour paints
717
+ // nothing. The GTK side makes exactly the same call, and the two have to
718
+ // agree or the dumps diverge on a view neither actually draws a border
719
+ // for.
720
+ out[0] = out[1] = out[2] = out[3] = 0;
721
+ return;
722
+ }
723
+ const auto components = facebook::react::colorComponentsFromColor(*color);
724
+ out[0] = (CGFloat)components.red;
725
+ out[1] = (CGFloat)components.green;
726
+ out[2] = (CGFloat)components.blue;
727
+ out[3] = (CGFloat)components.alpha;
728
+ };
729
+ CGFloat colors[16];
730
+ edgeColor(borders.borderColors.top, &colors[0]);
731
+ edgeColor(borders.borderColors.right, &colors[4]);
732
+ edgeColor(borders.borderColors.bottom, &colors[8]);
733
+ edgeColor(borders.borderColors.left, &colors[12]);
734
+ [view setRnBorderWidths:widths colors:colors];
735
+
736
+ // resolveTransform folds in transformOrigin, but only when one was set: the
737
+ // default anchor is the view's centre, which is what a layer-backed NSView
738
+ // already uses.
739
+ const auto transform = props->resolveTransform(shadowView.layoutMetrics);
740
+ if (transform == facebook::react::Transform::Identity()) {
741
+ [view setRnTransform:nullptr];
742
+ } else {
743
+ [view setRnTransform:transform.matrix.data()];
744
+ }
745
+
746
+ // `backfaceVisibility: 'hidden'`, which stops the back of a card being drawn
747
+ // mirrored halfway through a flip. Whether a transform has turned away is
748
+ // core/Backface.h's to decide, so all three hide the same face.
749
+ [view setRnHidesBackFace:props->backfaceVisibility ==
750
+ facebook::react::BackfaceVisibility::Hidden];
751
+
752
+ // The app's nativeID, which marks a view for a behaviour React Native has
753
+ // no prop for -- a drop target today. See core/DragAndDrop.h, and
754
+ // core/TitleBarRegions.h for the older use of the same idea.
755
+ view.rnNativeId = props->nativeId.empty()
756
+ ? nil
757
+ : [NSString stringWithUTF8String:props->nativeId.c_str()];
758
+
759
+ // Painting and hit testing only; see setRnZIndex:.
760
+ [view setRnZIndex:(NSInteger)props->zIndex.value_or(0)];
761
+
762
+ // Hit testing only. RnAppKitHitTest reads it; nothing about drawing does.
763
+ switch (props->pointerEvents) {
764
+ case facebook::react::PointerEventsMode::None:
765
+ view.rnPointerEvents = RnAppKitPointerEventsNone;
766
+ break;
767
+ case facebook::react::PointerEventsMode::BoxNone:
768
+ view.rnPointerEvents = RnAppKitPointerEventsBoxNone;
769
+ break;
770
+ case facebook::react::PointerEventsMode::BoxOnly:
771
+ view.rnPointerEvents = RnAppKitPointerEventsBoxOnly;
772
+ break;
773
+ case facebook::react::PointerEventsMode::Auto:
774
+ view.rnPointerEvents = RnAppKitPointerEventsAuto;
775
+ break;
776
+ }
777
+ }
778
+
779
+ namespace {
780
+
781
+ RnAppKitAccessibleFlag toFlag(bool value) {
782
+ return value ? RnAppKitAccessibleTrue : RnAppKitAccessibleFalse;
783
+ }
784
+
785
+ // The role React Native effectively means for this view: what the app asked
786
+ // for, or what the component implies.
787
+ //
788
+ // A <Text> is a label and an <Image> is an image whether or not the app said
789
+ // so, which is what makes an ordinary screen navigable without every developer
790
+ // having annotated it. The GTK side infers the same two, at construction time,
791
+ // because its role is construct-only.
792
+ std::string effectiveRole(const ShadowView &shadowView) {
793
+ if (const auto props =
794
+ std::dynamic_pointer_cast<const facebook::react::AccessibilityProps>(shadowView.props)) {
795
+ if (!props->accessibilityRole.empty()) {
796
+ return props->accessibilityRole;
797
+ }
798
+ }
799
+ if (shadowView.componentName != nullptr) {
800
+ const std::string_view name(shadowView.componentName);
801
+ if (name == "Paragraph") {
802
+ return "text";
803
+ }
804
+ if (name == "Image") {
805
+ return "image";
806
+ }
807
+ }
808
+ return {};
809
+ }
810
+
811
+ } // namespace
812
+
813
+ // Accessibility, as VoiceOver sees it.
814
+ void AppKitMountingManager::applyAccessibility(RnAppKitView *view, const ShadowView &shadowView) {
815
+ const auto props =
816
+ std::dynamic_pointer_cast<const facebook::react::AccessibilityProps>(shadowView.props);
817
+ if (props == nullptr) {
818
+ return;
819
+ }
820
+
821
+ const std::string role = effectiveRole(shadowView);
822
+ [view setRnAccessibleRole:role.empty() ? nil : [NSString stringWithUTF8String:role.c_str()]];
823
+
824
+ // A label given in props wins. Falling back to a Paragraph's own text means a
825
+ // plain <Text> announces itself without the app having to repeat the string
826
+ // in an accessibilityLabel.
827
+ std::string label = props->accessibilityLabel;
828
+ if (label.empty() && shadowView.componentName != nullptr &&
829
+ std::string_view(shadowView.componentName) == "Paragraph") {
830
+ if (const auto state =
831
+ std::dynamic_pointer_cast<const facebook::react::ConcreteState<ParagraphState>>(
832
+ shadowView.state)) {
833
+ label = state->getData().attributedString.getString();
834
+ }
835
+ }
836
+
837
+ NSString *labelText = label.empty() ? nil : [NSString stringWithUTF8String:label.c_str()];
838
+ NSString *hintText = props->accessibilityHint.empty()
839
+ ? nil
840
+ : [NSString stringWithUTF8String:props->accessibilityHint.c_str()];
841
+
842
+ // A <TextInput>'s label belongs on its peer, not on this view.
843
+ //
844
+ // The peer is a real NSTextField and so is already an AXTextField in its own
845
+ // right -- which is the element VoiceOver lands on. Left alone, the label
846
+ // goes on the wrapper and the field announces itself as an unnamed "text
847
+ // field", so a field the app carefully labelled is read out as if it had no
848
+ // label at all. Checked with System Events: the group carried "your name"
849
+ // and the AXTextField inside it carried nothing.
850
+ //
851
+ // The wrapper then stops being an element of its own, because two nested
852
+ // elements for one control is a worse tree than one: a screen reader stops
853
+ // twice and says the name once.
854
+ if (NSView *peer = view.rnEditable) {
855
+ peer.accessibilityLabel = labelText;
856
+ if (hintText != nil) {
857
+ peer.accessibilityHelp = hintText;
858
+ }
859
+ [view setRnAccessibleLabel:nil hint:nil];
860
+ // Not `setRnAccessibleRole:@"none"`, which would be React Native's role
861
+ // vocabulary and so would print in `describeTree` -- and GTK cannot answer
862
+ // it, because a GtkAccessible role is construct-only. That made the two
863
+ // hosts' trees disagree on this view and nothing else. Taking the wrapper
864
+ // out of the accessibility tree directly says the same thing to VoiceOver
865
+ // and nothing at all to the dump.
866
+ view.accessibilityElement = NO;
867
+ return;
868
+ }
869
+
870
+ [view setRnAccessibleLabel:labelText hint:hintText];
871
+
872
+ if (props->accessibilityState.has_value()) {
873
+ const auto &state = *props->accessibilityState;
874
+ RnAppKitAccessibleFlag checked = RnAppKitAccessibleUnset;
875
+ switch (state.checked) {
876
+ case facebook::react::AccessibilityState::Checked:
877
+ checked = RnAppKitAccessibleTrue;
878
+ break;
879
+ case facebook::react::AccessibilityState::Unchecked:
880
+ checked = RnAppKitAccessibleFalse;
881
+ break;
882
+ case facebook::react::AccessibilityState::Mixed:
883
+ case facebook::react::AccessibilityState::None:
884
+ break;
885
+ }
886
+ [view setRnAccessibleStateDisabled:toFlag(state.disabled)
887
+ checked:checked
888
+ selected:toFlag(state.selected)
889
+ expanded:state.expanded.has_value()
890
+ ? toFlag(*state.expanded)
891
+ : RnAppKitAccessibleUnset
892
+ busy:toFlag(state.busy)];
893
+ } else {
894
+ [view setRnAccessibleStateDisabled:RnAppKitAccessibleUnset
895
+ checked:RnAppKitAccessibleUnset
896
+ selected:RnAppKitAccessibleUnset
897
+ expanded:RnAppKitAccessibleUnset
898
+ busy:RnAppKitAccessibleUnset];
899
+ }
900
+
901
+ // Keyboard focus. `accessible` is the signal because React Native's
902
+ // `focusable` prop never reaches this platform -- see AppKitFocus.h -- and
903
+ // because it is what <Pressable> sets on everything it renders. A hidden view
904
+ // is not focusable whatever it says: Tab stopping on something nobody can see
905
+ // is worse than Tab skipping it.
906
+ // A control is focusable whether or not the app said `accessible`.
907
+ //
908
+ // React Native's `focusable` prop never reaches this platform -- ReactCommon
909
+ // parses it only into Android's and tvOS's props -- so `accessible` is the
910
+ // signal, and <Pressable> sets it. <Switch> does not: on a phone the native
911
+ // control is focusable by being a control, and React Native never had to say
912
+ // so. On a desktop a switch that Tab skips is simply broken, so being a
913
+ // control is the signal here too.
914
+ // Disabled is excluded, which is what every desktop does: Tab skips a control
915
+ // that cannot be operated rather than stopping on one that does nothing.
916
+ const basalt::ControlState control = basalt::controlStateOf(shadowView);
917
+ const bool isControl = control.kind == basalt::ControlKind::Switch && !control.disabled;
918
+ view.rnFocusable =
919
+ (props->accessible || isControl) && !props->accessibilityElementsHidden ? YES : NO;
920
+
921
+ // accessibilityElementsHidden hides the subtree; importantForAccessibility
922
+ // NoHideDescendants is Android's spelling of the same idea.
923
+ const bool hidden = props->accessibilityElementsHidden ||
924
+ props->importantForAccessibility ==
925
+ facebook::react::ImportantForAccessibility::NoHideDescendants;
926
+ [view setRnAccessibleHidden:hidden ? YES : NO];
927
+ }
928
+
929
+ void AppKitMountingManager::applyLayoutMetrics(RnAppKitView *view, const ShadowView &shadowView) {
930
+ const auto &frame = shadowView.layoutMetrics.frame;
931
+ [view setRnFrameX:frame.origin.x
932
+ y:frame.origin.y
933
+ width:frame.size.width
934
+ height:frame.size.height];
935
+
936
+ // display: 'none' keeps the node in the shadow tree but takes it out of
937
+ // layout and painting.
938
+ [view setRnHidden:shadowView.layoutMetrics.displayType ==
939
+ facebook::react::DisplayType::None
940
+ ? YES
941
+ : NO];
942
+
943
+ // TODO(layout): pointScaleFactor, once a Retina backing store is involved.
944
+ }
945
+
946
+ } // namespace basalt