basalt-appkit 0.0.0-rc5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/native/CMakeLists.txt +16 -2
  2. package/native/appkit/AppKitAppWindow.h +24 -0
  3. package/native/appkit/AppKitImageLoader.h +48 -1
  4. package/native/appkit/AppKitImageLoader.mm +228 -10
  5. package/native/appkit/AppKitMenuBar.mm +58 -0
  6. package/native/appkit/AppKitMountingManager.h +34 -0
  7. package/native/appkit/AppKitMountingManager.mm +582 -12
  8. package/native/appkit/AppKitScrollView.h +48 -0
  9. package/native/appkit/AppKitScrollView.mm +210 -0
  10. package/native/appkit/AppKitSkiaPeer.h +15 -0
  11. package/native/appkit/AppKitTextInput.h +38 -1
  12. package/native/appkit/AppKitTextInput.mm +235 -8
  13. package/native/appkit/AppKitTextPeer.h +60 -0
  14. package/native/appkit/AppKitTextPeer.mm +307 -0
  15. package/native/appkit/AppKitTouchDispatcher.h +56 -0
  16. package/native/appkit/AppKitTouchDispatcher.mm +140 -0
  17. package/native/appkit/AppKitWindowControl.mm +38 -3
  18. package/native/appkit/ComponentRegistryAppKit.mm +28 -3
  19. package/native/appkit/CoreTextLayout.h +64 -2
  20. package/native/appkit/CoreTextLayout.mm +431 -71
  21. package/native/appkit/CoreTextLayoutManager.mm +57 -6
  22. package/native/appkit/FontRegistryCoreText.mm +6 -5
  23. package/native/appkit/PlatformServicesAppKit.mm +101 -20
  24. package/native/appkit/RnAppKitView.h +492 -0
  25. package/native/appkit/RnAppKitView.mm +2266 -23
  26. package/native/appkit/RnTextLayout.h +106 -0
  27. package/native/appkit/RnTextLayout.mm +321 -2
  28. package/native/appkit/main_appkit.mm +174 -3
  29. package/native/tests/test_appkit_accessibility.mm +289 -0
  30. package/native/tests/test_appkit_borders.mm +975 -0
  31. package/native/tests/test_appkit_fonts.mm +203 -0
  32. package/native/tests/test_appkit_image.mm +392 -17
  33. package/native/tests/test_appkit_input.mm +209 -0
  34. package/native/tests/test_appkit_mounting.mm +360 -0
  35. package/native/tests/test_appkit_scrollbar.mm +125 -0
  36. package/native/tests/test_appkit_scrollview.mm +371 -0
  37. package/native/tests/test_appkit_selection.mm +349 -0
  38. package/native/tests/test_appkit_services.mm +51 -1
  39. package/native/tests/test_appkit_text.mm +1628 -0
  40. package/native/tests/test_appkit_textinput.mm +819 -0
  41. package/native/tests/test_appkit_titlebar.mm +43 -0
  42. package/native/tests/test_appkit_view.mm +609 -0
  43. package/package.json +4 -3
@@ -0,0 +1,203 @@
1
+ // Fonts loaded while the app is running, which is the seam `expo-font` arrives
2
+ // through.
3
+ //
4
+ // The half worth testing is not that Core Text can register a file. It is that
5
+ // the name the app chose keeps working afterwards: a font file carries its own
6
+ // family name inside it, usually not the one the app passed to `loadAsync`, so
7
+ // `fontFamily: 'TheAppsName'` only renders in that font if the registry
8
+ // remembers the mapping and the layout asks it. That mapping is the whole of
9
+ // core/FontRegistry.h, and until now nothing on this host asked it anything.
10
+ //
11
+ // The file is a real one, found through Core Text rather than added to this
12
+ // repository as a fixture. Registering a font the system already has, under a
13
+ // second name of the app's choosing, exercises exactly the mapping this seam
14
+ // exists for and does it on any Mac.
15
+ //
16
+ // A face the system does *not* have is the case `expo-font` really exists for,
17
+ // and it gets its own test at the bottom of this file: tests/RenamedFontFile.h
18
+ // renames the family inside a copy of that same file, which is the one change
19
+ // that makes a font new to the machine.
20
+
21
+ #include "TestHarness.h"
22
+
23
+ #import "CoreTextLayout.h"
24
+ #import "RnTextLayout.h"
25
+
26
+ #include "FontRegistry.h"
27
+ #include "RenamedFontFile.h"
28
+
29
+ #import <AppKit/AppKit.h>
30
+ #import <CoreText/CoreText.h>
31
+
32
+ #include <react/renderer/attributedstring/AttributedString.h>
33
+ #include <react/renderer/textlayoutmanager/TextLayoutManager.h>
34
+ #include <react/utils/ContextContainer.h>
35
+
36
+ #include <cmath>
37
+ #include <filesystem>
38
+ #include <string>
39
+
40
+ namespace {
41
+
42
+ // Monospaced on purpose. The assertions below compare against the proportional
43
+ // system font, and two proportional faces can measure the same string to the
44
+ // same width, which would make a passing test mean nothing.
45
+ NSString *const kKnownFamily = @"Courier New";
46
+
47
+ // Where the system keeps that family's file. `kCTFontURLAttribute` is the only
48
+ // way to ask: a descriptor built from a family name answers with the name, and
49
+ // `registerFont` needs a path.
50
+ std::string pathOfKnownFamily() {
51
+ NSFontDescriptor *wanted = [NSFontDescriptor fontDescriptorWithFontAttributes:@{
52
+ NSFontFamilyAttribute : kKnownFamily,
53
+ }];
54
+ NSArray<NSFontDescriptor *> *matches = [wanted
55
+ matchingFontDescriptorsWithMandatoryKeys:[NSSet setWithObject:NSFontFamilyAttribute]];
56
+ for (NSFontDescriptor *descriptor in matches) {
57
+ NSURL *url = (__bridge_transfer NSURL *)CTFontDescriptorCopyAttribute(
58
+ (__bridge CTFontDescriptorRef)descriptor, kCTFontURLAttribute);
59
+ if (url.isFileURL && url.path.length > 0) {
60
+ return url.path.UTF8String;
61
+ }
62
+ }
63
+ return {};
64
+ }
65
+
66
+ facebook::react::AttributedString paragraphIn(const std::string &family, const char *text) {
67
+ facebook::react::TextAttributes attributes;
68
+ attributes.fontSize = 24.0F;
69
+ attributes.fontFamily = family;
70
+
71
+ facebook::react::AttributedString::Fragment fragment;
72
+ fragment.string = text;
73
+ fragment.textAttributes = attributes;
74
+
75
+ facebook::react::AttributedString string;
76
+ string.appendFragment(std::move(fragment));
77
+ return string;
78
+ }
79
+
80
+ // The font Core Text was actually handed for a paragraph asking for `family`,
81
+ // which is the question the registry exists to answer. Through buildTextLayout
82
+ // rather than through fontFor directly, because the path from a prop to a font
83
+ // is what an app's `fontFamily` travels.
84
+ NSString *familyLaidOutFor(const std::string &family) {
85
+ NSAttributedString *built =
86
+ basalt::buildTextLayout(paragraphIn(family, "Handgloves"),
87
+ facebook::react::ParagraphAttributes{})
88
+ .attributedString;
89
+ NSFont *font = [built attribute:NSFontAttributeName atIndex:0 effectiveRange:nullptr];
90
+ return font.familyName;
91
+ }
92
+
93
+ } // namespace
94
+
95
+ TEST(appkit_a_font_loaded_at_runtime_resolves_to_the_family_in_the_file) {
96
+ @autoreleasepool {
97
+ const std::string path = pathOfKnownFamily();
98
+ EXPECT(!path.empty());
99
+
100
+ const std::string alias = "BasaltResolvedAlias";
101
+ EXPECT(basalt::registerFont(alias, path));
102
+ EXPECT(basalt::isFontRegistered(alias));
103
+
104
+ // The point of the registry: what the app calls it maps to what the file
105
+ // calls itself, which is the name Core Text indexes by.
106
+ EXPECT_EQ(basalt::resolveFontFamily(alias), std::string(kKnownFamily.UTF8String));
107
+
108
+ // And a name nobody registered comes back unchanged, so a system family
109
+ // reaches Core Text as itself rather than being rewritten.
110
+ EXPECT_EQ(basalt::resolveFontFamily("Helvetica"), std::string("Helvetica"));
111
+ EXPECT(!basalt::isFontRegistered("BasaltNeverLoaded"));
112
+ }
113
+ }
114
+
115
+ TEST(appkit_text_asking_for_a_runtime_family_is_laid_out_in_it) {
116
+ @autoreleasepool {
117
+ const std::string path = pathOfKnownFamily();
118
+ EXPECT(!path.empty());
119
+
120
+ const std::string alias = "BasaltLaidOutAlias";
121
+ EXPECT(basalt::registerFont(alias, path));
122
+
123
+ EXPECT_EQ(std::string(familyLaidOutFor(alias).UTF8String),
124
+ std::string(kKnownFamily.UTF8String));
125
+
126
+ // The negative control, and the reason the assertion above is not vacuous:
127
+ // a family the system does not have falls back to the system font, so a
128
+ // registry that forgot the mapping would land here instead.
129
+ NSString *fallback = familyLaidOutFor("BasaltNeverLoaded");
130
+ EXPECT(![fallback isEqualToString:kKnownFamily]);
131
+ EXPECT_EQ(std::string(fallback.UTF8String),
132
+ std::string([NSFont systemFontOfSize:24].familyName.UTF8String));
133
+ }
134
+ }
135
+
136
+ TEST(appkit_a_font_registered_after_a_measurement_invalidates_it) {
137
+ @autoreleasepool {
138
+ const std::string path = pathOfKnownFamily();
139
+ EXPECT(!path.empty());
140
+
141
+ // This is how every loader works, `useFonts` included: the app renders
142
+ // first, in whatever the fallback is, and the font arrives afterwards. The
143
+ // measurement from before has to stop counting, or the paragraph keeps the
144
+ // fallback's size for the rest of the session and the font appears to have
145
+ // done nothing.
146
+ const std::string alias = "BasaltCacheAlias";
147
+ EXPECT(!basalt::isFontRegistered(alias));
148
+
149
+ const facebook::react::TextLayoutManager manager(
150
+ std::make_shared<const facebook::react::ContextContainer>());
151
+ const auto widthNow = [&] {
152
+ return manager
153
+ .measure(facebook::react::AttributedStringBox{paragraphIn(alias, "Handgloves")},
154
+ facebook::react::ParagraphAttributes{},
155
+ facebook::react::TextLayoutContext{},
156
+ facebook::react::LayoutConstraints{})
157
+ .size.width;
158
+ };
159
+
160
+ const float fallbackWidth = widthNow();
161
+ EXPECT(fallbackWidth > 0.0F);
162
+ // Measured again with nothing changed, to show the cache is in the way: if
163
+ // the two differed here, the test below would prove nothing.
164
+ EXPECT(std::fabs(widthNow() - fallbackWidth) < 0.01F);
165
+
166
+ const unsigned long before = basalt::fontGeneration();
167
+ EXPECT(basalt::registerFont(alias, path));
168
+ EXPECT(basalt::fontGeneration() != before);
169
+
170
+ EXPECT(std::fabs(widthNow() - fallbackWidth) > 0.5F);
171
+ }
172
+ }
173
+
174
+ TEST(appkit_a_font_the_system_does_not_have_becomes_usable) {
175
+ @autoreleasepool {
176
+ const std::string path = pathOfKnownFamily();
177
+ EXPECT(!path.empty());
178
+
179
+ const std::filesystem::path copy =
180
+ std::filesystem::temp_directory_path() / "basalt-appkit-renamed-font.ttf";
181
+ const basalt::test::RenamedFont renamed = basalt::test::renameFontFile(
182
+ path, kKnownFamily.UTF8String, copy.string());
183
+ EXPECT(!renamed.path.empty());
184
+
185
+ // Before: nobody has this family, so a paragraph asking for it by its own
186
+ // name gets the system font. This is the assertion that makes the rest of
187
+ // the file more than a lookup table test -- everything above registers a
188
+ // face Core Text already had.
189
+ EXPECT_EQ(std::string(familyLaidOutFor(renamed.family).UTF8String),
190
+ std::string([NSFont systemFontOfSize:24].familyName.UTF8String));
191
+
192
+ const std::string alias = "BasaltBundledAlias";
193
+ EXPECT(basalt::registerFont(alias, renamed.path));
194
+ EXPECT_EQ(basalt::resolveFontFamily(alias), renamed.family);
195
+
196
+ // After: the app's name reaches it, and so does the family inside the file,
197
+ // which is what `CTFontManagerRegisterFontsForURL` was called for.
198
+ EXPECT_EQ(std::string(familyLaidOutFor(alias).UTF8String), renamed.family);
199
+ EXPECT_EQ(std::string(familyLaidOutFor(renamed.family).UTF8String), renamed.family);
200
+
201
+ std::filesystem::remove(copy);
202
+ }
203
+ }
@@ -18,7 +18,9 @@
18
18
 
19
19
  #import "RnAppKitView.h"
20
20
 
21
+ #include <cmath>
21
22
  #include <sstream>
23
+ #include <vector>
22
24
 
23
25
  namespace {
24
26
 
@@ -36,18 +38,25 @@ CGImageRef makeImage(size_t width, size_t height) {
36
38
  return image;
37
39
  }
38
40
 
39
- struct Ink {
40
- int left{-1};
41
- int top{-1};
42
- int right{-1};
43
- int bottom{-1};
44
- int width() const { return right < 0 ? 0 : right - left + 1; }
45
- int height() const { return bottom < 0 ? 0 : bottom - top + 1; }
46
- };
41
+ // Left half black, right half white, opaque. A step edge, which is the one
42
+ // picture a blur is measurable against: how far the ramp spreads is the radius.
43
+ CGImageRef makeSplitImage(size_t width, size_t height) {
44
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
45
+ CGContextRef context = CGBitmapContextCreate(nullptr, width, height, 8, 0, space,
46
+ kCGImageAlphaPremultipliedLast);
47
+ CGColorSpaceRelease(space);
48
+ CGContextSetRGBFillColor(context, 1, 1, 1, 1);
49
+ CGContextFillRect(context, CGRectMake(0, 0, (CGFloat)width, (CGFloat)height));
50
+ CGContextSetRGBFillColor(context, 0, 0, 0, 1);
51
+ CGContextFillRect(context, CGRectMake(0, 0, (CGFloat)width / 2, (CGFloat)height));
52
+ CGImageRef image = CGBitmapContextCreateImage(context);
53
+ CGContextRelease(context);
54
+ return image;
55
+ }
47
56
 
48
- // Draws the view into a white bitmap and returns the bounding box of anything
49
- // darker than white.
50
- Ink inkOf(RnAppKitView *view, CGSize size) {
57
+ // The red channel of the view drawn onto white, row-major. Red rather than
58
+ // luminance because everything here is grey or black: the three channels agree.
59
+ std::vector<unsigned char> drawnPixels(RnAppKitView *view, CGSize size) {
51
60
  CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
52
61
  CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width, (size_t)size.height,
53
62
  8, 0, space, kCGImageAlphaPremultipliedLast);
@@ -56,9 +65,7 @@ Ink inkOf(RnAppKitView *view, CGSize size) {
56
65
  CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
57
66
  // A CGBitmapContext counts y from the bottom and this view counts it from the
58
67
  // top, and `graphicsContextWithCGContext:flipped:` only *declares* which way
59
- // up a context is -- it applies no transform. Every case here happens to be
60
- // vertically symmetric, so this changes no result; without it the next case
61
- // that is not symmetric would be read upside down.
68
+ // up a context is -- it applies no transform.
62
69
  CGContextTranslateCTM(context, 0, size.height);
63
70
  CGContextScaleCTM(context, 1, -1);
64
71
 
@@ -68,12 +75,60 @@ Ink inkOf(RnAppKitView *view, CGSize size) {
68
75
  [view drawRect:NSMakeRect(0, 0, size.width, size.height)];
69
76
  NSGraphicsContext.currentContext = previous;
70
77
 
71
- auto *pixels = static_cast<unsigned char *>(CGBitmapContextGetData(context));
78
+ auto *bytes = static_cast<unsigned char *>(CGBitmapContextGetData(context));
72
79
  const size_t stride = CGBitmapContextGetBytesPerRow(context);
80
+ std::vector<unsigned char> red((size_t)size.width * (size_t)size.height);
81
+ for (size_t y = 0; y < (size_t)size.height; y++) {
82
+ for (size_t x = 0; x < (size_t)size.width; x++) {
83
+ red[y * (size_t)size.width + x] = bytes[y * stride + x * 4];
84
+ }
85
+ }
86
+ CGContextRelease(context);
87
+ return red;
88
+ }
89
+
90
+ // How wide the black-to-white ramp is along the middle row, from 10% to 90%.
91
+ //
92
+ // For a gaussian of standard deviation sigma that width is 2.563 * sigma, which
93
+ // is how a measured number here becomes a statement about the radius: an
94
+ // unblurred edge is one or two pixels and nothing else needs to be known about
95
+ // the filter.
96
+ double rampWidth(RnAppKitView *view, CGSize size) {
97
+ const std::vector<unsigned char> pixels = drawnPixels(view, size);
98
+ const int width = (int)size.width;
99
+ const int row = (int)size.height / 2;
100
+ int low = -1;
101
+ int high = -1;
102
+ for (int x = 0; x < width; x++) {
103
+ const unsigned char value = pixels[(size_t)row * (size_t)width + (size_t)x];
104
+ if (low < 0 && value > 25) {
105
+ low = x;
106
+ }
107
+ if (value > 229) {
108
+ high = x;
109
+ break;
110
+ }
111
+ }
112
+ return (high > low && low >= 0) ? (double)(high - low) : 0.0;
113
+ }
114
+
115
+ struct Ink {
116
+ int left{-1};
117
+ int top{-1};
118
+ int right{-1};
119
+ int bottom{-1};
120
+ int width() const { return right < 0 ? 0 : right - left + 1; }
121
+ int height() const { return bottom < 0 ? 0 : bottom - top + 1; }
122
+ };
123
+
124
+ // Draws the view into a white bitmap and returns the bounding box of anything
125
+ // darker than white.
126
+ Ink inkOf(RnAppKitView *view, CGSize size) {
127
+ const std::vector<unsigned char> pixels = drawnPixels(view, size);
73
128
  Ink ink;
74
129
  for (int y = 0; y < (int)size.height; y++) {
75
130
  for (int x = 0; x < (int)size.width; x++) {
76
- if (pixels[(size_t)y * stride + (size_t)x * 4] < 128) {
131
+ if (pixels[(size_t)y * (size_t)size.width + (size_t)x] < 128) {
77
132
  if (ink.left < 0 || x < ink.left) ink.left = x;
78
133
  if (ink.right < 0 || x > ink.right) ink.right = x;
79
134
  if (ink.top < 0 || y < ink.top) ink.top = y;
@@ -81,7 +136,6 @@ Ink inkOf(RnAppKitView *view, CGSize size) {
81
136
  }
82
137
  }
83
138
  }
84
- CGContextRelease(context);
85
139
  return ink;
86
140
  }
87
141
 
@@ -235,3 +289,324 @@ TEST(image_repeat_keeps_the_tile_at_its_natural_size) {
235
289
  CGImageRelease(image);
236
290
  }
237
291
  }
292
+
293
+ // `blurRadius` blurs the image, and by the right amount.
294
+ //
295
+ // The amount is the whole point of the test. A blur that is applied in the
296
+ // source image's pixels rather than in the view's coordinates still looks
297
+ // blurred, and still passes a test that only asks whether the edge is soft, but
298
+ // it is a different picture from the GTK side's for every image that is not
299
+ // drawn at its natural size. Here a 40-pixel-wide image is stretched to 200, so
300
+ // a blur in the wrong space would be five times too narrow.
301
+ TEST(image_blur_radius_softens_the_edge_in_view_coordinates) {
302
+ @autoreleasepool {
303
+ CGImageRef image = makeSplitImage(40, 8);
304
+ const CGSize size = CGSizeMake(200, 40);
305
+
306
+ RnAppKitView *sharp = imageView(image, RnAppKitImageFitStretch, size);
307
+ const double unblurred = rampWidth(sharp, size);
308
+ // Four pixels, which is what "no blur" looks like here: one source pixel is
309
+ // five destination pixels wide after the stretch, and the interpolation
310
+ // between them is the whole ramp.
311
+ EXPECT(unblurred <= 5);
312
+
313
+ RnAppKitView *view = imageView(image, RnAppKitImageFitStretch, size);
314
+ [view setRnImageBlur:20];
315
+ // sigma is half the radius and a 10-90 ramp is 2.563 sigma, so a radius of
316
+ // 20 is a ramp near 26 pixels, which is what it measures. The tolerance is
317
+ // wide enough for Core Image to round its kernel differently on a future
318
+ // macOS and narrow enough to fail on a blur applied in the source's own
319
+ // pixels: that one is magnified by the stretch along with the picture and
320
+ // measures 130, which is checked by sabotage rather than guessed at.
321
+ EXPECT_NEAR((int)rampWidth(view, size), 26, 5);
322
+
323
+ CGImageRelease(image);
324
+ }
325
+ }
326
+
327
+ // Zero is no blur, and so is a negative radius: an app can send either and
328
+ // neither is a crash or a filter with a nonsense kernel.
329
+ TEST(image_blur_radius_of_zero_or_less_draws_the_image_itself) {
330
+ @autoreleasepool {
331
+ CGImageRef image = makeSplitImage(40, 8);
332
+ const CGSize size = CGSizeMake(200, 40);
333
+
334
+ RnAppKitView *view = imageView(image, RnAppKitImageFitStretch, size);
335
+ [view setRnImageBlur:20];
336
+ [view setRnImageBlur:0];
337
+ EXPECT((int)rampWidth(view, size) <= 5);
338
+
339
+ [view setRnImageBlur:-20];
340
+ EXPECT((int)rampWidth(view, size) <= 5);
341
+
342
+ CGImageRelease(image);
343
+ }
344
+ }
345
+
346
+ // A tinted image is a silhouette filled with one colour, and a blurred tinted
347
+ // image is that silhouette with soft edges. The mask is what gets blurred here,
348
+ // which is the same picture as blurring the fill because the colour is
349
+ // constant, and it is what the GTK side does by pushing its blur around the
350
+ // tint rather than inside it.
351
+ TEST(image_blur_radius_applies_to_a_tinted_image_too) {
352
+ @autoreleasepool {
353
+ // Opaque black on the left, transparent on the right: with a tint it is the
354
+ // alpha that decides where the colour lands.
355
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
356
+ CGContextRef bitmap = CGBitmapContextCreate(nullptr, 40, 8, 8, 0, space,
357
+ kCGImageAlphaPremultipliedLast);
358
+ CGColorSpaceRelease(space);
359
+ CGContextSetRGBFillColor(bitmap, 0, 0, 0, 1);
360
+ CGContextFillRect(bitmap, CGRectMake(0, 0, 20, 8));
361
+ CGImageRef image = CGBitmapContextCreateImage(bitmap);
362
+ CGContextRelease(bitmap);
363
+
364
+ const CGSize size = CGSizeMake(200, 40);
365
+ RnAppKitView *view = imageView(image, RnAppKitImageFitStretch, size);
366
+ [view setRnImageTint:[NSColor colorWithSRGBRed:0 green:0 blue:0 alpha:1]];
367
+ EXPECT((int)rampWidth(view, size) <= 5);
368
+
369
+ [view setRnImageBlur:20];
370
+ EXPECT_NEAR((int)rampWidth(view, size), 26, 5);
371
+
372
+ CGImageRelease(image);
373
+ }
374
+ }
375
+
376
+ // Clearing the image must not leave the blurred copy of it behind to be drawn
377
+ // against the next one, and a blur with no image at all is nothing rather than a
378
+ // crash.
379
+ TEST(image_blur_survives_the_image_being_replaced) {
380
+ @autoreleasepool {
381
+ const CGSize size = CGSizeMake(200, 40);
382
+ RnAppKitView *view = [RnAppKitView viewWithTag:1];
383
+ [view setRnFrameX:0 y:0 width:size.width height:size.height];
384
+ [view setRnImageBlur:20];
385
+ EXPECT_EQ(inkOf(view, size).width(), 0);
386
+
387
+ CGImageRef image = makeSplitImage(40, 8);
388
+ [view setRnImage:image fit:RnAppKitImageFitStretch];
389
+ EXPECT_NEAR((int)rampWidth(view, size), 26, 5);
390
+
391
+ [view setRnImage:nullptr fit:RnAppKitImageFitStretch];
392
+ EXPECT_EQ(inkOf(view, size).width(), 0);
393
+ CGImageRelease(image);
394
+ }
395
+ }
396
+
397
+ // And the radius is in the tree dump, spelled as GTK spells it.
398
+ //
399
+ // Which is what makes the wiring testable rather than only the drawing: the
400
+ // prop is read in a branch of AppKitMountingManager that knows ImageProps, and
401
+ // every test above sets the blur on the view by hand, so all of them pass while
402
+ // the mounting manager drops it on the floor. The GTK side did exactly that.
403
+ TEST(image_blur_is_reported_in_the_tree) {
404
+ @autoreleasepool {
405
+ CGImageRef image = makeImage(4, 2);
406
+ RnAppKitView *view = imageView(image, RnAppKitImageFitStretch, CGSizeMake(50, 50));
407
+ CGImageRelease(image);
408
+
409
+ EXPECT(![[view describeTree] containsString:@"blur="]);
410
+ [view setRnImageBlur:8];
411
+ EXPECT([[view describeTree] containsString:@"blur=8"]);
412
+ [view setRnImageBlur:0];
413
+ EXPECT(![[view describeTree] containsString:@"blur="]);
414
+ }
415
+ }
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Animated images.
419
+ //
420
+ // The frames come out of a real GIF rather than from bitmaps built here: the
421
+ // delays and the loop count are in the file's own bytes, and reading them is
422
+ // half of what this platform had to learn. The file is four pixels square,
423
+ // ninety-five bytes, and inline for the same reason the still fixtures above
424
+ // are -- it needs no asset pipeline and no fixture to keep in step.
425
+ //
426
+ // Written with ImageMagick:
427
+ //
428
+ // magick -loop 0 -delay 8 -size 4x4 xc:'#ff0000' \
429
+ // -delay 4 -size 4x4 xc:'#0000ff' anim.gif
430
+ //
431
+ // so frame one is red for 80ms and frame two is blue for 40ms, forever. The
432
+ // delays differ on purpose: equal ones hide an engine that uses the first
433
+ // frame's delay for all of them.
434
+ // ---------------------------------------------------------------------------
435
+ namespace {
436
+
437
+ const char *const kAnimatedGifBase64 =
438
+ "R0lGODlhBAAEAPAAAP8AAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQACAAAACwAAAAABAAEAAAC"
439
+ "BISPCQUAIfkEAAQAAAAsAAAAAAQABACAAAD/AAAAAgSEjwkFADs=";
440
+
441
+ CGImageSourceRef animatedGifSource() {
442
+ NSData *bytes = [[NSData alloc] initWithBase64EncodedString:@(kAnimatedGifBase64) options:0];
443
+ return CGImageSourceCreateWithData((__bridge CFDataRef)bytes, nullptr);
444
+ }
445
+
446
+ // The frames of that GIF, as the view takes them.
447
+ NSArray *animatedGifFrames() {
448
+ CGImageSourceRef source = animatedGifSource();
449
+ NSMutableArray *frames = [NSMutableArray array];
450
+ for (size_t index = 0; index < CGImageSourceGetCount(source); index++) {
451
+ CGImageRef frame = CGImageSourceCreateImageAtIndex(source, index, nullptr);
452
+ if (frame != nullptr) {
453
+ [frames addObject:(__bridge_transfer id)frame];
454
+ }
455
+ }
456
+ CFRelease(source);
457
+ return frames;
458
+ }
459
+
460
+ // The colour at the centre of the view, drawn onto white. All three channels,
461
+ // unlike `drawnPixels` above: red and blue differ in two of them and agree in
462
+ // the green, so one channel cannot tell these frames apart.
463
+ struct Rgb {
464
+ unsigned char red{0};
465
+ unsigned char green{0};
466
+ unsigned char blue{0};
467
+ };
468
+
469
+ Rgb centreColour(RnAppKitView *view, CGSize size) {
470
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
471
+ CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width, (size_t)size.height,
472
+ 8, 0, space, kCGImageAlphaPremultipliedLast);
473
+ CGColorSpaceRelease(space);
474
+ CGContextSetRGBFillColor(context, 1, 1, 1, 1);
475
+ CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
476
+ CGContextTranslateCTM(context, 0, size.height);
477
+ CGContextScaleCTM(context, 1, -1);
478
+
479
+ NSGraphicsContext *previous = NSGraphicsContext.currentContext;
480
+ NSGraphicsContext.currentContext = [NSGraphicsContext graphicsContextWithCGContext:context
481
+ flipped:YES];
482
+ [view drawRect:NSMakeRect(0, 0, size.width, size.height)];
483
+ NSGraphicsContext.currentContext = previous;
484
+
485
+ auto *bytes = static_cast<unsigned char *>(CGBitmapContextGetData(context));
486
+ const size_t stride = CGBitmapContextGetBytesPerRow(context);
487
+ const size_t row = (size_t)size.height / 2;
488
+ const size_t column = (size_t)size.width / 2;
489
+ const Rgb colour{bytes[row * stride + column * 4], bytes[row * stride + column * 4 + 1],
490
+ bytes[row * stride + column * 4 + 2]};
491
+ CGContextRelease(context);
492
+ return colour;
493
+ }
494
+
495
+ RnAppKitView *animatedView(NSArray *frames, NSArray<NSNumber *> *delays, NSUInteger loops) {
496
+ RnAppKitView *view = [RnAppKitView viewWithTag:1];
497
+ [view setRnFrameX:0 y:0 width:20 height:20];
498
+ [view setRnImage:(__bridge CGImageRef)frames.firstObject fit:RnAppKitImageFitStretch];
499
+ [view setRnImageFrames:frames delaysMs:delays loopCount:loops];
500
+ return view;
501
+ }
502
+
503
+ } // namespace
504
+
505
+ TEST(animated_image_the_frame_follows_the_time_it_is_advanced_by) {
506
+ @autoreleasepool {
507
+ NSArray *frames = animatedGifFrames();
508
+ EXPECT_EQ((int)frames.count, 2);
509
+
510
+ RnAppKitView *view = animatedView(frames, @[@80, @40], 0);
511
+ const CGSize size = CGSizeMake(20, 20);
512
+
513
+ // Frame one, red, which is also what a still <Image> of this file draws.
514
+ EXPECT(centreColour(view, size).red > 200);
515
+ EXPECT(centreColour(view, size).blue < 60);
516
+ EXPECT_EQ((int)view.rnImageFrameIndex, 0);
517
+
518
+ // Still frame one at 79ms: the delay is the file's and not a guess.
519
+ [view rnAdvanceImageAnimationBy:79];
520
+ EXPECT_EQ((int)view.rnImageFrameIndex, 0);
521
+
522
+ // Frame two, blue, one millisecond later.
523
+ [view rnAdvanceImageAnimationBy:1];
524
+ EXPECT_EQ((int)view.rnImageFrameIndex, 1);
525
+ EXPECT(centreColour(view, size).blue > 200);
526
+ EXPECT(centreColour(view, size).red < 60);
527
+
528
+ // And round again after the second frame's own, shorter delay: 40ms, not
529
+ // another 80.
530
+ [view rnAdvanceImageAnimationBy:40];
531
+ EXPECT_EQ((int)view.rnImageFrameIndex, 0);
532
+ EXPECT(centreColour(view, size).red > 200);
533
+ }
534
+ }
535
+
536
+ TEST(animated_image_says_how_long_until_the_next_frame) {
537
+ @autoreleasepool {
538
+ RnAppKitView *view = animatedView(animatedGifFrames(), @[@80, @40], 0);
539
+
540
+ // What the host's timer is set to, which is why this is answered rather
541
+ // than kept private: at the start of frame one, the whole 80ms.
542
+ EXPECT_NEAR([view rnAdvanceImageAnimationBy:0], 80.0, 0.01);
543
+ EXPECT_NEAR([view rnAdvanceImageAnimationBy:30], 50.0, 0.01);
544
+ // Into frame two, which is 40ms long.
545
+ EXPECT_NEAR([view rnAdvanceImageAnimationBy:50], 40.0 - 0.0, 0.01);
546
+ }
547
+ }
548
+
549
+ TEST(animated_image_a_loop_count_stops_on_the_last_frame) {
550
+ @autoreleasepool {
551
+ RnAppKitView *view = animatedView(animatedGifFrames(), @[@80, @40], 1);
552
+ const CGSize size = CGSizeMake(20, 20);
553
+
554
+ // One pass is 120ms. After it the animation stops, on frame two, and says
555
+ // nothing more is due -- which is what stops the timer.
556
+ EXPECT_NEAR([view rnAdvanceImageAnimationBy:120], 0.0, 0.01);
557
+ EXPECT_EQ((int)view.rnImageFrameIndex, 1);
558
+ EXPECT(centreColour(view, size).blue > 200);
559
+
560
+ [view rnAdvanceImageAnimationBy:1000];
561
+ EXPECT_EQ((int)view.rnImageFrameIndex, 1);
562
+ }
563
+ }
564
+
565
+ TEST(animated_image_a_single_frame_is_a_still_image) {
566
+ @autoreleasepool {
567
+ NSArray *frames = animatedGifFrames();
568
+ RnAppKitView *view = animatedView(@[frames.firstObject], @[@80], 0);
569
+
570
+ EXPECT_NEAR([view rnAdvanceImageAnimationBy:500], 0.0, 0.01);
571
+ EXPECT_EQ((int)view.rnImageFrameIndex, 0);
572
+ EXPECT(![[view describeTree] containsString:@"animated=1"]);
573
+ }
574
+ }
575
+
576
+ // A re-mount must not restart the animation, which is the bug this shape of
577
+ // mounting manager invites: every mutation that touches an <Image> re-applies
578
+ // the first frame, a layout-only one included, so a spinner would stutter back
579
+ // to the top on every resize.
580
+ TEST(animated_image_survives_the_props_being_reapplied) {
581
+ @autoreleasepool {
582
+ NSArray *frames = animatedGifFrames();
583
+ RnAppKitView *view = animatedView(frames, @[@80, @40], 0);
584
+
585
+ [view rnAdvanceImageAnimationBy:100];
586
+ EXPECT_EQ((int)view.rnImageFrameIndex, 1);
587
+
588
+ // What the mounting manager does on a mutation: the first frame again, and
589
+ // the same arrays the loader cached.
590
+ [view setRnImage:(__bridge CGImageRef)frames.firstObject fit:RnAppKitImageFitStretch];
591
+ [view setRnImageFrames:frames delaysMs:@[@80, @40] loopCount:0];
592
+ EXPECT_EQ((int)view.rnImageFrameIndex, 1);
593
+ EXPECT(centreColour(view, CGSizeMake(20, 20)).blue > 200);
594
+
595
+ // A different animation does start over, which is the same check from the
596
+ // other side: the frames array is what tells them apart.
597
+ [view setRnImageFrames:animatedGifFrames() delaysMs:@[@80, @40] loopCount:0];
598
+ EXPECT_EQ((int)view.rnImageFrameIndex, 0);
599
+ }
600
+ }
601
+
602
+ TEST(animated_image_is_reported_in_the_tree) {
603
+ @autoreleasepool {
604
+ RnAppKitView *view = animatedView(animatedGifFrames(), @[@80, @40], 0);
605
+ EXPECT([[view describeTree] containsString:@"animated=1"]);
606
+
607
+ // Cleared with the frames, so an <Image> whose source becomes a still one
608
+ // does not keep claiming to move.
609
+ [view setRnImageFrames:nil delaysMs:nil loopCount:0];
610
+ EXPECT(![[view describeTree] containsString:@"animated=1"]);
611
+ }
612
+ }