basalt-appkit 0.0.0-rc5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/cli/runMacos.js +33 -0
  3. package/cli/shared.js +75 -0
  4. package/native/CMakeLists.txt +298 -0
  5. package/native/appkit/AppKitAnimationChoreographer.h +54 -0
  6. package/native/appkit/AppKitAnimationChoreographer.mm +125 -0
  7. package/native/appkit/AppKitDropTarget.h +34 -0
  8. package/native/appkit/AppKitDropTarget.mm +292 -0
  9. package/native/appkit/AppKitFocus.h +120 -0
  10. package/native/appkit/AppKitFocus.mm +293 -0
  11. package/native/appkit/AppKitImageLoader.h +80 -0
  12. package/native/appkit/AppKitImageLoader.mm +156 -0
  13. package/native/appkit/AppKitKeyEvents.h +26 -0
  14. package/native/appkit/AppKitKeyEvents.mm +97 -0
  15. package/native/appkit/AppKitMenuBar.mm +424 -0
  16. package/native/appkit/AppKitMountingManager.h +135 -0
  17. package/native/appkit/AppKitMountingManager.mm +946 -0
  18. package/native/appkit/AppKitRunLoopObserver.h +34 -0
  19. package/native/appkit/AppKitRunLoopObserver.mm +42 -0
  20. package/native/appkit/AppKitScrollView.h +168 -0
  21. package/native/appkit/AppKitScrollView.mm +550 -0
  22. package/native/appkit/AppKitSkiaContext.h +33 -0
  23. package/native/appkit/AppKitSkiaContext.mm +255 -0
  24. package/native/appkit/AppKitSkiaModule.h +53 -0
  25. package/native/appkit/AppKitSkiaModule.mm +81 -0
  26. package/native/appkit/AppKitSkiaPeer.h +37 -0
  27. package/native/appkit/AppKitSkiaPeer.mm +123 -0
  28. package/native/appkit/AppKitSkiaView.h +62 -0
  29. package/native/appkit/AppKitSnapshot.h +21 -0
  30. package/native/appkit/AppKitSnapshot.mm +91 -0
  31. package/native/appkit/AppKitTextInput.h +140 -0
  32. package/native/appkit/AppKitTextInput.mm +588 -0
  33. package/native/appkit/AppKitTextPeer.h +74 -0
  34. package/native/appkit/AppKitTextPeer.mm +353 -0
  35. package/native/appkit/AppKitTitleBar.h +90 -0
  36. package/native/appkit/AppKitTitleBar.mm +186 -0
  37. package/native/appkit/AppKitTouchDispatcher.h +138 -0
  38. package/native/appkit/AppKitTouchDispatcher.mm +514 -0
  39. package/native/appkit/AppKitWindowControl.mm +222 -0
  40. package/native/appkit/AppKitWindowModule.h +62 -0
  41. package/native/appkit/AppKitWindowModule.mm +194 -0
  42. package/native/appkit/ColorSchemeAppKit.mm +71 -0
  43. package/native/appkit/ComponentRegistryAppKit.mm +121 -0
  44. package/native/appkit/CoreTextLayout.h +43 -0
  45. package/native/appkit/CoreTextLayout.mm +252 -0
  46. package/native/appkit/CoreTextLayoutManager.mm +138 -0
  47. package/native/appkit/FontRegistryCoreText.mm +148 -0
  48. package/native/appkit/PlatformServicesAppKit.mm +540 -0
  49. package/native/appkit/RnAppKitView.h +417 -0
  50. package/native/appkit/RnAppKitView.mm +1605 -0
  51. package/native/appkit/RnTextLayout.h +38 -0
  52. package/native/appkit/RnTextLayout.mm +168 -0
  53. package/native/appkit/demo_layout_appkit.mm +93 -0
  54. package/native/appkit/main_appkit.mm +1860 -0
  55. package/native/appkit/mount_harness_appkit.mm +197 -0
  56. package/native/tests/test_appkit_accessibility.mm +170 -0
  57. package/native/tests/test_appkit_image.mm +237 -0
  58. package/native/tests/test_appkit_input.mm +852 -0
  59. package/native/tests/test_appkit_key_events.mm +150 -0
  60. package/native/tests/test_appkit_mounting.mm +430 -0
  61. package/native/tests/test_appkit_scrollbar.mm +132 -0
  62. package/native/tests/test_appkit_services.mm +57 -0
  63. package/native/tests/test_appkit_skia.mm +212 -0
  64. package/native/tests/test_appkit_text.mm +180 -0
  65. package/native/tests/test_appkit_textinput.mm +748 -0
  66. package/native/tests/test_appkit_titlebar.mm +107 -0
  67. package/native/tests/test_appkit_view.mm +237 -0
  68. package/package.json +26 -0
  69. package/react-native.config.js +22 -0
@@ -0,0 +1,197 @@
1
+ // Drives real ShadowViewMutations through the real AppKitMountingManager into real
2
+ // NSViews, with no JS runtime, no Hermes and no Metro.
3
+ //
4
+ // The mirror of gtk/mount_harness_gtk.cpp, with the same two transactions and the
5
+ // same boxes, which is the point: the two platforms now share their mutation
6
+ // walk, and the cheapest way to notice that sharing has broken is to put the
7
+ // two pictures side by side.
8
+ //
9
+ // Note that executeMount only *queues*: in a real host it is called on the JS
10
+ // thread and marshals to the main queue. Calling it from the main thread here
11
+ // takes the same path, one run-loop turn later -- which is why this drains the
12
+ // run loop rather than assuming anything has happened.
13
+ //
14
+ // With BASALT_SNAPSHOT_DIR set it renders both transactions to PNGs and exits.
15
+ // Without it, it opens a window and applies the second transaction after two
16
+ // seconds, the way the GTK harness does.
17
+
18
+ #import "AppKitMountingManager.h"
19
+ #import "AppKitSnapshot.h"
20
+
21
+ #include <react/renderer/components/view/ViewProps.h>
22
+ #include <react/renderer/core/LayoutMetrics.h>
23
+ #include <react/renderer/graphics/Color.h>
24
+ #include <react/renderer/mounting/MountingTransaction.h>
25
+ #include <react/renderer/mounting/ShadowViewMutation.h>
26
+
27
+ #include <cstdint>
28
+ #include <cstdio>
29
+ #include <cstdlib>
30
+ #include <memory>
31
+ #include <utility>
32
+
33
+ using facebook::react::ColorComponents;
34
+ using facebook::react::LayoutMetrics;
35
+ using facebook::react::MountingTransaction;
36
+ using facebook::react::ShadowView;
37
+ using facebook::react::ShadowViewMutation;
38
+ using facebook::react::ShadowViewMutationList;
39
+ using facebook::react::SurfaceId;
40
+ using facebook::react::Tag;
41
+ using facebook::react::TransactionTelemetry;
42
+ using facebook::react::ViewProps;
43
+
44
+ namespace {
45
+
46
+ constexpr SurfaceId kSurfaceId = 1;
47
+
48
+ ShadowView makeShadowView(Tag tag,
49
+ float x, float y, float width, float height,
50
+ float red, float green, float blue,
51
+ float opacity = 1.0f) {
52
+ auto props = std::make_shared<ViewProps>();
53
+ props->backgroundColor = facebook::react::colorFromComponents(
54
+ ColorComponents{.red = red, .green = green, .blue = blue, .alpha = 1.0f});
55
+ props->opacity = opacity;
56
+
57
+ LayoutMetrics layoutMetrics;
58
+ layoutMetrics.frame = {.origin = {.x = x, .y = y},
59
+ .size = {.width = width, .height = height}};
60
+
61
+ ShadowView shadowView;
62
+ shadowView.componentName = "View";
63
+ shadowView.surfaceId = kSurfaceId;
64
+ shadowView.tag = tag;
65
+ shadowView.props = props;
66
+ shadowView.layoutMetrics = layoutMetrics;
67
+ return shadowView;
68
+ }
69
+
70
+ MountingTransaction makeTransaction(MountingTransaction::Number number,
71
+ ShadowViewMutationList &&mutations) {
72
+ return MountingTransaction(kSurfaceId, number, std::move(mutations), TransactionTelemetry{});
73
+ }
74
+
75
+ // First transaction: create four views and place them, one of them nested.
76
+ ShadowViewMutationList firstTransaction() {
77
+ ShadowViewMutationList mutations;
78
+
79
+ const ShadowView blue = makeShadowView(2, 32, 32, 240, 160, 0.30f, 0.55f, 0.95f);
80
+ const ShadowView orange = makeShadowView(3, 296, 32, 240, 160, 0.95f, 0.45f, 0.35f);
81
+ const ShadowView green = makeShadowView(4, 32, 224, 504, 140, 0.35f, 0.80f, 0.55f);
82
+ const ShadowView nested = makeShadowView(5, 24, 24, 120, 90, 1.0f, 1.0f, 1.0f, 0.85f);
83
+
84
+ // Fabric emits every Create before the Inserts that place them.
85
+ mutations.push_back(ShadowViewMutation::CreateMutation(blue));
86
+ mutations.push_back(ShadowViewMutation::CreateMutation(orange));
87
+ mutations.push_back(ShadowViewMutation::CreateMutation(green));
88
+ mutations.push_back(ShadowViewMutation::CreateMutation(nested));
89
+
90
+ mutations.push_back(ShadowViewMutation::InsertMutation(kSurfaceId, blue, 0));
91
+ mutations.push_back(ShadowViewMutation::InsertMutation(kSurfaceId, orange, 1));
92
+ mutations.push_back(ShadowViewMutation::InsertMutation(kSurfaceId, green, 2));
93
+ mutations.push_back(ShadowViewMutation::InsertMutation(blue.tag, nested, 0));
94
+
95
+ return mutations;
96
+ }
97
+
98
+ // Second transaction: recolour one view, and remove another entirely. Fabric
99
+ // always emits Remove before Delete, and this reproduces that ordering.
100
+ ShadowViewMutationList secondTransaction() {
101
+ ShadowViewMutationList mutations;
102
+
103
+ // Update: same tag, new props. Recolour blue -> purple and shrink.
104
+ const ShadowView oldBlue = makeShadowView(2, 32, 32, 240, 160, 0.30f, 0.55f, 0.95f);
105
+ const ShadowView newBlue = makeShadowView(2, 32, 32, 240, 100, 0.60f, 0.35f, 0.90f);
106
+ mutations.push_back(ShadowViewMutation::UpdateMutation(oldBlue, newBlue, kSurfaceId));
107
+
108
+ // Remove then Delete, the order Fabric guarantees.
109
+ const ShadowView orange = makeShadowView(3, 296, 32, 240, 160, 0.95f, 0.45f, 0.35f);
110
+ mutations.push_back(ShadowViewMutation::RemoveMutation(kSurfaceId, orange, 1));
111
+ mutations.push_back(ShadowViewMutation::DeleteMutation(orange));
112
+
113
+ return mutations;
114
+ }
115
+
116
+ // executeMount hands the transaction to the main queue, so nothing has actually
117
+ // mounted when it returns. Spinning the run loop is what makes the queued block
118
+ // run -- and doing it explicitly rather than sleeping is what keeps this from
119
+ // being flaky.
120
+ void drainMainQueue() {
121
+ [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
122
+ }
123
+
124
+ int runSnapshots(basalt::AppKitMountingManager &manager, const char *directory) {
125
+ RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
126
+ [root setRnFrameX:0 y:0 width:640 height:420];
127
+ [root setRnBackgroundColorRed:0.12 green:0.13 blue:0.16 alpha:1.0 hasColor:YES];
128
+
129
+ manager.executeMount(kSurfaceId, makeTransaction(1, firstTransaction()));
130
+ drainMainQueue();
131
+ NSString *first = [NSString stringWithFormat:@"%s/mount-1.png", directory];
132
+ if (!RnAppKitWriteSnapshot(root, first)) {
133
+ return 1;
134
+ }
135
+ std::fputs([[root describeTree] UTF8String], stdout);
136
+
137
+ manager.executeMount(kSurfaceId, makeTransaction(2, secondTransaction()));
138
+ drainMainQueue();
139
+ NSString *second = [NSString stringWithFormat:@"%s/mount-2.png", directory];
140
+ if (!RnAppKitWriteSnapshot(root, second)) {
141
+ return 1;
142
+ }
143
+ std::fputs("---\n", stdout);
144
+ std::fputs([[root describeTree] UTF8String], stdout);
145
+
146
+ manager.destroySurfaceRoot(kSurfaceId);
147
+ return 0;
148
+ }
149
+
150
+ } // namespace
151
+
152
+ int main(int argc, const char *argv[]) {
153
+ (void)argc;
154
+ (void)argv;
155
+ @autoreleasepool {
156
+ // Constructed on the main thread: AppKitMountingManager records this thread and
157
+ // asserts every transaction arrives on it.
158
+ basalt::AppKitMountingManager manager;
159
+
160
+ if (const char *directory = getenv("BASALT_SNAPSHOT_DIR")) {
161
+ return runSnapshots(manager, directory);
162
+ }
163
+
164
+ [NSApplication sharedApplication];
165
+ [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
166
+
167
+ // The host owns the root: Fabric never emits a Create for it.
168
+ RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
169
+ [root setRnFrameX:0 y:0 width:640 height:420];
170
+ [root setRnBackgroundColorRed:0.12 green:0.13 blue:0.16 alpha:1.0 hasColor:YES];
171
+
172
+ NSWindow *window =
173
+ [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 640, 420)
174
+ styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable
175
+ backing:NSBackingStoreBuffered
176
+ defer:NO];
177
+ window.title = @"basalt-core — macOS mount harness";
178
+ window.contentView = root;
179
+ [window center];
180
+ [window makeKeyAndOrderFront:nil];
181
+ [NSApp activateIgnoringOtherApps:YES];
182
+
183
+ manager.executeMount(kSurfaceId, makeTransaction(1, firstTransaction()));
184
+
185
+ // Captured by pointer: a block copies what it captures, and a mounting
186
+ // manager is neither copyable nor const-callable.
187
+ basalt::AppKitMountingManager *pending = &manager;
188
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)),
189
+ dispatch_get_main_queue(),
190
+ ^{
191
+ pending->executeMount(kSurfaceId, makeTransaction(2, secondTransaction()));
192
+ });
193
+
194
+ [NSApp run];
195
+ }
196
+ return 0;
197
+ }
@@ -0,0 +1,170 @@
1
+ // Tests for accessibility, as VoiceOver would see it.
2
+ //
3
+ // These assert the *AppKit* role, not the React Native name. The name is what
4
+ // `describeTree` reports and what the cross-platform diff compares; whether the
5
+ // mapping onto NSAccessibility actually happened is a platform question, and
6
+ // this is where a platform question belongs. The GTK suite's
7
+ // test_accessibility.cpp asserts the same thing about GtkAccessibleRole.
8
+ //
9
+ // Against the view directly, with no Fabric: the mapping is a pure function of
10
+ // a string, and testing it through a mounting transaction would prove the same
11
+ // thing more slowly and less clearly.
12
+
13
+ #include "TestHarness.h"
14
+
15
+ #import "RnAppKitView.h"
16
+
17
+ #include <sstream>
18
+
19
+ namespace {
20
+
21
+ RnAppKitView *viewWithRole(NSString *role) {
22
+ RnAppKitView *view = [RnAppKitView viewWithTag:1];
23
+ [view setRnAccessibleRole:role];
24
+ return view;
25
+ }
26
+
27
+ } // namespace
28
+
29
+ TEST(accessibility_maps_react_native_roles_onto_appkit) {
30
+ @autoreleasepool {
31
+ EXPECT(viewWithRole(@"button").accessibilityRole == NSAccessibilityButtonRole);
32
+ EXPECT(viewWithRole(@"text").accessibilityRole == NSAccessibilityStaticTextRole);
33
+ EXPECT(viewWithRole(@"image").accessibilityRole == NSAccessibilityImageRole);
34
+ EXPECT(viewWithRole(@"link").accessibilityRole == NSAccessibilityLinkRole);
35
+ EXPECT(viewWithRole(@"checkbox").accessibilityRole == NSAccessibilityCheckBoxRole);
36
+ EXPECT(viewWithRole(@"list").accessibilityRole == NSAccessibilityListRole);
37
+ EXPECT(viewWithRole(@"adjustable").accessibilityRole == NSAccessibilitySliderRole);
38
+ }
39
+ }
40
+
41
+ // A role AppKit has no equivalent for, and one it has never heard of. Both
42
+ // become a group rather than a guess: a wrong role is worse for a screen reader
43
+ // than a vague one, because it makes the view announce itself as something it
44
+ // is not.
45
+ TEST(accessibility_falls_back_to_a_group) {
46
+ @autoreleasepool {
47
+ EXPECT(viewWithRole(@"header").accessibilityRole == NSAccessibilityGroupRole);
48
+ EXPECT(viewWithRole(@"somethingnobodyhasheardof").accessibilityRole ==
49
+ NSAccessibilityGroupRole);
50
+ }
51
+ }
52
+
53
+ // A plain <View> is scenery. Leaving every one of them in the tree would bury
54
+ // the handful that mean something under hundreds that do not.
55
+ TEST(accessibility_leaves_plain_views_out_of_the_tree) {
56
+ @autoreleasepool {
57
+ RnAppKitView *plain = [RnAppKitView viewWithTag:1];
58
+ [plain setRnAccessibleRole:nil];
59
+ EXPECT(!plain.isAccessibilityElement);
60
+
61
+ // ...but a role puts it back in.
62
+ EXPECT(viewWithRole(@"button").isAccessibilityElement);
63
+
64
+ // ...and so does a label on its own, which is the common case for an
65
+ // icon-only <Pressable>.
66
+ RnAppKitView *labelled = [RnAppKitView viewWithTag:2];
67
+ [labelled setRnAccessibleRole:nil];
68
+ [labelled setRnAccessibleLabel:@"Close" hint:nil];
69
+ EXPECT(labelled.isAccessibilityElement);
70
+ }
71
+ }
72
+
73
+ // `none` and `presentation` mean "do not announce this", which is a different
74
+ // thing from having no role: the app asked for silence explicitly.
75
+ TEST(accessibility_honours_a_presentational_role) {
76
+ @autoreleasepool {
77
+ EXPECT(!viewWithRole(@"none").isAccessibilityElement);
78
+ EXPECT(!viewWithRole(@"presentation").isAccessibilityElement);
79
+ }
80
+ }
81
+
82
+ TEST(accessibility_label_and_hint_reach_appkit) {
83
+ @autoreleasepool {
84
+ RnAppKitView *view = viewWithRole(@"button");
85
+ [view setRnAccessibleLabel:@"Save" hint:@"Writes the file to disk"];
86
+
87
+ EXPECT([view.accessibilityLabel isEqualToString:@"Save"]);
88
+ // The hint is help, not value: React Native's hint is the supplementary
89
+ // description, which on a Mac is what a help tag carries.
90
+ EXPECT([view.accessibilityHelp isEqualToString:@"Writes the file to disk"]);
91
+
92
+ // Empty clears rather than setting an empty string, which VoiceOver would
93
+ // read as a pause.
94
+ [view setRnAccessibleLabel:@"" hint:@""];
95
+ EXPECT(view.accessibilityLabel == nil);
96
+ EXPECT(view.accessibilityHelp == nil);
97
+ }
98
+ }
99
+
100
+ TEST(accessibility_states_reach_appkit) {
101
+ @autoreleasepool {
102
+ RnAppKitView *view = viewWithRole(@"checkbox");
103
+
104
+ [view setRnAccessibleStateDisabled:RnAppKitAccessibleTrue
105
+ checked:RnAppKitAccessibleTrue
106
+ selected:RnAppKitAccessibleTrue
107
+ expanded:RnAppKitAccessibleTrue
108
+ busy:RnAppKitAccessibleUnset];
109
+
110
+ EXPECT(!view.isAccessibilityEnabled);
111
+ EXPECT(view.isAccessibilitySelected);
112
+ EXPECT(view.isAccessibilityExpanded);
113
+ // Checked is a value on a Mac, the way a checkbox reports one, rather than
114
+ // a state of its own.
115
+ EXPECT([view.accessibilityValue isEqual:@YES]);
116
+
117
+ [view setRnAccessibleStateDisabled:RnAppKitAccessibleFalse
118
+ checked:RnAppKitAccessibleFalse
119
+ selected:RnAppKitAccessibleFalse
120
+ expanded:RnAppKitAccessibleFalse
121
+ busy:RnAppKitAccessibleUnset];
122
+
123
+ EXPECT(view.isAccessibilityEnabled);
124
+ EXPECT(!view.isAccessibilitySelected);
125
+ EXPECT([view.accessibilityValue isEqual:@NO]);
126
+ }
127
+ }
128
+
129
+ // Unset is not false. A view whose app never mentioned `disabled` must keep
130
+ // AppKit's default rather than being marked enabled, because the two are
131
+ // different claims and only one of them was made.
132
+ TEST(accessibility_unset_leaves_appkit_alone) {
133
+ @autoreleasepool {
134
+ RnAppKitView *view = viewWithRole(@"button");
135
+ view.accessibilityEnabled = NO;
136
+
137
+ [view setRnAccessibleStateDisabled:RnAppKitAccessibleUnset
138
+ checked:RnAppKitAccessibleUnset
139
+ selected:RnAppKitAccessibleUnset
140
+ expanded:RnAppKitAccessibleUnset
141
+ busy:RnAppKitAccessibleUnset];
142
+
143
+ EXPECT(!view.isAccessibilityEnabled);
144
+ EXPECT(view.accessibilityValue == nil);
145
+ }
146
+ }
147
+
148
+ TEST(accessibility_hidden_takes_a_view_out_of_the_tree) {
149
+ @autoreleasepool {
150
+ RnAppKitView *view = viewWithRole(@"button");
151
+ EXPECT(view.isAccessibilityElement);
152
+
153
+ [view setRnAccessibleHidden:YES];
154
+ EXPECT(!view.isAccessibilityElement);
155
+ }
156
+ }
157
+
158
+ // The dump reports React Native's name, which is the whole reason the view
159
+ // keeps one: the two hosts' trees are diffed line by line.
160
+ TEST(accessibility_role_is_reported_in_the_tree) {
161
+ @autoreleasepool {
162
+ RnAppKitView *view = viewWithRole(@"button");
163
+ const std::string described = [view describeTree].UTF8String;
164
+ EXPECT(described.find("role=button") != std::string::npos);
165
+
166
+ RnAppKitView *plain = [RnAppKitView viewWithTag:2];
167
+ [plain setRnAccessibleRole:nil];
168
+ EXPECT(std::string([plain describeTree].UTF8String).find("role=") == std::string::npos);
169
+ }
170
+ }
@@ -0,0 +1,237 @@
1
+ // Tests for how an <Image> fills its frame.
2
+ //
3
+ // The four resize modes are the part of images two platforms are most likely to
4
+ // disagree about while both looking plausible on their own, and the arithmetic
5
+ // is duplicated per view layer -- the view layers share no code, by design --
6
+ // so it is exactly the kind of thing that drifts.
7
+ //
8
+ // Against a real bitmap rather than against the rectangle the view computed:
9
+ // the rectangle is private, and a view that computed it correctly and drew
10
+ // nowhere near it would pass either way. What is asserted is where the ink
11
+ // lands, which is what somebody looking at the screen would check.
12
+ //
13
+ // No exact pixel counts. Nearest-neighbour versus smoothed scaling puts an
14
+ // edge half a pixel either way, and pinning that would fail on a future macOS
15
+ // for no useful reason.
16
+
17
+ #include "TestHarness.h"
18
+
19
+ #import "RnAppKitView.h"
20
+
21
+ #include <sstream>
22
+
23
+ namespace {
24
+
25
+ // A 4x2 image, opaque black. Small and oddly shaped, so a mode that ignores the
26
+ // aspect ratio is obvious.
27
+ CGImageRef makeImage(size_t width, size_t height) {
28
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
29
+ CGContextRef context = CGBitmapContextCreate(nullptr, width, height, 8, 0, space,
30
+ kCGImageAlphaPremultipliedLast);
31
+ CGColorSpaceRelease(space);
32
+ CGContextSetRGBFillColor(context, 0, 0, 0, 1);
33
+ CGContextFillRect(context, CGRectMake(0, 0, (CGFloat)width, (CGFloat)height));
34
+ CGImageRef image = CGBitmapContextCreateImage(context);
35
+ CGContextRelease(context);
36
+ return image;
37
+ }
38
+
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
+ };
47
+
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) {
51
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
52
+ CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width, (size_t)size.height,
53
+ 8, 0, space, kCGImageAlphaPremultipliedLast);
54
+ CGColorSpaceRelease(space);
55
+ CGContextSetRGBFillColor(context, 1, 1, 1, 1);
56
+ CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
57
+ // A CGBitmapContext counts y from the bottom and this view counts it from the
58
+ // 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.
62
+ CGContextTranslateCTM(context, 0, size.height);
63
+ CGContextScaleCTM(context, 1, -1);
64
+
65
+ NSGraphicsContext *previous = NSGraphicsContext.currentContext;
66
+ NSGraphicsContext.currentContext = [NSGraphicsContext graphicsContextWithCGContext:context
67
+ flipped:YES];
68
+ [view drawRect:NSMakeRect(0, 0, size.width, size.height)];
69
+ NSGraphicsContext.currentContext = previous;
70
+
71
+ auto *pixels = static_cast<unsigned char *>(CGBitmapContextGetData(context));
72
+ const size_t stride = CGBitmapContextGetBytesPerRow(context);
73
+ Ink ink;
74
+ for (int y = 0; y < (int)size.height; y++) {
75
+ for (int x = 0; x < (int)size.width; x++) {
76
+ if (pixels[(size_t)y * stride + (size_t)x * 4] < 128) {
77
+ if (ink.left < 0 || x < ink.left) ink.left = x;
78
+ if (ink.right < 0 || x > ink.right) ink.right = x;
79
+ if (ink.top < 0 || y < ink.top) ink.top = y;
80
+ if (ink.bottom < 0 || y > ink.bottom) ink.bottom = y;
81
+ }
82
+ }
83
+ }
84
+ CGContextRelease(context);
85
+ return ink;
86
+ }
87
+
88
+ RnAppKitView *imageView(CGImageRef image, RnAppKitImageFit fit, CGSize size) {
89
+ RnAppKitView *view = [RnAppKitView viewWithTag:1];
90
+ [view setRnFrameX:0 y:0 width:size.width height:size.height];
91
+ [view setRnImage:image fit:fit];
92
+ return view;
93
+ }
94
+
95
+ } // namespace
96
+
97
+ // stretch ignores the aspect ratio and fills the box exactly.
98
+ TEST(image_stretch_fills_the_frame) {
99
+ @autoreleasepool {
100
+ CGImageRef image = makeImage(4, 2);
101
+ const CGSize size = CGSizeMake(100, 100);
102
+ const Ink ink = inkOf(imageView(image, RnAppKitImageFitStretch, size), size);
103
+ CGImageRelease(image);
104
+
105
+ EXPECT_NEAR(ink.width(), 100, 2);
106
+ EXPECT_NEAR(ink.height(), 100, 2);
107
+ }
108
+ }
109
+
110
+ // contain fits the whole image inside, so a 2:1 image in a square box spans the
111
+ // full width and half the height, centred.
112
+ TEST(image_contain_fits_inside_and_centres) {
113
+ @autoreleasepool {
114
+ CGImageRef image = makeImage(4, 2);
115
+ const CGSize size = CGSizeMake(100, 100);
116
+ const Ink ink = inkOf(imageView(image, RnAppKitImageFitContain, size), size);
117
+ CGImageRelease(image);
118
+
119
+ EXPECT_NEAR(ink.width(), 100, 2);
120
+ EXPECT_NEAR(ink.height(), 50, 2);
121
+ // Centred vertically: 25 above and 25 below.
122
+ EXPECT_NEAR(ink.top, 25, 2);
123
+ }
124
+ }
125
+
126
+ // cover fills the box and crops, so the same image spans the full box with its
127
+ // sides cut off -- never any white.
128
+ TEST(image_cover_fills_the_frame_and_crops) {
129
+ @autoreleasepool {
130
+ CGImageRef image = makeImage(4, 2);
131
+ const CGSize size = CGSizeMake(100, 100);
132
+ const Ink ink = inkOf(imageView(image, RnAppKitImageFitCover, size), size);
133
+ CGImageRelease(image);
134
+
135
+ EXPECT_NEAR(ink.width(), 100, 2);
136
+ EXPECT_NEAR(ink.height(), 100, 2);
137
+ }
138
+ }
139
+
140
+ // center draws at natural size, centred -- and never scales up, which is the
141
+ // half of `center` that is easy to miss.
142
+ TEST(image_center_draws_at_natural_size) {
143
+ @autoreleasepool {
144
+ CGImageRef image = makeImage(40, 20);
145
+ const CGSize size = CGSizeMake(100, 100);
146
+ const Ink ink = inkOf(imageView(image, RnAppKitImageFitCenter, size), size);
147
+ CGImageRelease(image);
148
+
149
+ EXPECT_NEAR(ink.width(), 40, 2);
150
+ EXPECT_NEAR(ink.height(), 20, 2);
151
+ EXPECT_NEAR(ink.left, 30, 2);
152
+ EXPECT_NEAR(ink.top, 40, 2);
153
+ }
154
+ }
155
+
156
+ // An image larger than its frame is scaled down by `center` rather than
157
+ // overflowing, and cover and center both clip -- an <Image> never paints
158
+ // outside its own box on iOS or Android.
159
+ TEST(image_center_shrinks_an_oversized_image) {
160
+ @autoreleasepool {
161
+ CGImageRef image = makeImage(400, 200);
162
+ const CGSize size = CGSizeMake(100, 100);
163
+ const Ink ink = inkOf(imageView(image, RnAppKitImageFitCenter, size), size);
164
+ CGImageRelease(image);
165
+
166
+ EXPECT_NEAR(ink.width(), 100, 2);
167
+ EXPECT_NEAR(ink.height(), 50, 2);
168
+ }
169
+ }
170
+
171
+ TEST(image_is_reported_in_the_tree) {
172
+ @autoreleasepool {
173
+ CGImageRef image = makeImage(160, 100);
174
+ RnAppKitView *view = imageView(image, RnAppKitImageFitCover, CGSizeMake(50, 50));
175
+ CGImageRelease(image);
176
+
177
+ const std::string described = [view describeTree].UTF8String;
178
+ // The same `texture=WxH` the GTK side emits, which the end-to-end suite
179
+ // asserts on and the cross-platform diff compares.
180
+ EXPECT(described.find("texture=160x100") != std::string::npos);
181
+ }
182
+ }
183
+
184
+ // Clearing has to release, and drawing after it has to be a no-op rather than a
185
+ // use-after-free.
186
+ TEST(image_can_be_cleared) {
187
+ @autoreleasepool {
188
+ CGImageRef image = makeImage(4, 2);
189
+ RnAppKitView *view = imageView(image, RnAppKitImageFitCover, CGSizeMake(50, 50));
190
+ CGImageRelease(image);
191
+
192
+ [view setRnImage:nullptr fit:RnAppKitImageFitCover];
193
+
194
+ const std::string described = [view describeTree].UTF8String;
195
+ EXPECT(described.find("texture=") == std::string::npos);
196
+ EXPECT_EQ(inkOf(view, CGSizeMake(50, 50)).width(), 0);
197
+ }
198
+ }
199
+
200
+ // `repeat` tiles from the top left at the image's natural size.
201
+ //
202
+ // The case that tells it from `center`, which is what it used to fall back
203
+ // to: a small image in a large box leaves the corners blank when centred and
204
+ // covers them when tiled. Asserting the ink's bounding box is the whole test
205
+ // -- if it fills the view, every tile landed.
206
+ TEST(image_repeat_tiles_the_whole_box) {
207
+ @autoreleasepool {
208
+ CGImageRef image = makeImage(4, 2);
209
+ RnAppKitView *view = imageView(image, RnAppKitImageFitRepeat, CGSizeMake(100, 100));
210
+
211
+ const Ink ink = inkOf(view, CGSizeMake(100, 100));
212
+ // Corner to corner. Centred, a 4x2 image would leave everything outside a
213
+ // 4x2 patch in the middle blank.
214
+ EXPECT_EQ(ink.left, 0);
215
+ EXPECT_EQ(ink.top, 0);
216
+ EXPECT_EQ(ink.right, 99);
217
+ EXPECT_EQ(ink.bottom, 99);
218
+
219
+ CGImageRelease(image);
220
+ }
221
+ }
222
+
223
+ // Not stretched: a tile keeps its own size, which is what distinguishes
224
+ // `repeat` from `stretch` when the image is small and the box is not.
225
+ TEST(image_repeat_keeps_the_tile_at_its_natural_size) {
226
+ @autoreleasepool {
227
+ // A 4x2 black image tiled into 100x100 puts a seam every 4 across and
228
+ // every 2 down. What is checked is cheaper than seams: the image is fully
229
+ // opaque, so a stretch and a tile both fill the box -- but a tile of a
230
+ // *transparent-edged* image would not. Use the fit name instead, which is
231
+ // what the cross-host dump compares.
232
+ CGImageRef image = makeImage(4, 2);
233
+ RnAppKitView *view = imageView(image, RnAppKitImageFitRepeat, CGSizeMake(100, 100));
234
+ EXPECT([[view describeTree] containsString:@"fit=repeat"]);
235
+ CGImageRelease(image);
236
+ }
237
+ }