basalt-appkit 0.0.0-rc5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/native/CMakeLists.txt +16 -2
  2. package/native/appkit/AppKitAppWindow.h +24 -0
  3. package/native/appkit/AppKitImageLoader.h +48 -1
  4. package/native/appkit/AppKitImageLoader.mm +228 -10
  5. package/native/appkit/AppKitMenuBar.mm +58 -0
  6. package/native/appkit/AppKitMountingManager.h +34 -0
  7. package/native/appkit/AppKitMountingManager.mm +576 -9
  8. package/native/appkit/AppKitScrollView.h +48 -0
  9. package/native/appkit/AppKitScrollView.mm +210 -0
  10. package/native/appkit/AppKitTextInput.h +38 -1
  11. package/native/appkit/AppKitTextInput.mm +235 -8
  12. package/native/appkit/AppKitTextPeer.h +60 -0
  13. package/native/appkit/AppKitTextPeer.mm +307 -0
  14. package/native/appkit/AppKitTouchDispatcher.h +56 -0
  15. package/native/appkit/AppKitTouchDispatcher.mm +140 -0
  16. package/native/appkit/AppKitWindowControl.mm +38 -3
  17. package/native/appkit/ComponentRegistryAppKit.mm +20 -0
  18. package/native/appkit/CoreTextLayout.h +64 -2
  19. package/native/appkit/CoreTextLayout.mm +431 -71
  20. package/native/appkit/CoreTextLayoutManager.mm +57 -6
  21. package/native/appkit/FontRegistryCoreText.mm +6 -5
  22. package/native/appkit/PlatformServicesAppKit.mm +101 -20
  23. package/native/appkit/RnAppKitView.h +492 -0
  24. package/native/appkit/RnAppKitView.mm +2266 -23
  25. package/native/appkit/RnTextLayout.h +106 -0
  26. package/native/appkit/RnTextLayout.mm +321 -2
  27. package/native/appkit/main_appkit.mm +174 -3
  28. package/native/tests/test_appkit_accessibility.mm +289 -0
  29. package/native/tests/test_appkit_borders.mm +975 -0
  30. package/native/tests/test_appkit_fonts.mm +203 -0
  31. package/native/tests/test_appkit_image.mm +392 -17
  32. package/native/tests/test_appkit_input.mm +209 -0
  33. package/native/tests/test_appkit_mounting.mm +360 -0
  34. package/native/tests/test_appkit_scrollbar.mm +125 -0
  35. package/native/tests/test_appkit_scrollview.mm +371 -0
  36. package/native/tests/test_appkit_selection.mm +349 -0
  37. package/native/tests/test_appkit_services.mm +51 -1
  38. package/native/tests/test_appkit_text.mm +1628 -0
  39. package/native/tests/test_appkit_textinput.mm +819 -0
  40. package/native/tests/test_appkit_titlebar.mm +43 -0
  41. package/native/tests/test_appkit_view.mm +609 -0
  42. package/package.json +4 -3
@@ -129,8 +129,10 @@ target_include_directories(basalt_appkit_view PUBLIC appkit)
129
129
  # so anything it takes from there has to be `inline`.
130
130
  target_include_directories(basalt_appkit_view PUBLIC
131
131
  ${CMAKE_CURRENT_SOURCE_DIR}/../../basalt-core/native/core)
132
+ # CoreImage is here for one prop: an <Image>'s `blurRadius`, which Core Graphics
133
+ # has no filter of its own for. See RnBlurredImageCreate.
132
134
  target_link_libraries(basalt_appkit_view PUBLIC
133
- "-framework Cocoa" "-framework QuartzCore" "-framework CoreText")
135
+ "-framework Cocoa" "-framework QuartzCore" "-framework CoreText" "-framework CoreImage")
134
136
 
135
137
  add_executable(demo_layout_appkit appkit/demo_layout_appkit.mm)
136
138
  target_compile_options(demo_layout_appkit PRIVATE -Wall -Wextra -fobjc-arc)
@@ -144,9 +146,13 @@ set(BASALT_APPKIT_TEST_SOURCES
144
146
  # Core Text, against RnTextLayout alone. No React Native, so these run in the
145
147
  # toolkit-only build too.
146
148
  tests/test_appkit_text.mm
149
+ tests/test_appkit_selection.mm
147
150
  # The resize modes, against a real bitmap. No React Native, so these run in
148
151
  # the toolkit-only build too.
149
152
  tests/test_appkit_image.mm
153
+ # Dotted and dashed borders, also against a bitmap: what a border is drawn as
154
+ # rather than what the view was told.
155
+ tests/test_appkit_borders.mm
150
156
  # The overlay scrollbar's platform half: that it is a view above the content
151
157
  # rather than paint behind it. No React Native, so these run in the
152
158
  # toolkit-only build too.
@@ -269,6 +275,10 @@ if(TARGET basalt_core)
269
275
  # package header it was never given the include path for.
270
276
  ${BASALT_PACKAGE_TESTS}
271
277
  tests/test_appkit_mounting.mm
278
+ # Fonts loaded at runtime: core/FontRegistry.h's macOS half, and the
279
+ # measurement cache a late registration has to invalidate. Needs React
280
+ # Native, for the AttributedString a paragraph is measured from.
281
+ tests/test_appkit_fonts.mm
272
282
  # Hit testing lives with the touch dispatcher, which the host builds; the
273
283
  # tests need it too, and it pulls in nothing else.
274
284
  appkit/AppKitFocus.mm
@@ -279,7 +289,11 @@ if(TARGET basalt_core)
279
289
  tests/test_appkit_input.mm
280
290
  tests/test_appkit_key_events.mm
281
291
  # <TextInput>'s controlled-value loop, through real mutations.
282
- tests/test_appkit_textinput.mm)
292
+ tests/test_appkit_textinput.mm
293
+ # <ScrollView>'s props, as far as they reach without a window: the manager
294
+ # takes a view and an emitter lookup, so the offset a prop asks for is
295
+ # observable with no display at all.
296
+ tests/test_appkit_scrollview.mm)
283
297
  endif()
284
298
 
285
299
  add_executable(basalt_appkit_tests ${BASALT_APPKIT_TEST_SOURCES})
@@ -0,0 +1,24 @@
1
+ // The window this process owns, for the parts of the host that need it and are
2
+ // not the window module.
3
+ //
4
+ // `NSApp.keyWindow` and `NSApp.mainWindow` are both nil whenever the app is not
5
+ // the active one, and two different things were asking only those: a bounds read
6
+ // answered 0x0, and a menu role sent through the responder chain went nowhere.
7
+ // Both are what an app looks like when it was launched while something else had
8
+ // focus, which is most of the time under a test harness and some of the time on
9
+ // a desktop.
10
+ //
11
+ // So the rule lives in one place. It is GTK's rule, from
12
+ // GtkWindowControl.cpp: prefer the active window, fall back to the first visible
13
+ // one that is not a panel.
14
+
15
+ #pragma once
16
+
17
+ #import <AppKit/AppKit.h>
18
+
19
+ namespace basalt {
20
+
21
+ // The app's own window, or nil when it has none yet. Main thread only.
22
+ NSWindow *appWindow();
23
+
24
+ } // namespace basalt
@@ -23,7 +23,9 @@
23
23
  #include <functional>
24
24
  #include <string>
25
25
  #include <unordered_map>
26
+ #include <vector>
26
27
 
28
+ #include "ImageBytes.h"
27
29
  #include "ImageCache.h"
28
30
 
29
31
  #include <react/io/IImageLoader.h>
@@ -49,7 +51,43 @@ class AppKitImageLoader : public facebook::react::IImageLoader {
49
51
  AppKitImageLoader(AppKitImageLoader &&) = delete;
50
52
  AppKitImageLoader &operator=(AppKitImageLoader &&) = delete;
51
53
 
52
- void load(const std::string &uri, Callback &&callback);
54
+ // `onProgress` is optional and is `basalt::ImageProgress`: how much of an
55
+ // http fetch has arrived, called from the fetching thread. Only an http URI
56
+ // reports anything; see core/ImageBytes.h.
57
+ //
58
+ // Not marshalled to the main thread, unlike the callback above, and that is
59
+ // the point of it: a Fabric event emitter may be dispatched from any thread,
60
+ // and a progress tick that had to wait for the main queue would arrive after
61
+ // the image it is about.
62
+ void load(const std::string &uri,
63
+ Callback &&callback,
64
+ basalt::ImageProgress onProgress = {});
65
+
66
+ // The frames of an animated image, or null.
67
+ //
68
+ // Separate from `load` because being animated is a property of the file
69
+ // rather than of the request: `load` answers with the first frame, which is
70
+ // what a still <Image> draws and what an animated one starts on, and this
71
+ // says whether there is more. Both come out of the same decode, so asking
72
+ // costs nothing.
73
+ //
74
+ // The arrays are the loader's own and stay at the same addresses for as long
75
+ // as the URI is cached, which is what lets a view tell "the same animation
76
+ // again" from "a different one" when a layout-only mutation re-applies the
77
+ // props. The pointer is good until the next load, since an eviction can take
78
+ // the entry away.
79
+ struct Animation {
80
+ // CGImageRefs, one per frame, bridged into an NSArray so that
81
+ // RnAppKitView.h stays free of C++ -- the same reason the paragraph is
82
+ // handed over as an object.
83
+ NSArray *frames;
84
+ // The file's own delays in milliseconds, unclamped: the clamp is
85
+ // core/ImageAnimation.h's and belongs where the arithmetic is.
86
+ NSArray<NSNumber *> *delaysMs;
87
+ // Zero for forever, which is what a looping GIF carries.
88
+ unsigned loopCount;
89
+ };
90
+ const Animation *animation(const std::string &uri);
53
91
 
54
92
  // --- IImageLoader ----------------------------------------------------------
55
93
  //
@@ -70,8 +108,17 @@ class AppKitImageLoader : public facebook::react::IImageLoader {
70
108
  // Nothing evicts from this yet; see docs/BACKLOG.md.
71
109
  // Caches an image and releases whatever the policy dropped.
72
110
  void remember(const std::string &uri, CGImageRef image);
111
+ void rememberAnimation(const std::string &uri,
112
+ const std::vector<CGImageRef> &frames,
113
+ const std::vector<unsigned> &delaysMs,
114
+ unsigned loopCount);
115
+ void releaseAnimation(Animation &animation);
116
+ void forgetAnimations();
73
117
 
74
118
  std::unordered_map<std::string, CGImageRef> cache_;
119
+ // Only for the files that have more than one frame, so a still image costs
120
+ // nothing. Keyed by the same URI and dropped by the same eviction.
121
+ std::unordered_map<std::string, Animation> animations_;
75
122
  // Which URI goes next, and when. The images are this class's; the decision
76
123
  // is shared with the other two hosts. See core/ImageCache.h.
77
124
  basalt::ImageCachePolicy policy_;
@@ -4,8 +4,10 @@
4
4
 
5
5
  #import <ImageIO/ImageIO.h>
6
6
 
7
+ #include <optional>
7
8
  #include <thread>
8
9
  #include <utility>
10
+ #include <vector>
9
11
 
10
12
  namespace basalt {
11
13
 
@@ -14,8 +16,17 @@ struct AppKitImageLoader::Pending {
14
16
  AppKitImageLoader *loader;
15
17
  std::string uri;
16
18
  CGImageRef image;
19
+ // Every frame and its delay, for a file that has more than one. Decoded on
20
+ // the worker thread with the first frame, because a CGImage is immutable and
21
+ // the expensive part is the same work either way.
22
+ std::vector<CGImageRef> frames;
23
+ std::vector<unsigned> delaysMs;
24
+ unsigned loopCount;
17
25
  std::string error;
18
26
  Callback callback;
27
+ // Called from the worker thread as the bytes arrive, rather than from the
28
+ // main-queue delivery below: see the header.
29
+ basalt::ImageProgress onProgress;
19
30
  };
20
31
 
21
32
  AppKitImageLoader::AppKitImageLoader() = default;
@@ -26,6 +37,29 @@ AppKitImageLoader::~AppKitImageLoader() {
26
37
  CGImageRelease(image);
27
38
  }
28
39
  cache_.clear();
40
+ forgetAnimations();
41
+ }
42
+
43
+ // Releases every frame of every animation.
44
+ //
45
+ // Each frame carries two references: the one the decode produced, which this
46
+ // class owns, and the one the NSArray took when the frame went in. So both have
47
+ // to go: the `CGImageRelease` drops the decode's, and nilling the array lets
48
+ // ARC drop the array's.
49
+ void AppKitImageLoader::forgetAnimations() {
50
+ for (auto &[uri, animation] : animations_) {
51
+ (void)uri;
52
+ releaseAnimation(animation);
53
+ }
54
+ animations_.clear();
55
+ }
56
+
57
+ void AppKitImageLoader::releaseAnimation(Animation &animation) {
58
+ for (id frame in animation.frames) {
59
+ CGImageRelease((__bridge CGImageRef)frame);
60
+ }
61
+ animation.frames = nil;
62
+ animation.delaysMs = nil;
29
63
  }
30
64
 
31
65
  namespace {
@@ -35,7 +69,96 @@ namespace {
35
69
  // means telling it a size and a context -- so a 160x100 PNG can come back
36
70
  // 320x200 on a Retina display, which then measures wrong. CGImageSource hands
37
71
  // back exactly what is in the file.
38
- CGImageRef decode(const std::string &bytes, std::string *error) {
72
+ // One frame's delay in milliseconds, or nothing when the format carries none.
73
+ //
74
+ // Three dictionaries for three formats, and the unclamped delay first where
75
+ // there is one: ImageIO's clamped value has already had a floor of its own
76
+ // applied, and the clamp belongs in core/ImageAnimation.h where both hosts and
77
+ // the next one can see it.
78
+ std::optional<unsigned> delayOfFrame(CGImageSourceRef source, size_t index) {
79
+ CFDictionaryRef properties = CGImageSourceCopyPropertiesAtIndex(source, index, nullptr);
80
+ if (properties == nullptr) {
81
+ return std::nullopt;
82
+ }
83
+
84
+ std::optional<unsigned> milliseconds;
85
+ for (CFStringRef container : {kCGImagePropertyGIFDictionary,
86
+ kCGImagePropertyPNGDictionary,
87
+ kCGImagePropertyWebPDictionary}) {
88
+ const void *found = CFDictionaryGetValue(properties, container);
89
+ if (found == nullptr) {
90
+ continue;
91
+ }
92
+ CFDictionaryRef dictionary = static_cast<CFDictionaryRef>(found);
93
+ for (CFStringRef key : {kCGImagePropertyGIFUnclampedDelayTime,
94
+ kCGImagePropertyGIFDelayTime,
95
+ kCGImagePropertyAPNGUnclampedDelayTime,
96
+ kCGImagePropertyAPNGDelayTime,
97
+ kCGImagePropertyWebPUnclampedDelayTime,
98
+ kCGImagePropertyWebPDelayTime}) {
99
+ const void *value = CFDictionaryGetValue(dictionary, key);
100
+ if (value == nullptr) {
101
+ continue;
102
+ }
103
+ double seconds = 0.0;
104
+ if (CFNumberGetValue(static_cast<CFNumberRef>(value), kCFNumberDoubleType, &seconds)) {
105
+ milliseconds = static_cast<unsigned>(seconds * 1000.0 + 0.5);
106
+ break;
107
+ }
108
+ }
109
+ if (milliseconds.has_value()) {
110
+ break;
111
+ }
112
+ }
113
+
114
+ CFRelease(properties);
115
+ return milliseconds;
116
+ }
117
+
118
+ // How many times round, zero for forever.
119
+ //
120
+ // There is no "absent" case to handle, which was measured rather than assumed:
121
+ // ImageIO answers `kCGImagePropertyGIFLoopCount` with 1 for a GIF carrying no
122
+ // NETSCAPE2.0 extension at all, which is the format's way of saying play once.
123
+ // So the fallback below is unreachable for a GIF and left as forever for a
124
+ // format that really says nothing.
125
+ unsigned loopCountOf(CGImageSourceRef source) {
126
+ CFDictionaryRef properties = CGImageSourceCopyProperties(source, nullptr);
127
+ if (properties == nullptr) {
128
+ return 0;
129
+ }
130
+
131
+ unsigned loops = 0;
132
+ for (auto [container, key] :
133
+ {std::pair<CFStringRef, CFStringRef>{kCGImagePropertyGIFDictionary,
134
+ kCGImagePropertyGIFLoopCount},
135
+ std::pair<CFStringRef, CFStringRef>{kCGImagePropertyPNGDictionary,
136
+ kCGImagePropertyAPNGLoopCount},
137
+ std::pair<CFStringRef, CFStringRef>{kCGImagePropertyWebPDictionary,
138
+ kCGImagePropertyWebPLoopCount}}) {
139
+ const void *found = CFDictionaryGetValue(properties, container);
140
+ if (found == nullptr) {
141
+ continue;
142
+ }
143
+ const void *value = CFDictionaryGetValue(static_cast<CFDictionaryRef>(found), key);
144
+ int count = 0;
145
+ if (value != nullptr &&
146
+ CFNumberGetValue(static_cast<CFNumberRef>(value), kCFNumberIntType, &count) &&
147
+ count > 0) {
148
+ loops = static_cast<unsigned>(count);
149
+ break;
150
+ }
151
+ }
152
+
153
+ CFRelease(properties);
154
+ return loops;
155
+ }
156
+
157
+ CGImageRef decode(const std::string &bytes,
158
+ std::string *error,
159
+ std::vector<CGImageRef> *frames = nullptr,
160
+ std::vector<unsigned> *delaysMs = nullptr,
161
+ unsigned *loopCount = nullptr) {
39
162
  if (bytes.empty()) {
40
163
  *error = "no image data";
41
164
  return nullptr;
@@ -56,15 +179,51 @@ CGImageRef decode(const std::string &bytes, std::string *error) {
56
179
  return nullptr;
57
180
  }
58
181
 
59
- // Index 0: an animated GIF and a multi-page TIFF both have more, and drawing
60
- // the first frame is the honest thing to do until there is an animator. See
61
- // docs/BACKLOG.md.
62
182
  CGImageRef image = CGImageSourceCreateImageAtIndex(source, 0, nullptr);
63
- CFRelease(source);
64
183
  if (image == nullptr) {
184
+ CFRelease(source);
65
185
  *error = "could not decode image";
66
186
  return nullptr;
67
187
  }
188
+
189
+ // The rest of the frames, when there are any. A multi-page TIFF reaches here
190
+ // too and is deliberately not animated: it carries no per-frame delay, so
191
+ // `delayOfFrame` answers nothing, the frames are dropped, and the caller sees
192
+ // the still first page -- which is what a multi-page TIFF is.
193
+ if (frames != nullptr && delaysMs != nullptr && loopCount != nullptr) {
194
+ const size_t count = CGImageSourceGetCount(source);
195
+ if (count > 1) {
196
+ std::vector<unsigned> delays;
197
+ std::vector<CGImageRef> decoded;
198
+ bool everyFrameHasADelay = true;
199
+ for (size_t index = 0; index < count; index++) {
200
+ const std::optional<unsigned> delay = delayOfFrame(source, index);
201
+ if (!delay.has_value()) {
202
+ everyFrameHasADelay = false;
203
+ break;
204
+ }
205
+ CGImageRef frame = index == 0 ? CGImageRetain(image)
206
+ : CGImageSourceCreateImageAtIndex(source, index, nullptr);
207
+ if (frame == nullptr) {
208
+ everyFrameHasADelay = false;
209
+ break;
210
+ }
211
+ decoded.push_back(frame);
212
+ delays.push_back(*delay);
213
+ }
214
+ if (everyFrameHasADelay) {
215
+ *frames = std::move(decoded);
216
+ *delaysMs = std::move(delays);
217
+ *loopCount = loopCountOf(source);
218
+ } else {
219
+ for (CGImageRef frame : decoded) {
220
+ CGImageRelease(frame);
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ CFRelease(source);
68
227
  return image;
69
228
  }
70
229
 
@@ -79,7 +238,18 @@ void AppKitImageLoader::remember(const std::string &uri, CGImageRef image) {
79
238
  // Height times the row stride, which is what the decode actually allocated
80
239
  // -- rows are padded, so width times four is an underestimate on some
81
240
  // widths and this is not.
82
- const size_t bytes = CGImageGetBytesPerRow(image) * CGImageGetHeight(image);
241
+ size_t bytes = CGImageGetBytesPerRow(image) * CGImageGetHeight(image);
242
+ // Every frame of an animation, because every frame is decoded and held: a
243
+ // sixty-frame GIF is sixty images and accounting for one of them would let
244
+ // the cache hold far more than it was told to.
245
+ if (const auto found = animations_.find(uri); found != animations_.end()) {
246
+ for (id frame in found->second.frames) {
247
+ CGImageRef one = (__bridge CGImageRef)frame;
248
+ if (one != image) {
249
+ bytes += CGImageGetBytesPerRow(one) * CGImageGetHeight(one);
250
+ }
251
+ }
252
+ }
83
253
 
84
254
  for (const std::string &evicted : policy_.insert(uri, bytes)) {
85
255
  const auto it = cache_.find(evicted);
@@ -87,10 +257,16 @@ void AppKitImageLoader::remember(const std::string &uri, CGImageRef image) {
87
257
  CGImageRelease(it->second);
88
258
  cache_.erase(it);
89
259
  }
260
+ if (const auto animated = animations_.find(evicted); animated != animations_.end()) {
261
+ releaseAnimation(animated->second);
262
+ animations_.erase(animated);
263
+ }
90
264
  }
91
265
  }
92
266
 
93
- void AppKitImageLoader::load(const std::string &uri, Callback &&callback) {
267
+ void AppKitImageLoader::load(const std::string &uri,
268
+ Callback &&callback,
269
+ basalt::ImageProgress onProgress) {
94
270
  if (uri.empty()) {
95
271
  callback(nullptr, "empty source uri");
96
272
  return;
@@ -102,20 +278,28 @@ void AppKitImageLoader::load(const std::string &uri, Callback &&callback) {
102
278
  return;
103
279
  }
104
280
 
105
- auto *pending = new Pending{this, uri, nullptr, {}, std::move(callback)};
281
+ auto *pending = new Pending{this, uri, nullptr, {}, {}, 0U, {},
282
+ std::move(callback), std::move(onProgress)};
106
283
 
107
284
  std::thread([pending]() {
108
285
  std::string bytes;
109
286
  // Both the fetch and the decode run here. A CGImage is immutable and
110
287
  // thread-safe, so unlike the GTK side there is nothing that has to wait for
111
288
  // the main thread except the delivery itself.
112
- if (fetchImageBytes(pending->uri, &bytes, &pending->error)) {
113
- pending->image = decode(bytes, &pending->error);
289
+ if (fetchImageBytes(pending->uri, &bytes, &pending->error, pending->onProgress)) {
290
+ pending->image = decode(bytes, &pending->error, &pending->frames, &pending->delaysMs,
291
+ &pending->loopCount);
114
292
  }
115
293
 
116
294
  dispatch_async_f(dispatch_get_main_queue(), pending, [](void *data) {
117
295
  std::unique_ptr<Pending> done{static_cast<Pending *>(data)};
118
296
  if (done->image != nullptr) {
297
+ // The frames first, so that `remember` can account for all of them in
298
+ // one insert rather than growing the cache's idea of this URI twice.
299
+ if (done->frames.size() > 1) {
300
+ done->loader->rememberAnimation(done->uri, done->frames, done->delaysMs,
301
+ done->loopCount);
302
+ }
119
303
  // The cache takes the reference the decode produced; the callback
120
304
  // borrows it, and a view that keeps the image retains its own.
121
305
  done->loader->remember(done->uri, done->image);
@@ -144,6 +328,40 @@ void AppKitImageLoader::loadImage(const std::string &uri,
144
328
  });
145
329
  }
146
330
 
331
+ // Keeps the frames as NSArrays, built once here. The arrays are what the view
332
+ // is handed, and handing back the same arrays each time is what lets it tell a
333
+ // re-mount from a new animation; see RnAppKitView's setRnImageFrames.
334
+ void AppKitImageLoader::rememberAnimation(const std::string &uri,
335
+ const std::vector<CGImageRef> &frames,
336
+ const std::vector<unsigned> &delaysMs,
337
+ unsigned loopCount) {
338
+ if (const auto found = animations_.find(uri); found != animations_.end()) {
339
+ // A second decode of the same URI, which a cache miss on one and a hit on
340
+ // the other can produce. The frames already held are the ones views are
341
+ // drawing, so these are dropped rather than swapped in.
342
+ for (CGImageRef frame : frames) {
343
+ CGImageRelease(frame);
344
+ }
345
+ return;
346
+ }
347
+
348
+ NSMutableArray *images = [NSMutableArray arrayWithCapacity:frames.size()];
349
+ NSMutableArray<NSNumber *> *delays = [NSMutableArray arrayWithCapacity:delaysMs.size()];
350
+ for (size_t index = 0; index < frames.size(); index++) {
351
+ // The array's own retain, and the decode's reference is what this class
352
+ // releases on eviction.
353
+ [images addObject:(__bridge id)frames[index]];
354
+ [delays addObject:@(delaysMs[index])];
355
+ }
356
+
357
+ animations_[uri] = Animation{images, delays, loopCount};
358
+ }
359
+
360
+ const AppKitImageLoader::Animation *AppKitImageLoader::animation(const std::string &uri) {
361
+ const auto found = animations_.find(uri);
362
+ return found == animations_.end() ? nullptr : &found->second;
363
+ }
364
+
147
365
  facebook::react::IImageLoader::CacheStatus AppKitImageLoader::getCacheStatus(
148
366
  const std::string &uri) {
149
367
  // Memory or nothing: there is no disk cache here, and saying `Disk` would be
@@ -22,6 +22,9 @@
22
22
  // would deliver it to the wrong object and do nothing.
23
23
 
24
24
  #include "MenuModel.h"
25
+ #include "TextSelection.h"
26
+
27
+ #import "AppKitAppWindow.h"
25
28
 
26
29
  #import <Cocoa/Cocoa.h>
27
30
 
@@ -113,6 +116,14 @@ SEL selectorForRole(const std::string &role) {
113
116
  return nullptr;
114
117
  }
115
118
 
119
+ // --- Roles, for a popup menu too ----------------------------------------------
120
+ //
121
+ // `showMenu` in PlatformServicesAppKit.mm performs a role itself rather than
122
+ // handing the item a `nil` target the way the bar does. The bar can let AppKit
123
+ // route the action, because nothing is waiting on an answer; a popup resolves a
124
+ // promise with the index chosen, so it has to stay in the conversation. It calls
125
+ // these two, declared in core/PlatformServices.h.
126
+
116
127
  // The shortcut a role brings with it. macOS has a standard one for each, and an
117
128
  // app that spelled its own would be fighting the platform.
118
129
  NSString *keyEquivalentForRole(const std::string &role, NSEventModifierFlags *mask) {
@@ -328,6 +339,53 @@ MenuModel defaultMenu() {
328
339
 
329
340
  } // namespace
330
341
 
342
+ // Every role in kMenuRoles, which is the whole list: each one is an NSResponder
343
+ // or NSApplication action that macOS has implemented since before this project.
344
+ bool menuRoleSupported(const std::string &role) {
345
+ return selectorForRole(role) != nullptr;
346
+ }
347
+
348
+ void performMenuRole(const std::string &role) {
349
+ // A paragraph's selection first, when there is one: `Copy` over selected text
350
+ // means that text, and the responder chain below is what the role meant when
351
+ // only a <TextInput> could hold a selection. At most one of the two exists --
352
+ // pressing in a field clears a paragraph's selection on the way past -- so
353
+ // the order settles a case that does not arise rather than choosing between
354
+ // two live selections.
355
+ //
356
+ // This is also what makes Cmd+C work for a selected paragraph: the
357
+ // application menu's Copy item carries the key equivalent and performs this
358
+ // role, so there is no second key path to write. See core/TextSelection.h.
359
+ if (role == "copy" && copySelectedText()) {
360
+ return;
361
+ }
362
+
363
+ SEL selector = selectorForRole(role);
364
+ if (selector == nullptr) {
365
+ return;
366
+ }
367
+
368
+ // The app's own window first, and this is not belt and braces: `sendAction:`
369
+ // with `to:nil` walks the responder chain from the **key window's** first
370
+ // responder, and an app that is not the active one has no key window. So a
371
+ // `paste` role reached nothing at all whenever the host was launched behind
372
+ // something else, which is most of the time under the harness. The scenario
373
+ // "a role in a context menu performs it" found it by failing on a developer
374
+ // Mac while every CI shard passed, CI's runner having nothing else on screen.
375
+ //
376
+ // `tryToPerform:with:` walks from the responder it is sent to, which is the
377
+ // same walk with a different starting point: the field that has focus in the
378
+ // window this process owns.
379
+ NSWindow *const target = appWindow();
380
+ if (target != nil && [target.firstResponder tryToPerform:selector with:nil]) {
381
+ return;
382
+ }
383
+
384
+ // And then the usual routing, which is what answers for a role no responder
385
+ // wants: `terminate:` belongs to the application and not to a field.
386
+ [NSApp sendAction:selector to:nil from:nil];
387
+ }
388
+
331
389
  bool applicationMenuSupported() {
332
390
  return true;
333
391
  }
@@ -17,6 +17,8 @@
17
17
  #import "AppKitScrollView.h"
18
18
  #import "AppKitTextInput.h"
19
19
 
20
+ #include "LabelRegistry.h"
21
+ #include "LiveRegions.h"
20
22
  #include "MountingWalk.h"
21
23
  #include "RnAppKitView.h"
22
24
 
@@ -57,6 +59,13 @@ class AppKitMountingManager final : public facebook::react::IMountingManager,
57
59
  // object is in reach, so this passes it on. See core/UIManagerAccess.h.
58
60
  void setUIManager(std::weak_ptr<facebook::react::UIManager> uiManager) noexcept override;
59
61
 
62
+ // React Native hands this a null executor from `destroyReactInstance`, which
63
+ // is the only notice a mounting manager gets that the instance is going
64
+ // away -- a reload destroys one and builds another while this object
65
+ // survives. See MountingWalk::invalidatePendingMounts.
66
+ void setSchedulerTaskExecutor(
67
+ facebook::react::SchedulerTaskExecutor &&schedulerTaskExecutor) noexcept override;
68
+
60
69
  // --- Host-facing ----------------------------------------------------------
61
70
  //
62
71
  // createSurfaceRoot, destroySurfaceRoot, getSurfaceRoot, viewForTag and
@@ -114,6 +123,17 @@ class AppKitMountingManager final : public facebook::react::IMountingManager,
114
123
  void applyTextInput(RnAppKitView *view, const facebook::react::ShadowView &shadowView);
115
124
  void applyLayoutMetrics(RnAppKitView *view, const facebook::react::ShadowView &shadowView);
116
125
 
126
+ // The labelled-by relations whose resolution changed in the transaction that
127
+ // just mounted. Once per transaction rather than per view, because a view can
128
+ // name a label that mounts after it.
129
+ void applyLabelRelations();
130
+
131
+ // Announces any live region whose text changed in the transaction that just
132
+ // mounted. Once per transaction, and after it, because the text to announce is
133
+ // what the region says *now*: a <Text> inside it may have been updated by a
134
+ // later mutation in the same batch.
135
+ void announceLiveRegions();
136
+
117
137
  AppKitScrollViewManager scrollViews_;
118
138
  AppKitTextInputManager textInputs_;
119
139
  // Shared rather than held by value: React Native's `ImageLoaderModule` takes
@@ -130,6 +150,20 @@ class AppKitMountingManager final : public facebook::react::IMountingManager,
130
150
  // The source each <Image> is currently showing, so that a mutation which
131
151
  // changed only layout does not restart the load.
132
152
  std::unordered_map<facebook::react::Tag, std::string> imageUris_;
153
+
154
+ // Which view each `nativeID` names, and which views are waiting to be labelled
155
+ // by one. Shared with the GTK host; see core/LabelRegistry.h for why the
156
+ // resolution cannot happen as the props arrive.
157
+ basalt::LabelRegistry labels_;
158
+
159
+ // Which views are live regions and what each last said, so a changed status
160
+ // message can be announced. Shared with the GTK host; see core/LiveRegions.h.
161
+ basalt::LiveRegionRegistry liveRegions_;
162
+
163
+ // Each live region's `accessibilityLabel`, which stands in for its text when
164
+ // the app set one. Kept beside the registry so both hosts decide it the same
165
+ // way and in the same order.
166
+ std::unordered_map<facebook::react::Tag, std::string> liveRegionLabels_;
133
167
  };
134
168
 
135
169
  } // namespace basalt