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
@@ -73,8 +73,88 @@ bool inkAt(NSView *view, CGSize size, int atX, int atY) {
73
73
  return marked;
74
74
  }
75
75
 
76
+ // The red channel at a point, drawn over mid-grey rather than white.
77
+ //
78
+ // Grey because this is what tells the thumb's *colour* apart rather than its
79
+ // presence: a black thumb at core's alpha darkens grey and a white one lightens
80
+ // it, where over white only one of the two shows up at all.
81
+ int greyAt(NSView *view, CGSize size, int atX, int atY) {
82
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
83
+ CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width, (size_t)size.height, 8,
84
+ 0, space, kCGImageAlphaPremultipliedLast);
85
+ CGColorSpaceRelease(space);
86
+ CGContextSetRGBFillColor(context, 0.5, 0.5, 0.5, 1);
87
+ CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
88
+ CGContextTranslateCTM(context, 0, size.height);
89
+ CGContextScaleCTM(context, 1, -1);
90
+
91
+ NSGraphicsContext *previous = NSGraphicsContext.currentContext;
92
+ NSGraphicsContext.currentContext = [NSGraphicsContext graphicsContextWithCGContext:context
93
+ flipped:YES];
94
+ [view drawRect:NSMakeRect(0, 0, size.width, size.height)];
95
+ NSGraphicsContext.currentContext = previous;
96
+
97
+ auto *pixels = static_cast<unsigned char *>(CGBitmapContextGetData(context));
98
+ const size_t stride = CGBitmapContextGetBytesPerRow(context);
99
+ const int red = pixels[(size_t)atY * stride + (size_t)atX * 4];
100
+ CGContextRelease(context);
101
+ return red;
102
+ }
103
+
76
104
  } // namespace
77
105
 
106
+ // `indicatorStyle`, which is the thumb's colour: black over light content and
107
+ // white over dark, as core resolves it. The colour is held on the scroll view
108
+ // rather than on the overlay because the overlay comes and goes with the
109
+ // content size, which the second half of this asserts.
110
+ TEST(appkit_scrollbar_draws_in_the_colour_it_was_given) {
111
+ RnAppKitView *view = scrollViewWithIndicators(100, 100);
112
+ NSView *indicators = indicatorViewOf(view);
113
+ EXPECT(indicators != nil);
114
+
115
+ const CGSize size = CGSizeMake(100, 100);
116
+ const int grey = 128;
117
+ // The default: darker than the ground it sits on.
118
+ EXPECT(greyAt(indicators, size, 95, 10) < grey - 20);
119
+
120
+ const basalt::ScrollIndicatorColour white =
121
+ basalt::scrollIndicatorColourFor(basalt::ScrollIndicatorStyle::White);
122
+ [view setRnScrollIndicatorColourRed:white.red
123
+ green:white.green
124
+ blue:white.blue
125
+ alpha:white.alpha];
126
+ // And white: lighter than the same ground, which is the whole point of the
127
+ // prop -- over dark content a black thumb is not there.
128
+ EXPECT(greyAt(indicatorViewOf(view), size, 95, 10) > grey + 20);
129
+ }
130
+
131
+ TEST(appkit_scrollbar_keeps_its_colour_across_an_overlay_it_lost) {
132
+ // The overlay is destroyed when everything fits and built again when it does
133
+ // not, so a colour kept only there would go back to black the first time a
134
+ // list shrank inside its viewport.
135
+ RnAppKitView *view = scrollViewWithIndicators(100, 100);
136
+ const basalt::ScrollIndicatorColour white =
137
+ basalt::scrollIndicatorColourFor(basalt::ScrollIndicatorStyle::White);
138
+ [view setRnScrollIndicatorColourRed:white.red
139
+ green:white.green
140
+ blue:white.blue
141
+ alpha:white.alpha];
142
+
143
+ [view setRnScrollIndicatorVerticalOffset:0
144
+ verticalLength:0
145
+ horizontalOffset:0
146
+ horizontalLength:0];
147
+ EXPECT(indicatorViewOf(view) == nil);
148
+
149
+ [view setRnScrollIndicatorVerticalOffset:0
150
+ verticalLength:24
151
+ horizontalOffset:0
152
+ horizontalLength:0];
153
+ NSView *rebuilt = indicatorViewOf(view);
154
+ EXPECT(rebuilt != nil);
155
+ EXPECT(greyAt(rebuilt, CGSizeMake(100, 100), 95, 10) > 148);
156
+ }
157
+
78
158
  // The thumb exists, and it is the last subview -- which is the one AppKit draws
79
159
  // on top of the others.
80
160
  TEST(appkit_scrollbar_is_the_topmost_subview) {
@@ -130,3 +210,48 @@ TEST(appkit_scrollbar_goes_away_when_everything_fits) {
130
210
  horizontalLength:0];
131
211
  EXPECT(indicatorViewOf(view) == nil);
132
212
  }
213
+
214
+ // The hairline that tells `default` from `black`, which is the whole
215
+ // difference between the two on iOS: `UIScrollViewIndicatorStyleDefault` is
216
+ // "black with a white border" and `.black` is black alone. Without it an app
217
+ // over dark content that left `indicatorStyle` alone got a black thumb on a
218
+ // black ground, which is a scrollbar nobody can see.
219
+ //
220
+ // Over grey rather than white, for the reason the colour test above is: the
221
+ // fill is a wash and both the fill and the edge have to be measurable against
222
+ // the same ground. The edge is read at the thumb's outermost column and the
223
+ // fill two points inside it, so the two cannot be confused with each other.
224
+ TEST(appkit_scrollbar_default_has_a_white_edge_and_black_does_not) {
225
+ RnAppKitView *view = scrollViewWithIndicators(100, 100);
226
+ const CGSize size = CGSizeMake(100, 100);
227
+ const int grey = 128;
228
+
229
+ const basalt::ScrollIndicatorColour fill =
230
+ basalt::scrollIndicatorColourFor(basalt::ScrollIndicatorStyle::Default);
231
+ const basalt::ScrollIndicatorColour edge =
232
+ basalt::scrollIndicatorBorderColourFor(basalt::ScrollIndicatorStyle::Default);
233
+ [view setRnScrollIndicatorColourRed:fill.red green:fill.green blue:fill.blue alpha:fill.alpha];
234
+ [view setRnScrollIndicatorBorderColourRed:edge.red
235
+ green:edge.green
236
+ blue:edge.blue
237
+ alpha:edge.alpha];
238
+
239
+ // The thumb is six points wide, two in from the right edge of a hundred, so
240
+ // its columns are 92 to 97. The left-hand edge column carries the hairline
241
+ // and the middle carries the fill.
242
+ const int edgeColumn = 92;
243
+ const int fillColumn = 95;
244
+ EXPECT(greyAt(indicatorViewOf(view), size, edgeColumn, 20) > grey + 10);
245
+ EXPECT(greyAt(indicatorViewOf(view), size, fillColumn, 20) < grey - 20);
246
+
247
+ // And `black`, which is the same fill with no edge: the column that was
248
+ // lighter than its ground is not any more.
249
+ const basalt::ScrollIndicatorColour none =
250
+ basalt::scrollIndicatorBorderColourFor(basalt::ScrollIndicatorStyle::Black);
251
+ [view setRnScrollIndicatorBorderColourRed:none.red
252
+ green:none.green
253
+ blue:none.blue
254
+ alpha:none.alpha];
255
+ EXPECT(greyAt(indicatorViewOf(view), size, edgeColumn, 20) <= grey);
256
+ EXPECT(greyAt(indicatorViewOf(view), size, fillColumn, 20) < grey - 20);
257
+ }
@@ -0,0 +1,371 @@
1
+ // `<ScrollView>` on AppKit, as far as a test without a window reaches.
2
+ //
3
+ // Most of what this host's scroll view does needs a display: the wheel arrives
4
+ // as an NSEvent, the momentum is the system's, and the indicator is a view that
5
+ // has to be drawn. What does not need one is the part React Native drives --
6
+ // props arriving as mutations and the offset that results -- and
7
+ // `AppKitScrollViewManager` takes an emitter lookup and a view, so it can be
8
+ // built on its own exactly as the GTK suite builds its own manager.
9
+ //
10
+ // An EventEmitter made by hand has no EventDispatcher, so the lookup answering
11
+ // nothing is honest: what is observable here is where the content sits.
12
+
13
+ #include "TestHarness.h"
14
+
15
+ #include "AppKitMountingManager.h"
16
+ #include "AppKitScrollView.h"
17
+ #include "ScrollIndicator.h"
18
+
19
+ #import "RnAppKitView.h"
20
+
21
+ #include <react/renderer/components/scrollview/ScrollViewProps.h>
22
+ #include <react/renderer/components/scrollview/ScrollViewShadowNode.h>
23
+ #include <react/renderer/components/scrollview/ScrollViewState.h>
24
+
25
+ #include <memory>
26
+ #include <optional>
27
+ #include <sstream>
28
+
29
+ using facebook::react::EventEmitter;
30
+ using facebook::react::LayoutMetrics;
31
+ using facebook::react::MountingTransaction;
32
+ using facebook::react::ScrollViewProps;
33
+ using facebook::react::ScrollViewShadowNode;
34
+ using facebook::react::ScrollViewState;
35
+ using facebook::react::ShadowNodeFamily;
36
+ using facebook::react::ShadowView;
37
+ using facebook::react::ShadowViewMutation;
38
+ using facebook::react::ShadowViewMutationList;
39
+ using facebook::react::SurfaceId;
40
+ using facebook::react::TransactionTelemetry;
41
+ using facebook::react::ViewProps;
42
+ using facebook::react::Tag;
43
+
44
+ // `Point`, `Rect` and `Size` are not imported by name: Carbon declares all
45
+ // three, through Cocoa, and an unqualified one is ambiguous in an Objective-C++
46
+ // translation unit. Aliases rather than a `using`, so the geometry below reads
47
+ // the way it does in the C++ suites.
48
+ using RnPoint = facebook::react::Point;
49
+ using RnRect = facebook::react::Rect;
50
+ using RnSize = facebook::react::Size;
51
+
52
+ namespace {
53
+
54
+ constexpr SurfaceId kSurfaceId = 1;
55
+
56
+ ShadowView makeScrollView(Tag tag,
57
+ float contentHeight,
58
+ RnPoint contentOffset = RnPoint{0, 0},
59
+ bool centerContent = false,
60
+ facebook::react::ScrollViewIndicatorStyle indicatorStyle =
61
+ facebook::react::ScrollViewIndicatorStyle::Default,
62
+ std::optional<facebook::react::ScrollViewMaintainVisibleContentPosition>
63
+ maintainVisible = std::nullopt) {
64
+ LayoutMetrics metrics;
65
+ metrics.frame = {.origin = {.x = 0, .y = 0}, .size = {.width = 400, .height = 300}};
66
+
67
+ auto props = std::make_shared<ScrollViewProps>();
68
+ props->contentOffset = contentOffset;
69
+ props->centerContent = centerContent;
70
+ props->indicatorStyle = indicatorStyle;
71
+ props->maintainVisibleContentPosition = maintainVisible;
72
+
73
+ ScrollViewState data;
74
+ // The prop and the state carry the same value, which is what React Native
75
+ // produces: `ScrollViewShadowNode::initialStateData` seeds the state from the
76
+ // prop. A test that set only one of them would be testing a shape that never
77
+ // arrives.
78
+ data.contentOffset = contentOffset;
79
+ data.contentBoundingRect = RnRect{.origin = {.x = 0, .y = 0},
80
+ .size = RnSize{.width = 400, .height = contentHeight}};
81
+
82
+ ShadowView view;
83
+ view.componentName = "ScrollView";
84
+ view.surfaceId = kSurfaceId;
85
+ view.tag = tag;
86
+ view.props = props;
87
+ view.layoutMetrics = metrics;
88
+ // An empty family: nothing can be committed back through it, so the state
89
+ // write-back is not observable from here.
90
+ view.state = std::make_shared<const ScrollViewShadowNode::ConcreteState>(
91
+ std::make_shared<const ScrollViewState>(data), ShadowNodeFamily::Weak{});
92
+ return view;
93
+ }
94
+
95
+ // A manager with one scroll view registered against a real RnAppKitView.
96
+ struct Scroller {
97
+ basalt::AppKitScrollViewManager manager;
98
+ RnAppKitView *view;
99
+ Tag tag;
100
+
101
+ Scroller(Tag tag,
102
+ float contentHeight,
103
+ RnPoint contentOffset = RnPoint{0, 0},
104
+ bool centerContent = false,
105
+ facebook::react::ScrollViewIndicatorStyle indicatorStyle =
106
+ facebook::react::ScrollViewIndicatorStyle::Default)
107
+ : manager([](Tag) { return EventEmitter::Shared{}; }),
108
+ view([RnAppKitView viewWithTag:static_cast<int>(tag)]),
109
+ tag(tag) {
110
+ [view setRnFrameX:0 y:0 width:400 height:300];
111
+ manager.update(
112
+ view,
113
+ makeScrollView(tag, contentHeight, contentOffset, centerContent, indicatorStyle));
114
+ }
115
+
116
+ ~Scroller() { manager.remove(tag); }
117
+
118
+ Scroller(const Scroller &) = delete;
119
+ Scroller &operator=(const Scroller &) = delete;
120
+
121
+ double offsetY() const { return view.rnScrollOffset.y; }
122
+ };
123
+
124
+ } // namespace
125
+
126
+ // --- contentOffset ----------------------------------------------------------
127
+ //
128
+ // The first value an app writes reaches here through the *state*, which
129
+ // ScrollViewShadowNode seeds from the prop, and every host already adopted it.
130
+ // A later change arrives as an ordinary prop update and reached no host until
131
+ // 2026-10-10.
132
+
133
+ TEST(appkit_scrollview_an_initial_content_offset_opens_the_list_there) {
134
+ @autoreleasepool {
135
+ Scroller scroller(10, 4000, RnPoint{0, 120});
136
+ EXPECT_NEAR(scroller.offsetY(), 120.0, 0.001);
137
+ }
138
+ }
139
+
140
+ TEST(appkit_scrollview_a_changed_content_offset_moves_the_list) {
141
+ @autoreleasepool {
142
+ Scroller scroller(11, 4000, RnPoint{0, 120});
143
+ scroller.manager.update(scroller.view, makeScrollView(11, 4000, RnPoint{0, 700}));
144
+ EXPECT_NEAR(scroller.offsetY(), 700.0, 0.001);
145
+ }
146
+ }
147
+
148
+ TEST(appkit_scrollview_an_unchanged_content_offset_leaves_the_list_where_it_is) {
149
+ @autoreleasepool {
150
+ // The reason this is a "when it changes" rule rather than a comparison
151
+ // against the list: React Native re-renders for all sorts of reasons, and
152
+ // every one of them carries the same `contentOffset` the app wrote once.
153
+ Scroller scroller(12, 4000, RnPoint{0, 120});
154
+ scroller.manager.dispatchCommand(
155
+ scroller.tag, "scrollTo", folly::dynamic::array(0, 900, false));
156
+ EXPECT_NEAR(scroller.offsetY(), 900.0, 0.001);
157
+
158
+ scroller.manager.update(scroller.view, makeScrollView(12, 4000, RnPoint{0, 120}));
159
+ EXPECT_NEAR(scroller.offsetY(), 900.0, 0.001);
160
+ }
161
+ }
162
+
163
+ TEST(appkit_scrollview_a_content_offset_past_the_content_is_clamped) {
164
+ @autoreleasepool {
165
+ // 300 of viewport over 500 of content: 200 is as far as it goes.
166
+ Scroller scroller(13, 500, RnPoint{0, 400});
167
+ EXPECT_NEAR(scroller.offsetY(), 200.0, 0.001);
168
+ }
169
+ }
170
+
171
+ // --- centerContent ----------------------------------------------------------
172
+ //
173
+ // The arithmetic is core/ScrollBounds.h's: an inset of half the slack at each
174
+ // end, which leaves one offset to rest at. What this asserts is that the prop
175
+ // reaches it on this host and that the clamp puts the content there.
176
+
177
+ TEST(appkit_scrollview_center_content_centres_content_that_fits) {
178
+ @autoreleasepool {
179
+ // 200 of content in a 300 container: 100 of slack, so the content sits 50
180
+ // further down, which is an offset of -50.
181
+ Scroller scroller(20, 200, RnPoint{0, 0}, /*centerContent=*/true);
182
+ EXPECT_NEAR(scroller.offsetY(), -50.0, 0.001);
183
+ }
184
+ }
185
+
186
+ TEST(appkit_scrollview_content_that_fits_is_not_centred_unless_asked) {
187
+ @autoreleasepool {
188
+ Scroller scroller(21, 200);
189
+ EXPECT_NEAR(scroller.offsetY(), 0.0, 0.001);
190
+ }
191
+ }
192
+
193
+ TEST(appkit_scrollview_center_content_does_nothing_to_a_list_that_scrolls) {
194
+ @autoreleasepool {
195
+ Scroller scroller(22, 4000, RnPoint{0, 0}, /*centerContent=*/true);
196
+ EXPECT_NEAR(scroller.offsetY(), 0.0, 0.001);
197
+ }
198
+ }
199
+
200
+ // --- indicatorStyle ---------------------------------------------------------
201
+ //
202
+ // The prop reaching the thumb's colour. What that colour draws as is
203
+ // tests/test_appkit_scrollbar.mm's question, where the overlay is rasterised;
204
+ // this is the wiring, and the colour is readable straight off the view.
205
+
206
+ TEST(appkit_scrollview_indicator_style_reaches_the_thumb) {
207
+ @autoreleasepool {
208
+ Scroller white(30,
209
+ 4000,
210
+ RnPoint{0, 0},
211
+ false,
212
+ facebook::react::ScrollViewIndicatorStyle::White);
213
+ NSColor *const colour =
214
+ [white.view.rnScrollIndicatorColour colorUsingColorSpace:NSColorSpace.sRGBColorSpace];
215
+ EXPECT(colour != nil);
216
+ if (colour == nil) {
217
+ return;
218
+ }
219
+ EXPECT_NEAR(colour.redComponent, 1.0, 0.001);
220
+ EXPECT_NEAR(colour.greenComponent, 1.0, 0.001);
221
+ EXPECT_NEAR(colour.blueComponent, 1.0, 0.001);
222
+ EXPECT_NEAR(colour.alphaComponent, basalt::kScrollIndicatorAlpha, 0.001);
223
+ }
224
+ }
225
+
226
+ TEST(appkit_scrollview_a_default_indicator_is_black) {
227
+ @autoreleasepool {
228
+ // Black rather than nothing: the manager answers the prop on every update,
229
+ // so a list that never set it still has a colour -- core's.
230
+ Scroller plain(31, 4000);
231
+ NSColor *const colour =
232
+ [plain.view.rnScrollIndicatorColour colorUsingColorSpace:NSColorSpace.sRGBColorSpace];
233
+ EXPECT(colour != nil);
234
+ if (colour == nil) {
235
+ return;
236
+ }
237
+ EXPECT_NEAR(colour.redComponent, 0.0, 0.001);
238
+ EXPECT_NEAR(colour.alphaComponent, basalt::kScrollIndicatorAlpha, 0.001);
239
+ }
240
+ }
241
+
242
+ // --- maintainVisibleContentPosition ------------------------------------------
243
+ //
244
+ // Through the *mounting* manager rather than the scroll manager the rest of
245
+ // this file uses: the two halves of this prop run around a transaction, so a
246
+ // test that called them itself would not be testing that anything calls them.
247
+ // The arithmetic is core/tests/test_scroll_visible_position.cpp's.
248
+ namespace {
249
+
250
+ ShadowView makePlainView(Tag tag, float x, float y, float width, float height) {
251
+ LayoutMetrics metrics;
252
+ metrics.frame = {.origin = {.x = x, .y = y}, .size = {.width = width, .height = height}};
253
+
254
+ ShadowView view;
255
+ view.componentName = "View";
256
+ view.surfaceId = kSurfaceId;
257
+ view.tag = tag;
258
+ view.props = std::make_shared<ViewProps>();
259
+ view.layoutMetrics = metrics;
260
+ return view;
261
+ }
262
+
263
+ void applyTo(basalt::AppKitMountingManager &manager, ShadowViewMutationList &&mutations) {
264
+ manager.applyTransaction(
265
+ kSurfaceId, MountingTransaction(kSurfaceId, 1, std::move(mutations), TransactionTelemetry{}));
266
+ }
267
+
268
+ // A ScrollView with a content view and two hundred-point rows under it, which
269
+ // is the smallest tree the prop can be asked about.
270
+ void mountList(basalt::AppKitMountingManager &manager, const ShadowView &scroller) {
271
+ ShadowViewMutationList mutations;
272
+ mutations.push_back(ShadowViewMutation::CreateMutation(scroller));
273
+ mutations.push_back(ShadowViewMutation::InsertMutation(kSurfaceId, scroller, 0));
274
+ const ShadowView content = makePlainView(11, 0, 0, 400, 1000);
275
+ mutations.push_back(ShadowViewMutation::CreateMutation(content));
276
+ mutations.push_back(ShadowViewMutation::InsertMutation(10, content, 0));
277
+ const ShadowView first = makePlainView(12, 0, 0, 400, 100);
278
+ const ShadowView second = makePlainView(13, 0, 100, 400, 100);
279
+ mutations.push_back(ShadowViewMutation::CreateMutation(first));
280
+ mutations.push_back(ShadowViewMutation::InsertMutation(11, first, 0));
281
+ mutations.push_back(ShadowViewMutation::CreateMutation(second));
282
+ mutations.push_back(ShadowViewMutation::InsertMutation(11, second, 1));
283
+ applyTo(manager, std::move(mutations));
284
+ }
285
+
286
+ // The first row grows by a hundred points, which pushes the second one down by
287
+ // the same -- the shape an insertion above the visible content has.
288
+ void growTheFirstRow(basalt::AppKitMountingManager &manager) {
289
+ ShadowViewMutationList mutations;
290
+ mutations.push_back(ShadowViewMutation::UpdateMutation(
291
+ makePlainView(12, 0, 0, 400, 100), makePlainView(12, 0, 0, 400, 200), kSurfaceId));
292
+ mutations.push_back(ShadowViewMutation::UpdateMutation(
293
+ makePlainView(13, 0, 100, 400, 100), makePlainView(13, 0, 200, 400, 100), kSurfaceId));
294
+ applyTo(manager, std::move(mutations));
295
+ }
296
+
297
+ double offsetOf(basalt::AppKitMountingManager &manager, Tag tag) {
298
+ return manager.viewForTag(tag).rnScrollOffset.y;
299
+ }
300
+
301
+ facebook::react::ScrollViewMaintainVisibleContentPosition maintainFrom(int minIndex) {
302
+ facebook::react::ScrollViewMaintainVisibleContentPosition maintain;
303
+ maintain.minIndexForVisible = minIndex;
304
+ return maintain;
305
+ }
306
+
307
+ ShadowView listScroller(
308
+ std::optional<facebook::react::ScrollViewMaintainVisibleContentPosition> maintain) {
309
+ return makeScrollView(10,
310
+ 1000,
311
+ RnPoint{0, 0},
312
+ false,
313
+ facebook::react::ScrollViewIndicatorStyle::Default,
314
+ maintain);
315
+ }
316
+
317
+ } // namespace
318
+
319
+ TEST(appkit_scrollview_maintain_visible_content_position_holds_the_content_still) {
320
+ @autoreleasepool {
321
+ basalt::AppKitMountingManager manager;
322
+ RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
323
+ [root setRnFrameX:0 y:0 width:400 height:300];
324
+
325
+ mountList(manager, listScroller(maintainFrom(0)));
326
+
327
+ // Scrolled to 150: the first row is entirely above the viewport and the
328
+ // second is the first with anything on screen, so the second is held still.
329
+ manager.applyCommand(10, "scrollTo", folly::dynamic::array(0, 150, false));
330
+ EXPECT_NEAR(offsetOf(manager, 10), 150.0, 0.001);
331
+
332
+ growTheFirstRow(manager);
333
+ EXPECT_NEAR(offsetOf(manager, 10), 250.0, 0.001);
334
+
335
+ manager.destroySurfaceRoot(kSurfaceId);
336
+ }
337
+ }
338
+
339
+ TEST(appkit_scrollview_a_list_that_did_not_ask_lets_the_content_jump) {
340
+ @autoreleasepool {
341
+ basalt::AppKitMountingManager manager;
342
+ RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
343
+ [root setRnFrameX:0 y:0 width:400 height:300];
344
+
345
+ mountList(manager, listScroller(std::nullopt));
346
+ manager.applyCommand(10, "scrollTo", folly::dynamic::array(0, 150, false));
347
+ growTheFirstRow(manager);
348
+
349
+ EXPECT_NEAR(offsetOf(manager, 10), 150.0, 0.001);
350
+
351
+ manager.destroySurfaceRoot(kSurfaceId);
352
+ }
353
+ }
354
+
355
+ TEST(appkit_scrollview_min_index_for_visible_skips_a_header) {
356
+ @autoreleasepool {
357
+ // At the top of the list the first row is the visible one, and an index of
358
+ // 1 takes it out of the running -- so the second row is watched, and that
359
+ // one moves.
360
+ basalt::AppKitMountingManager manager;
361
+ RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
362
+ [root setRnFrameX:0 y:0 width:400 height:300];
363
+
364
+ mountList(manager, listScroller(maintainFrom(1)));
365
+ EXPECT_NEAR(offsetOf(manager, 10), 0.0, 0.001);
366
+ growTheFirstRow(manager);
367
+ EXPECT_NEAR(offsetOf(manager, 10), 100.0, 0.001);
368
+
369
+ manager.destroySurfaceRoot(kSurfaceId);
370
+ }
371
+ }