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,852 @@
1
+ // Tests for hit testing and the touch state machine.
2
+ //
3
+ // Hit testing is the part of input most likely to be quietly wrong, and it is a
4
+ // pure function of the view tree, so it can be tested without a mouse, a window
5
+ // server, or permission to synthesise an event. That last one matters:
6
+ // synthesising a real click on macOS means CGEvent, which needs accessibility
7
+ // permission an automated run does not have, so everything below AppKit's event
8
+ // delivery is tested here instead.
9
+ //
10
+ // Unlike the GTK equivalent, none of this needs a window. `gtk_widget_pick`
11
+ // skips unmapped widgets, so those tests have to show a window and pump the
12
+ // frame clock; `RnAppKitHitTest` walks frames and is answerable straight away.
13
+
14
+ #include "TestHarness.h"
15
+
16
+ #include "PointerButtons.h"
17
+
18
+ #import "AppKitFocus.h"
19
+ #import "AppKitTouchDispatcher.h"
20
+ #import "RnAppKitView.h"
21
+
22
+ #include <react/renderer/components/view/ViewProps.h>
23
+ #include <react/renderer/graphics/Color.h>
24
+
25
+ #include <cstdint>
26
+ #include <sstream>
27
+
28
+ using facebook::react::LayoutMetrics;
29
+ using facebook::react::MountingTransaction;
30
+ using facebook::react::ShadowView;
31
+ using facebook::react::ShadowViewMutation;
32
+ using facebook::react::ShadowViewMutationList;
33
+ using facebook::react::SurfaceId;
34
+ using facebook::react::Tag;
35
+ using facebook::react::TransactionTelemetry;
36
+ using facebook::react::ViewProps;
37
+
38
+ namespace {
39
+
40
+ constexpr SurfaceId kSurfaceId = 1;
41
+
42
+ RnAppKitView *box(NSInteger tag, CGFloat x, CGFloat y, CGFloat w, CGFloat h) {
43
+ RnAppKitView *view = [RnAppKitView viewWithTag:tag];
44
+ [view setRnFrameX:x y:y width:w height:h];
45
+ return view;
46
+ }
47
+
48
+ NSInteger hit(RnAppKitView *root, CGFloat x, CGFloat y) {
49
+ RnAppKitView *view = RnAppKitHitTest(root, x, y);
50
+ return view == nil ? 0 : view.rnTag;
51
+ }
52
+
53
+ ShadowView makeView(Tag tag, float x, float y, float width, float height) {
54
+ auto props = std::make_shared<ViewProps>();
55
+ LayoutMetrics metrics;
56
+ metrics.frame = {.origin = {.x = x, .y = y}, .size = {.width = width, .height = height}};
57
+
58
+ ShadowView view;
59
+ view.componentName = "View";
60
+ view.surfaceId = kSurfaceId;
61
+ view.tag = tag;
62
+ view.props = props;
63
+ view.layoutMetrics = metrics;
64
+ return view;
65
+ }
66
+
67
+ void apply(basalt::AppKitMountingManager &manager, ShadowViewMutationList &&mutations) {
68
+ manager.applyTransaction(
69
+ kSurfaceId, MountingTransaction(kSurfaceId, 1, std::move(mutations), TransactionTelemetry{}));
70
+ }
71
+
72
+ } // namespace
73
+
74
+ TEST(hit_test_finds_the_view_at_a_point) {
75
+ @autoreleasepool {
76
+ RnAppKitView *root = box(1, 0, 0, 200, 200);
77
+ RnAppKitView *child = box(10, 20, 30, 50, 40);
78
+ [root insertRnChild:child atIndex:0];
79
+
80
+ EXPECT_EQ((long)hit(root, 25, 35), 10L);
81
+ // Inside the root, outside the child.
82
+ EXPECT_EQ((long)hit(root, 5, 5), 1L);
83
+ // Outside everything.
84
+ EXPECT_EQ((long)hit(root, 500, 500), 0L);
85
+ }
86
+ }
87
+
88
+ // The point is top-left relative, like every other coordinate here. An
89
+ // unflipped hit test would find the child 30 from the *bottom* instead, which
90
+ // is the same bug `isFlipped` exists to prevent and would be invisible in a
91
+ // vertically symmetric layout.
92
+ TEST(hit_test_uses_top_left_coordinates) {
93
+ @autoreleasepool {
94
+ RnAppKitView *root = box(1, 0, 0, 200, 200);
95
+ RnAppKitView *top = box(10, 0, 0, 200, 20);
96
+ [root insertRnChild:top atIndex:0];
97
+
98
+ EXPECT_EQ((long)hit(root, 100, 10), 10L);
99
+ EXPECT_EQ((long)hit(root, 100, 190), 1L);
100
+ }
101
+ }
102
+
103
+ // A transformed view is hit where it is *drawn*, not where its frame is.
104
+ //
105
+ // The two are the same for every untransformed view, which is why subtracting
106
+ // the frame origin passed every test here for as long as it did. A translation
107
+ // is the cheapest case that tells them apart: the frame does not move, the
108
+ // drawing does, and a press has to follow the drawing.
109
+ TEST(hit_test_follows_a_translated_view) {
110
+ @autoreleasepool {
111
+ RnAppKitView *root = box(1, 0, 0, 200, 200);
112
+ RnAppKitView *child = box(10, 0, 0, 40, 40);
113
+ // matrix3d, CSS order: identity with a 100pt shift right and 50 down.
114
+ const float moved[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 100, 50, 0, 1};
115
+ [child setRnTransform:moved];
116
+ [root insertRnChild:child atIndex:0];
117
+
118
+ // Where it is drawn.
119
+ EXPECT_EQ((long)hit(root, 120, 70), 10L);
120
+ // Where its frame is, and where it is no longer drawn.
121
+ EXPECT_EQ((long)hit(root, 20, 20), 1L);
122
+ }
123
+ }
124
+
125
+ // A rotation, which is the case that cannot be got right by adjusting an
126
+ // offset: the corners of the drawn box are outside its frame and its centre is
127
+ // in the same place.
128
+ TEST(hit_test_follows_a_rotated_view) {
129
+ @autoreleasepool {
130
+ RnAppKitView *root = box(1, 0, 0, 200, 200);
131
+ RnAppKitView *child = box(10, 50, 50, 100, 20);
132
+ // 90 degrees. A box 100 wide and 20 tall becomes 20 wide and 100 tall,
133
+ // about its own centre at (100, 60).
134
+ const float quarterTurn[16] = {0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
135
+ [child setRnTransform:quarterTurn];
136
+ [root insertRnChild:child atIndex:0];
137
+
138
+ // The centre does not move, so it is still the child.
139
+ EXPECT_EQ((long)hit(root, 100, 60), 10L);
140
+ // Above and below the centre: inside the turned box, outside the frame.
141
+ EXPECT_EQ((long)hit(root, 100, 20), 10L);
142
+ EXPECT_EQ((long)hit(root, 100, 100), 10L);
143
+ // Left and right of the centre: inside the frame, outside the turned box.
144
+ EXPECT_EQ((long)hit(root, 60, 60), 1L);
145
+ EXPECT_EQ((long)hit(root, 140, 60), 1L);
146
+ }
147
+ }
148
+
149
+ // `scale: 0` is legal, draws nothing, and has no inverse. Skipped rather than
150
+ // inverted, and the press carries on to what is behind it.
151
+ TEST(hit_test_skips_a_view_scaled_to_nothing) {
152
+ @autoreleasepool {
153
+ RnAppKitView *root = box(1, 0, 0, 200, 200);
154
+ RnAppKitView *child = box(10, 0, 0, 100, 100);
155
+ const float flattened[16] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
156
+ [child setRnTransform:flattened];
157
+ [root insertRnChild:child atIndex:0];
158
+
159
+ EXPECT_EQ((long)hit(root, 50, 50), 1L);
160
+ }
161
+ }
162
+
163
+ // --------------------------------------------------------------------------
164
+ // offsetPoint
165
+ // --------------------------------------------------------------------------
166
+ //
167
+ // `Touch::offsetPoint` is where inside the target a press landed, and what
168
+ // `locationX`/`locationY` are built from. It carried the *page* point until
169
+ // now, which is right only for a view at the surface's origin.
170
+ //
171
+ // No unit test can read a touch event here -- see docs/backlog/testing.md --
172
+ // so what is tested is the walk the dispatcher uses, which is the whole of the
173
+ // arithmetic.
174
+
175
+ TEST(page_to_local_subtracts_the_ancestors) {
176
+ @autoreleasepool {
177
+ RnAppKitView *root = box(1, 0, 0, 400, 400);
178
+ RnAppKitView *middle = box(10, 50, 60, 300, 300);
179
+ RnAppKitView *leaf = box(20, 20, 30, 100, 100);
180
+ [root insertRnChild:middle atIndex:0];
181
+ [middle insertRnChild:leaf atIndex:0];
182
+
183
+ NSPoint local = NSZeroPoint;
184
+ EXPECT([leaf rnPageToLocal:NSMakePoint(100, 120) fromRoot:root into:&local]);
185
+ // 100 - 50 - 20, and 120 - 60 - 30.
186
+ EXPECT_NEAR(local.x, 30.0, 0.001);
187
+ EXPECT_NEAR(local.y, 30.0, 0.001);
188
+ }
189
+ }
190
+
191
+ // The root's own point is the page point, which is the case that made the old
192
+ // behaviour look right for as long as it did.
193
+ TEST(page_to_local_at_the_root_is_the_page_point) {
194
+ @autoreleasepool {
195
+ RnAppKitView *root = box(1, 0, 0, 400, 400);
196
+ NSPoint local = NSZeroPoint;
197
+ EXPECT([root rnPageToLocal:NSMakePoint(100, 120) fromRoot:root into:&local]);
198
+ EXPECT_NEAR(local.x, 100.0, 0.001);
199
+ EXPECT_NEAR(local.y, 120.0, 0.001);
200
+ }
201
+ }
202
+
203
+ TEST(page_to_local_follows_a_transformed_ancestor) {
204
+ @autoreleasepool {
205
+ RnAppKitView *root = box(1, 0, 0, 400, 400);
206
+ RnAppKitView *moved = box(10, 0, 0, 100, 100);
207
+ // Shifted 100 right and 50 down by a transform rather than by its frame.
208
+ const float translate[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 100, 50, 0, 1};
209
+ [moved setRnTransform:translate];
210
+ [root insertRnChild:moved atIndex:0];
211
+
212
+ NSPoint local = NSZeroPoint;
213
+ EXPECT([moved rnPageToLocal:NSMakePoint(110, 60) fromRoot:root into:&local]);
214
+ // The press is 10 into the view as drawn, not 110.
215
+ EXPECT_NEAR(local.x, 10.0, 0.001);
216
+ EXPECT_NEAR(local.y, 10.0, 0.001);
217
+ }
218
+ }
219
+
220
+ // A scrolled ancestor moves its children, and the offset has to come back out
221
+ // -- the same offset the hit test adds on the way down.
222
+ TEST(page_to_local_follows_a_scrolled_ancestor) {
223
+ @autoreleasepool {
224
+ RnAppKitView *root = box(1, 0, 0, 400, 400);
225
+ RnAppKitView *scroller = box(10, 0, 0, 200, 200);
226
+ RnAppKitView *row = box(20, 0, 300, 200, 50);
227
+ [root insertRnChild:scroller atIndex:0];
228
+ [scroller insertRnChild:row atIndex:0];
229
+ [scroller setRnScrollOffsetX:0 y:280];
230
+
231
+ NSPoint local = NSZeroPoint;
232
+ // The row starts at 300 in content space and the view is scrolled 280, so
233
+ // it is drawn 20 down -- and a press there is at the row's own top.
234
+ EXPECT([row rnPageToLocal:NSMakePoint(10, 20) fromRoot:root into:&local]);
235
+ EXPECT_NEAR(local.x, 10.0, 0.001);
236
+ EXPECT_NEAR(local.y, 0.0, 0.001);
237
+ }
238
+ }
239
+
240
+ TEST(page_to_local_refuses_a_chain_with_no_inverse) {
241
+ @autoreleasepool {
242
+ RnAppKitView *root = box(1, 0, 0, 400, 400);
243
+ RnAppKitView *flat = box(10, 0, 0, 100, 100);
244
+ const float flattened[16] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
245
+ [flat setRnTransform:flattened];
246
+ [root insertRnChild:flat atIndex:0];
247
+
248
+ NSPoint local = NSMakePoint(-1, -1);
249
+ EXPECT(![flat rnPageToLocal:NSMakePoint(10, 10) fromRoot:root into:&local]);
250
+ // Untouched, so the caller's fallback is what is used.
251
+ EXPECT_NEAR(local.x, -1.0, 0.001);
252
+ }
253
+ }
254
+
255
+ // `backfaceVisibility: 'hidden'` takes the view out of hit testing as well as
256
+ // out of the picture -- the back of a card is not clickable. The hit test
257
+ // already skips hidden views, so what this checks is that turning away is what
258
+ // hides it.
259
+ TEST(hit_test_skips_a_view_turned_away) {
260
+ @autoreleasepool {
261
+ RnAppKitView *root = box(1, 0, 0, 200, 200);
262
+ RnAppKitView *card = box(10, 0, 0, 100, 100);
263
+ [card setRnHidesBackFace:YES];
264
+ [root insertRnChild:card atIndex:0];
265
+
266
+ // Facing the viewer: it is hit.
267
+ EXPECT_EQ((long)hit(root, 50, 50), 10L);
268
+
269
+ // Half a turn about Y, which mirrors it.
270
+ const float flipped[16] = {-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1};
271
+ [card setRnTransform:flipped];
272
+ EXPECT_EQ((long)hit(root, 50, 50), 1L);
273
+
274
+ // And back again.
275
+ [card setRnTransform:nullptr];
276
+ EXPECT_EQ((long)hit(root, 50, 50), 10L);
277
+ }
278
+ }
279
+
280
+ // Without the prop a turned view is still drawn and still clickable, which is
281
+ // what `backfaceVisibility: 'visible'` means and is the default.
282
+ TEST(hit_test_keeps_a_turned_view_that_did_not_ask_to_hide) {
283
+ @autoreleasepool {
284
+ RnAppKitView *root = box(1, 0, 0, 200, 200);
285
+ RnAppKitView *card = box(10, 0, 0, 100, 100);
286
+ [root insertRnChild:card atIndex:0];
287
+
288
+ const float flipped[16] = {-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1};
289
+ [card setRnTransform:flipped];
290
+ EXPECT_EQ((long)hit(root, 50, 50), 10L);
291
+ }
292
+ }
293
+
294
+ // The two reasons a view can be hidden are independent, and Fabric applies
295
+ // them through different calls: props first, then layout metrics. So a card
296
+ // turned away from the viewer had `setRnHidden:NO` written over the top of it
297
+ // on the very same mount, and came back -- which is how this looked in the
298
+ // demo long after the unit tests above were passing.
299
+ TEST(display_none_does_not_reveal_a_back_face) {
300
+ @autoreleasepool {
301
+ RnAppKitView *root = box(1, 0, 0, 200, 200);
302
+ RnAppKitView *card = box(10, 0, 0, 100, 100);
303
+ [card setRnHidesBackFace:YES];
304
+ [root insertRnChild:card atIndex:0];
305
+
306
+ const float flipped[16] = {-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1};
307
+ [card setRnTransform:flipped];
308
+ EXPECT_EQ((long)hit(root, 50, 50), 1L);
309
+
310
+ // What applyLayoutMetrics says about every view that is not display: none.
311
+ [card setRnHidden:NO];
312
+ EXPECT_EQ((long)hit(root, 50, 50), 1L);
313
+ }
314
+ }
315
+
316
+ // And the other way: a back face that is facing the viewer must not reveal a
317
+ // view the app itself hid.
318
+ TEST(a_facing_back_face_does_not_reveal_display_none) {
319
+ @autoreleasepool {
320
+ RnAppKitView *root = box(1, 0, 0, 200, 200);
321
+ RnAppKitView *card = box(10, 0, 0, 100, 100);
322
+ [card setRnHidden:YES];
323
+ [card setRnHidesBackFace:YES];
324
+ [root insertRnChild:card atIndex:0];
325
+
326
+ EXPECT_EQ((long)hit(root, 50, 50), 1L);
327
+
328
+ const float flipped[16] = {-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1};
329
+ [card setRnTransform:flipped];
330
+ [card setRnTransform:nullptr];
331
+ EXPECT_EQ((long)hit(root, 50, 50), 1L);
332
+ }
333
+ }
334
+
335
+ // Turning the prop off shows a view it had hidden. The early return this used
336
+ // to have left the view hidden for good.
337
+ TEST(clearing_the_back_face_prop_shows_the_view_again) {
338
+ @autoreleasepool {
339
+ RnAppKitView *root = box(1, 0, 0, 200, 200);
340
+ RnAppKitView *card = box(10, 0, 0, 100, 100);
341
+ [card setRnHidesBackFace:YES];
342
+ [root insertRnChild:card atIndex:0];
343
+
344
+ const float flipped[16] = {-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1};
345
+ [card setRnTransform:flipped];
346
+ EXPECT_EQ((long)hit(root, 50, 50), 1L);
347
+
348
+ [card setRnHidesBackFace:NO];
349
+ EXPECT_EQ((long)hit(root, 50, 50), 10L);
350
+ }
351
+ }
352
+
353
+ TEST(hit_test_finds_the_deepest_view) {
354
+ @autoreleasepool {
355
+ RnAppKitView *root = box(1, 0, 0, 200, 200);
356
+ RnAppKitView *outer = box(10, 10, 10, 100, 100);
357
+ RnAppKitView *inner = box(11, 10, 10, 20, 20);
358
+ [root insertRnChild:outer atIndex:0];
359
+ [outer insertRnChild:inner atIndex:0];
360
+
361
+ // Coordinates are parent-relative on the way down: (25,25) in the root is
362
+ // (15,15) in outer, which is (5,5) in inner.
363
+ EXPECT_EQ((long)hit(root, 25, 25), 11L);
364
+ EXPECT_EQ((long)hit(root, 50, 50), 10L);
365
+ }
366
+ }
367
+
368
+ // Later siblings draw on top, so they are hit first. An implementation that
369
+ // walked the subviews array forwards would return whichever overlapping view
370
+ // happened to be added first.
371
+ TEST(hit_test_prefers_the_topmost_sibling) {
372
+ @autoreleasepool {
373
+ RnAppKitView *root = box(1, 0, 0, 200, 200);
374
+ RnAppKitView *under = box(10, 0, 0, 100, 100);
375
+ RnAppKitView *over = box(11, 0, 0, 100, 100);
376
+ [root insertRnChild:under atIndex:0];
377
+ [root insertRnChild:over atIndex:1];
378
+
379
+ EXPECT_EQ((long)hit(root, 50, 50), 11L);
380
+ }
381
+ }
382
+
383
+ // The same question test_win32_hittest.cpp asks, in the same order and with the
384
+ // same tags: a press has to follow the paint order rather than the child list,
385
+ // or a view drawn behind another still swallows its clicks.
386
+ TEST(hit_test_follows_z_index) {
387
+ @autoreleasepool {
388
+ RnAppKitView *root = box(1, 0, 0, 400, 400);
389
+ RnAppKitView *under = box(2, 0, 0, 200, 200);
390
+ RnAppKitView *over = box(3, 0, 0, 200, 200);
391
+ [root insertRnChild:under atIndex:0];
392
+ [root insertRnChild:over atIndex:1];
393
+
394
+ [over setRnZIndex:-1];
395
+ EXPECT_EQ((long)hit(root, 100, 100), 2L);
396
+ }
397
+ }
398
+
399
+ TEST(z_index_reorders_painting_and_not_the_child_list) {
400
+ @autoreleasepool {
401
+ RnAppKitView *root = box(1, 0, 0, 200, 200);
402
+ RnAppKitView *first = box(30, 0, 0, 10, 10);
403
+ RnAppKitView *second = box(31, 0, 0, 10, 10);
404
+ [root insertRnChild:first atIndex:0];
405
+ [root insertRnChild:second atIndex:1];
406
+
407
+ [first setRnZIndex:10];
408
+ [second setRnZIndex:1];
409
+
410
+ // Fabric indexes into the child list on every Insert and Remove, so that
411
+ // list must not be resorted -- only what paints on top of what.
412
+ EXPECT_EQ((long)((RnAppKitView *)root.subviews.firstObject).rnTag, 30L);
413
+ EXPECT_EQ((long)((RnAppKitView *)root.subviews.lastObject).rnTag, 31L);
414
+
415
+ NSArray<RnAppKitView *> *painted = [root rnChildrenInPaintOrder];
416
+ EXPECT_EQ((long)painted.firstObject.rnTag, 31L);
417
+ EXPECT_EQ((long)painted.lastObject.rnTag, 30L);
418
+ }
419
+ }
420
+
421
+ // Equal values keep document order, which is what CSS and React Native both
422
+ // promise and what a non-stable sort would quietly break.
423
+ TEST(z_index_ties_keep_document_order) {
424
+ @autoreleasepool {
425
+ RnAppKitView *root = box(1, 0, 0, 200, 200);
426
+ for (NSInteger tag = 40; tag < 44; tag++) {
427
+ RnAppKitView *child = box(tag, 0, 0, 10, 10);
428
+ [child setRnZIndex:(tag == 43 ? 5 : 2)];
429
+ [root insertRnChild:child atIndex:tag - 40];
430
+ }
431
+ NSArray<RnAppKitView *> *painted = [root rnChildrenInPaintOrder];
432
+ EXPECT_EQ((long)painted[0].rnTag, 40L);
433
+ EXPECT_EQ((long)painted[1].rnTag, 41L);
434
+ EXPECT_EQ((long)painted[2].rnTag, 42L);
435
+ EXPECT_EQ((long)painted[3].rnTag, 43L);
436
+ }
437
+ }
438
+
439
+ // display: 'none' takes a view out of layout and painting, and it has to come
440
+ // out of hit testing too -- otherwise an invisible view swallows presses meant
441
+ // for whatever is behind it.
442
+ TEST(hit_test_skips_hidden_views) {
443
+ @autoreleasepool {
444
+ RnAppKitView *root = box(1, 0, 0, 200, 200);
445
+ RnAppKitView *child = box(10, 0, 0, 100, 100);
446
+ child.hidden = YES;
447
+ [root insertRnChild:child atIndex:0];
448
+
449
+ EXPECT_EQ((long)hit(root, 50, 50), 1L);
450
+ }
451
+ }
452
+
453
+ // Hit testing has to follow the scroll, and it does so without knowing what a
454
+ // ScrollView is: the point is translated into bounds space, and a scrolled view
455
+ // has a non-zero bounds origin. Getting this wrong means a list that scrolls
456
+ // correctly and whose rows respond to presses meant for the row above.
457
+ TEST(hit_test_follows_the_scroll_offset) {
458
+ @autoreleasepool {
459
+ RnAppKitView *scroller = box(1, 0, 0, 200, 100);
460
+ RnAppKitView *content = box(2, 0, 0, 200, 600);
461
+ RnAppKitView *lower = box(10, 0, 200, 200, 50);
462
+ [scroller insertRnChild:content atIndex:0];
463
+ [content insertRnChild:lower atIndex:0];
464
+
465
+ // Unscrolled, the row at y=200 is below the 100-tall viewport.
466
+ EXPECT_EQ((long)hit(scroller, 100, 50), 2L);
467
+
468
+ [scroller setRnScrollOffsetX:0 y:200];
469
+
470
+ // Scrolled to it, the same screen point is now that row.
471
+ EXPECT_EQ((long)hit(scroller, 100, 10), 10L);
472
+ // And a point past its bottom is the content again.
473
+ EXPECT_EQ((long)hit(scroller, 100, 80), 2L);
474
+ }
475
+ }
476
+
477
+ TEST(hit_test_survives_a_nil_root) {
478
+ @autoreleasepool {
479
+ EXPECT(RnAppKitHitTest(nil, 0, 0) == nil);
480
+ }
481
+ }
482
+
483
+ // The dispatcher against a real mounted tree: a tap has to find the tag the
484
+ // mounting manager registered, not just some view.
485
+ TEST(a_synthesised_tap_finds_the_mounted_view) {
486
+ @autoreleasepool {
487
+ basalt::AppKitMountingManager manager;
488
+ RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
489
+ [root setRnFrameX:0 y:0 width:400 height:300];
490
+
491
+ ShadowViewMutationList mutations;
492
+ mutations.push_back(ShadowViewMutation::CreateMutation(makeView(10, 40, 50, 120, 60)));
493
+ mutations.push_back(
494
+ ShadowViewMutation::InsertMutation(kSurfaceId, makeView(10, 40, 50, 120, 60), 0));
495
+ apply(manager, std::move(mutations));
496
+
497
+ EXPECT_EQ((long)basalt::hitTestTag(root, 50, 60), 10L);
498
+ EXPECT_EQ((long)basalt::hitTestTag(root, 5, 5), (long)kSurfaceId);
499
+
500
+ // No emitter is attached to these hand-built shadow views, so the tap has
501
+ // nowhere to deliver. It must not crash: that is exactly the shape of a
502
+ // press arriving between a Remove and its Delete.
503
+ basalt::AppKitTouchDispatcher dispatcher(&manager, root);
504
+ dispatcher.synthesiseTap(50, 60);
505
+ dispatcher.synthesiseTap(5000, 5000);
506
+
507
+ manager.destroySurfaceRoot(kSurfaceId);
508
+ }
509
+ }
510
+
511
+ // A move with no button down is hover, and the touch model has no place for it.
512
+ // Reporting it would look to the responder system like a finger dragging across
513
+ // the screen at all times, which cancels every press before it can fire.
514
+ TEST(a_move_before_a_press_is_ignored) {
515
+ @autoreleasepool {
516
+ basalt::AppKitMountingManager manager;
517
+ RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
518
+ [root setRnFrameX:0 y:0 width:400 height:300];
519
+
520
+ basalt::AppKitTouchDispatcher dispatcher(&manager, root);
521
+ // Nothing to assert beyond "does not crash and does not start a gesture";
522
+ // the state it must not enter is private, so this pins the behaviour that
523
+ // an end with no start is also a no-op.
524
+ dispatcher.dispatchTouchMove(10, 10);
525
+ dispatcher.dispatchTouchEnd(10, 10, basalt::PointerButton::Primary);
526
+ dispatcher.dispatchTouchCancel();
527
+
528
+ manager.destroySurfaceRoot(kSurfaceId);
529
+ }
530
+ }
531
+
532
+ // The root forwards mouse events to whatever handler it was given, and a view
533
+ // with no handler of its own walks up to find one. Without that, a press on a
534
+ // child would reach nothing.
535
+ TEST(a_child_forwards_the_mouse_to_the_root_handler) {
536
+ @autoreleasepool {
537
+ basalt::AppKitMountingManager manager;
538
+ RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
539
+ [root setRnFrameX:0 y:0 width:400 height:300];
540
+ RnAppKitView *child = box(10, 0, 0, 100, 100);
541
+ [root insertRnChild:child atIndex:0];
542
+
543
+ EXPECT(root.rnInputHandler == nil);
544
+ // Its own pool: a weak reference is zeroed in dealloc, and reading one
545
+ // retains and autoreleases the result, so the assertion after the scope
546
+ // only means anything once the pool that saw those has drained.
547
+ @autoreleasepool {
548
+ basalt::AppKitTouchDispatcher dispatcher(&manager, root);
549
+ EXPECT(root.rnInputHandler != nil);
550
+ // The child has none of its own and is expected to find the root's.
551
+ EXPECT(child.rnInputHandler == nil);
552
+ }
553
+ // The dispatcher is gone; the root's reference is weak, so it went with it
554
+ // rather than leaving a dangling handler behind.
555
+ EXPECT(root.rnInputHandler == nil);
556
+
557
+ manager.destroySurfaceRoot(kSurfaceId);
558
+ }
559
+ }
560
+
561
+ // Hover is fed from the same hit test as touches, and differs in one place
562
+ // that matters: it only runs for views that asked for it. The mask comes off
563
+ // the props ReactCommon parsed, on every Create and Update, which is what makes
564
+ // a cursor crossing an app that uses no hover cost nothing.
565
+ TEST(a_hover_listener_is_remembered_from_the_props) {
566
+ @autoreleasepool {
567
+ basalt::AppKitMountingManager manager;
568
+ RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
569
+ [root setRnFrameX:0 y:0 width:400 height:300];
570
+
571
+ ShadowView listening = makeView(10, 40, 50, 120, 60);
572
+ auto props = std::make_shared<ViewProps>();
573
+ props->events[facebook::react::ViewEvents::Offset::PointerEnter] = true;
574
+ listening.props = props;
575
+
576
+ ShadowViewMutationList mutations;
577
+ mutations.push_back(ShadowViewMutation::CreateMutation(listening));
578
+ mutations.push_back(ShadowViewMutation::InsertMutation(kSurfaceId, listening, 0));
579
+ apply(manager, std::move(mutations));
580
+
581
+ EXPECT_EQ(manager.hoverListenersForTag(10), (std::uint16_t)basalt::HoverListenerEnter);
582
+ // A view that never asked is not stored at all.
583
+ EXPECT_EQ(manager.hoverListenersForTag(kSurfaceId), (std::uint16_t)basalt::HoverListenerNone);
584
+
585
+ // An update can take the listener away again, and leaving the old mask
586
+ // behind would mean dispatching to a view that stopped listening.
587
+ ShadowViewMutationList updates;
588
+ updates.push_back(ShadowViewMutation::UpdateMutation(
589
+ listening, makeView(10, 40, 50, 120, 60), kSurfaceId));
590
+ apply(manager, std::move(updates));
591
+ EXPECT_EQ(manager.hoverListenersForTag(10), (std::uint16_t)basalt::HoverListenerNone);
592
+
593
+ manager.destroySurfaceRoot(kSurfaceId);
594
+ }
595
+ }
596
+
597
+ // The same shape as the synthesised tap: no emitter is attached to these
598
+ // hand-built shadow views, so every hover has nowhere to deliver and must not
599
+ // crash -- which is also what a cursor moving during a surface teardown looks
600
+ // like from here.
601
+ TEST(a_synthesised_hover_walks_the_tree_without_an_emitter) {
602
+ @autoreleasepool {
603
+ basalt::AppKitMountingManager manager;
604
+ RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
605
+ [root setRnFrameX:0 y:0 width:400 height:300];
606
+
607
+ ShadowViewMutationList mutations;
608
+ mutations.push_back(ShadowViewMutation::CreateMutation(makeView(10, 40, 50, 120, 60)));
609
+ mutations.push_back(
610
+ ShadowViewMutation::InsertMutation(kSurfaceId, makeView(10, 40, 50, 120, 60), 0));
611
+ apply(manager, std::move(mutations));
612
+
613
+ basalt::AppKitTouchDispatcher dispatcher(&manager, root);
614
+ dispatcher.synthesiseHover(50, 60);
615
+ dispatcher.synthesiseHover(5, 5);
616
+ // Negative means the cursor left the surface.
617
+ dispatcher.synthesiseHover(-1, -1);
618
+
619
+ manager.destroySurfaceRoot(kSurfaceId);
620
+ }
621
+ }
622
+
623
+ // `pointerEvents`, which decides what a press can land on rather than what is
624
+ // drawn. Four values, and the two in the middle are the ones worth testing:
625
+ // `none` and `auto` are obvious, `box-none` and `box-only` are the pair
626
+ // everyone gets the wrong way round.
627
+ TEST(pointer_events_none_passes_a_press_through_to_what_is_behind) {
628
+ @autoreleasepool {
629
+ RnAppKitView *root = box(1, 0, 0, 400, 300);
630
+ RnAppKitView *behind = box(10, 0, 0, 200, 200);
631
+ RnAppKitView *over = box(11, 0, 0, 200, 200);
632
+ [root insertRnChild:behind atIndex:0];
633
+ [root insertRnChild:over atIndex:1];
634
+
635
+ // Painted last, so it is on top and takes the press.
636
+ EXPECT_EQ(hit(root, 50, 50), 11L);
637
+
638
+ over.rnPointerEvents = RnAppKitPointerEventsNone;
639
+ EXPECT_EQ(hit(root, 50, 50), 10L);
640
+ }
641
+ }
642
+
643
+ TEST(pointer_events_none_takes_the_children_with_it) {
644
+ @autoreleasepool {
645
+ RnAppKitView *root = box(1, 0, 0, 400, 300);
646
+ RnAppKitView *over = box(11, 0, 0, 200, 200);
647
+ RnAppKitView *inside = box(12, 0, 0, 100, 100);
648
+ [over insertRnChild:inside atIndex:0];
649
+ [root insertRnChild:over atIndex:0];
650
+
651
+ EXPECT_EQ(hit(root, 50, 50), 12L);
652
+ // The whole subtree leaves hit testing, not just the view the prop is on.
653
+ over.rnPointerEvents = RnAppKitPointerEventsNone;
654
+ EXPECT_EQ(hit(root, 50, 50), 1L);
655
+ }
656
+ }
657
+
658
+ TEST(pointer_events_box_none_is_transparent_and_its_children_are_not) {
659
+ @autoreleasepool {
660
+ RnAppKitView *root = box(1, 0, 0, 400, 300);
661
+ RnAppKitView *behind = box(10, 0, 0, 300, 300);
662
+ RnAppKitView *overlay = box(11, 0, 0, 300, 300);
663
+ RnAppKitView *button = box(12, 0, 0, 100, 100);
664
+ [overlay insertRnChild:button atIndex:0];
665
+ [root insertRnChild:behind atIndex:0];
666
+ [root insertRnChild:overlay atIndex:1];
667
+
668
+ overlay.rnPointerEvents = RnAppKitPointerEventsBoxNone;
669
+ // Over the button: the overlay's child is still a target.
670
+ EXPECT_EQ(hit(root, 50, 50), 12L);
671
+ // Over the overlay and nothing inside it: the press belongs to the view
672
+ // *behind*, not to the overlay's parent. This is the whole reason the mode
673
+ // exists -- an absolutely-positioned layer that does not block what it
674
+ // covers -- and returning the parent here would look right until something
675
+ // was underneath.
676
+ EXPECT_EQ(hit(root, 200, 200), 10L);
677
+ }
678
+ }
679
+
680
+ TEST(pointer_events_box_only_swallows_presses_meant_for_its_children) {
681
+ @autoreleasepool {
682
+ RnAppKitView *root = box(1, 0, 0, 400, 300);
683
+ RnAppKitView *panel = box(11, 0, 0, 300, 300);
684
+ RnAppKitView *button = box(12, 0, 0, 100, 100);
685
+ [panel insertRnChild:button atIndex:0];
686
+ [root insertRnChild:panel atIndex:0];
687
+
688
+ EXPECT_EQ(hit(root, 50, 50), 12L);
689
+ panel.rnPointerEvents = RnAppKitPointerEventsBoxOnly;
690
+ // The press lands on the panel even though it is over the button, which is
691
+ // what makes a disabled panel disable everything in it.
692
+ EXPECT_EQ(hit(root, 50, 50), 11L);
693
+ EXPECT_EQ(hit(root, 200, 200), 11L);
694
+ }
695
+ }
696
+
697
+ TEST(pointer_events_reaches_the_view_from_the_props_and_the_tree_dump) {
698
+ @autoreleasepool {
699
+ basalt::AppKitMountingManager manager;
700
+ RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
701
+ [root setRnFrameX:0 y:0 width:400 height:300];
702
+
703
+ ShadowView blocked = makeView(10, 0, 0, 100, 100);
704
+ auto props = std::make_shared<ViewProps>();
705
+ props->pointerEvents = facebook::react::PointerEventsMode::BoxNone;
706
+ blocked.props = props;
707
+
708
+ ShadowViewMutationList mutations;
709
+ mutations.push_back(ShadowViewMutation::CreateMutation(blocked));
710
+ mutations.push_back(ShadowViewMutation::InsertMutation(kSurfaceId, blocked, 0));
711
+ apply(manager, std::move(mutations));
712
+
713
+ EXPECT_EQ((long)manager.viewForTag(10).rnPointerEvents,
714
+ (long)RnAppKitPointerEventsBoxNone);
715
+ // In the dump, because it is invisible otherwise: a view with this prop is
716
+ // drawn exactly like one without, so the cross-host diff can only see the
717
+ // prop arrived if each host prints it.
718
+ NSString *tree = [root describeTree];
719
+ EXPECT([tree containsString:@"pe=box-none"]);
720
+
721
+ manager.destroySurfaceRoot(kSurfaceId);
722
+ }
723
+ }
724
+
725
+ // Keyboard focus: what Tab stops on, in what order, and what Enter does.
726
+ //
727
+ // A window, because focus is the one part of input that needs one: AppKit's
728
+ // first responder belongs to a window, and `makeFirstResponder:` has nowhere to
729
+ // put it without one. The window is never ordered in front -- these run in an
730
+ // automated session that has no front.
731
+ namespace {
732
+
733
+ ShadowView makeAccessibleView(Tag tag, float y, bool accessible) {
734
+ auto props = std::make_shared<facebook::react::ViewProps>();
735
+ props->accessible = accessible;
736
+
737
+ LayoutMetrics metrics;
738
+ metrics.frame = {.origin = {.x = 0, .y = y}, .size = {.width = 200, .height = 40}};
739
+
740
+ ShadowView view;
741
+ view.componentName = "View";
742
+ view.surfaceId = kSurfaceId;
743
+ view.tag = tag;
744
+ view.props = props;
745
+ view.layoutMetrics = metrics;
746
+ return view;
747
+ }
748
+
749
+ void mountAll(basalt::AppKitMountingManager &manager, std::initializer_list<ShadowView> views) {
750
+ ShadowViewMutationList mutations;
751
+ int index = 0;
752
+ for (const ShadowView &view : views) {
753
+ mutations.push_back(ShadowViewMutation::CreateMutation(view));
754
+ mutations.push_back(ShadowViewMutation::InsertMutation(kSurfaceId, view, index++));
755
+ }
756
+ apply(manager, std::move(mutations));
757
+ }
758
+
759
+ } // namespace
760
+
761
+ TEST(focus_accessible_views_are_the_ones_tab_stops_on) {
762
+ @autoreleasepool {
763
+ basalt::AppKitMountingManager manager;
764
+ RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
765
+ [root setRnFrameX:0 y:0 width:400 height:400];
766
+ // React Native's `focusable` prop never reaches this platform, so
767
+ // `accessible` is the signal -- and it is what <Pressable> sets on
768
+ // everything it renders.
769
+ mountAll(manager,
770
+ {makeAccessibleView(10, 0, true),
771
+ makeAccessibleView(11, 50, false),
772
+ makeAccessibleView(12, 100, true)});
773
+
774
+ EXPECT(manager.viewForTag(10).rnFocusable);
775
+ EXPECT(!manager.viewForTag(11).rnFocusable);
776
+ EXPECT(manager.viewForTag(12).rnFocusable);
777
+ // And it shows up in the tree dump, so the cross-host diff can say the prop
778
+ // arrived on all three.
779
+ EXPECT([[root describeTree] containsString:@"focusable"]);
780
+
781
+ manager.destroySurfaceRoot(kSurfaceId);
782
+ }
783
+ }
784
+
785
+ TEST(focus_tab_visits_focusable_views_in_tree_order_and_wraps) {
786
+ @autoreleasepool {
787
+ basalt::AppKitMountingManager manager;
788
+ RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
789
+ [root setRnFrameX:0 y:0 width:400 height:400];
790
+
791
+ NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 400, 400)
792
+ styleMask:NSWindowStyleMaskBorderless
793
+ backing:NSBackingStoreBuffered
794
+ defer:NO];
795
+ window.contentView = root;
796
+
797
+ basalt::AppKitFocusManager focus(&manager, root);
798
+ mountAll(manager,
799
+ {makeAccessibleView(10, 0, true),
800
+ makeAccessibleView(11, 50, false),
801
+ makeAccessibleView(12, 100, true)});
802
+
803
+ // Nothing is focused when a window opens, on either desktop.
804
+ EXPECT_EQ((long)focus.focusedTag(), 0L);
805
+
806
+ EXPECT(focus.moveFocus(true));
807
+ EXPECT_EQ((long)focus.focusedTag(), 10L);
808
+ // 11 is not accessible, so Tab goes past it.
809
+ EXPECT(focus.moveFocus(true));
810
+ EXPECT_EQ((long)focus.focusedTag(), 12L);
811
+ // And round again: a window's Tab order wraps.
812
+ EXPECT(focus.moveFocus(true));
813
+ EXPECT_EQ((long)focus.focusedTag(), 10L);
814
+ // Backwards is the same order in reverse.
815
+ EXPECT(focus.moveFocus(false));
816
+ EXPECT_EQ((long)focus.focusedTag(), 12L);
817
+
818
+ window.contentView = nil;
819
+ manager.destroySurfaceRoot(kSurfaceId);
820
+ }
821
+ }
822
+
823
+ TEST(focus_activating_nothing_is_not_a_click) {
824
+ @autoreleasepool {
825
+ basalt::AppKitMountingManager manager;
826
+ RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
827
+ [root setRnFrameX:0 y:0 width:400 height:400];
828
+
829
+ NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 400, 400)
830
+ styleMask:NSWindowStyleMaskBorderless
831
+ backing:NSBackingStoreBuffered
832
+ defer:NO];
833
+ window.contentView = root;
834
+
835
+ basalt::AppKitFocusManager focus(&manager, root);
836
+ mountAll(manager, {makeAccessibleView(10, 0, true)});
837
+
838
+ // Nothing has focus yet, so there is nothing to activate -- and reporting a
839
+ // press with no target is how a keystroke reaches the wrong view.
840
+ EXPECT(!focus.activateFocused());
841
+
842
+ focus.moveFocus(true);
843
+ // No emitter is attached to these hand-built shadow views, so the click has
844
+ // nowhere to deliver. It must not crash, and the key is still consumed:
845
+ // that is exactly the shape of Enter arriving between a Remove and its
846
+ // Delete.
847
+ EXPECT(focus.activateFocused());
848
+
849
+ window.contentView = nil;
850
+ manager.destroySurfaceRoot(kSurfaceId);
851
+ }
852
+ }