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.
- package/LICENSE +21 -0
- package/cli/runMacos.js +33 -0
- package/cli/shared.js +75 -0
- package/native/CMakeLists.txt +298 -0
- package/native/appkit/AppKitAnimationChoreographer.h +54 -0
- package/native/appkit/AppKitAnimationChoreographer.mm +125 -0
- package/native/appkit/AppKitDropTarget.h +34 -0
- package/native/appkit/AppKitDropTarget.mm +292 -0
- package/native/appkit/AppKitFocus.h +120 -0
- package/native/appkit/AppKitFocus.mm +293 -0
- package/native/appkit/AppKitImageLoader.h +80 -0
- package/native/appkit/AppKitImageLoader.mm +156 -0
- package/native/appkit/AppKitKeyEvents.h +26 -0
- package/native/appkit/AppKitKeyEvents.mm +97 -0
- package/native/appkit/AppKitMenuBar.mm +424 -0
- package/native/appkit/AppKitMountingManager.h +135 -0
- package/native/appkit/AppKitMountingManager.mm +946 -0
- package/native/appkit/AppKitRunLoopObserver.h +34 -0
- package/native/appkit/AppKitRunLoopObserver.mm +42 -0
- package/native/appkit/AppKitScrollView.h +168 -0
- package/native/appkit/AppKitScrollView.mm +550 -0
- package/native/appkit/AppKitSkiaContext.h +33 -0
- package/native/appkit/AppKitSkiaContext.mm +255 -0
- package/native/appkit/AppKitSkiaModule.h +53 -0
- package/native/appkit/AppKitSkiaModule.mm +81 -0
- package/native/appkit/AppKitSkiaPeer.h +37 -0
- package/native/appkit/AppKitSkiaPeer.mm +123 -0
- package/native/appkit/AppKitSkiaView.h +62 -0
- package/native/appkit/AppKitSnapshot.h +21 -0
- package/native/appkit/AppKitSnapshot.mm +91 -0
- package/native/appkit/AppKitTextInput.h +140 -0
- package/native/appkit/AppKitTextInput.mm +588 -0
- package/native/appkit/AppKitTextPeer.h +74 -0
- package/native/appkit/AppKitTextPeer.mm +353 -0
- package/native/appkit/AppKitTitleBar.h +90 -0
- package/native/appkit/AppKitTitleBar.mm +186 -0
- package/native/appkit/AppKitTouchDispatcher.h +138 -0
- package/native/appkit/AppKitTouchDispatcher.mm +514 -0
- package/native/appkit/AppKitWindowControl.mm +222 -0
- package/native/appkit/AppKitWindowModule.h +62 -0
- package/native/appkit/AppKitWindowModule.mm +194 -0
- package/native/appkit/ColorSchemeAppKit.mm +71 -0
- package/native/appkit/ComponentRegistryAppKit.mm +121 -0
- package/native/appkit/CoreTextLayout.h +43 -0
- package/native/appkit/CoreTextLayout.mm +252 -0
- package/native/appkit/CoreTextLayoutManager.mm +138 -0
- package/native/appkit/FontRegistryCoreText.mm +148 -0
- package/native/appkit/PlatformServicesAppKit.mm +540 -0
- package/native/appkit/RnAppKitView.h +417 -0
- package/native/appkit/RnAppKitView.mm +1605 -0
- package/native/appkit/RnTextLayout.h +38 -0
- package/native/appkit/RnTextLayout.mm +168 -0
- package/native/appkit/demo_layout_appkit.mm +93 -0
- package/native/appkit/main_appkit.mm +1860 -0
- package/native/appkit/mount_harness_appkit.mm +197 -0
- package/native/tests/test_appkit_accessibility.mm +170 -0
- package/native/tests/test_appkit_image.mm +237 -0
- package/native/tests/test_appkit_input.mm +852 -0
- package/native/tests/test_appkit_key_events.mm +150 -0
- package/native/tests/test_appkit_mounting.mm +430 -0
- package/native/tests/test_appkit_scrollbar.mm +132 -0
- package/native/tests/test_appkit_services.mm +57 -0
- package/native/tests/test_appkit_skia.mm +212 -0
- package/native/tests/test_appkit_text.mm +180 -0
- package/native/tests/test_appkit_textinput.mm +748 -0
- package/native/tests/test_appkit_titlebar.mm +107 -0
- package/native/tests/test_appkit_view.mm +237 -0
- package/package.json +26 -0
- 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
|