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,748 @@
1
+ // Tests for <TextInput>'s controlled-value loop.
2
+ //
3
+ // The hard part of a text field is not typing. React Native's <TextInput> is a
4
+ // controlled component: JavaScript owns the value, the field reports every
5
+ // change, JavaScript re-renders and pushes the value back down as a prop. If
6
+ // applying that prop looks like the user typing, the two chase each other
7
+ // forever; if a prop that arrives late is applied anyway, a fast typist watches
8
+ // characters reorder themselves.
9
+ //
10
+ // Both of those are state machines rather than AppKit, and both are tested
11
+ // here through real mutations, because they are only wrong in combination.
12
+ //
13
+ // Props are built through React Native's own RawProps parser rather than by
14
+ // assigning fields: TextInputProps' members are const and only reachable that
15
+ // way, which is also how the GTK suite builds them.
16
+
17
+ #include "TestHarness.h"
18
+ #include "EventRecorder.h"
19
+
20
+ #import "AppKitMountingManager.h"
21
+ #import "AppKitTextPeer.h"
22
+ #import "RnAppKitView.h"
23
+
24
+ #include <react/renderer/components/iostextinput/TextInputProps.h>
25
+ #include <react/renderer/components/textinput/TextInputEventEmitter.h>
26
+ #include <react/renderer/core/PropsParserContext.h>
27
+ #include <react/renderer/core/RawProps.h>
28
+ #include <react/renderer/core/RawPropsParser.h>
29
+
30
+ #include <sstream>
31
+
32
+ using facebook::react::ContextContainer;
33
+ using facebook::react::LayoutMetrics;
34
+ using facebook::react::MountingTransaction;
35
+ using facebook::react::PropsParserContext;
36
+ using facebook::react::RawProps;
37
+ using facebook::react::RawPropsParser;
38
+ using facebook::react::ShadowView;
39
+ using facebook::react::ShadowViewMutation;
40
+ using facebook::react::ShadowViewMutationList;
41
+ using facebook::react::SurfaceId;
42
+ using facebook::react::Tag;
43
+ using facebook::react::TextInputEventEmitter;
44
+ using facebook::react::TextInputProps;
45
+ using facebook::react::TransactionTelemetry;
46
+
47
+ namespace {
48
+
49
+ constexpr SurfaceId kSurfaceId = 1;
50
+
51
+ // One parser, prepared once: preparing walks every prop TextInputProps knows,
52
+ // which is not something to redo per test. RawProps has to be parsed before it
53
+ // can be read -- constructing one and handing it straight to a Props
54
+ // constructor trips an assertion inside RawProps rather than producing empty
55
+ // props, which is a better failure than it sounds.
56
+ const RawPropsParser &textInputParser() {
57
+ static const RawPropsParser parser = []() {
58
+ RawPropsParser prepared;
59
+ prepared.prepare<TextInputProps>();
60
+ return prepared;
61
+ }();
62
+ return parser;
63
+ }
64
+
65
+ ShadowView makeTextInput(Tag tag,
66
+ folly::dynamic props,
67
+ facebook::react::SharedEventEmitter emitter = nullptr) {
68
+ static const auto contextContainer = std::make_shared<const ContextContainer>();
69
+ PropsParserContext context{kSurfaceId, *contextContainer};
70
+
71
+ RawProps rawProps{std::move(props)};
72
+ rawProps.parse(textInputParser());
73
+ auto parsed = std::make_shared<const TextInputProps>(context, TextInputProps{}, rawProps);
74
+
75
+ LayoutMetrics metrics;
76
+ metrics.frame = {.origin = {.x = 0, .y = 0}, .size = {.width = 200, .height = 40}};
77
+
78
+ ShadowView view;
79
+ view.componentName = "TextInput";
80
+ view.surfaceId = kSurfaceId;
81
+ view.tag = tag;
82
+ view.props = parsed;
83
+ view.layoutMetrics = metrics;
84
+ // Null for most tests, which is what a hand-built shadow view carries and
85
+ // why nothing here could see an event until EventRecorder.h.
86
+ view.eventEmitter = std::move(emitter);
87
+ return view;
88
+ }
89
+
90
+ void apply(basalt::AppKitMountingManager &manager, ShadowViewMutationList &&mutations) {
91
+ manager.applyTransaction(
92
+ kSurfaceId, MountingTransaction(kSurfaceId, 1, std::move(mutations), TransactionTelemetry{}));
93
+ }
94
+
95
+ // Mounts one TextInput under the surface root and hands back its view.
96
+ RnAppKitView *mountField(basalt::AppKitMountingManager &manager,
97
+ Tag tag,
98
+ folly::dynamic props,
99
+ facebook::react::SharedEventEmitter emitter = nullptr) {
100
+ RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
101
+ [root setRnFrameX:0 y:0 width:400 height:300];
102
+
103
+ ShadowViewMutationList mutations;
104
+ mutations.push_back(ShadowViewMutation::CreateMutation(makeTextInput(tag, props, emitter)));
105
+ mutations.push_back(
106
+ ShadowViewMutation::InsertMutation(kSurfaceId, makeTextInput(tag, props, emitter), 0));
107
+ apply(manager, std::move(mutations));
108
+
109
+ return manager.viewForTag(tag);
110
+ }
111
+
112
+ // Through the seam, because a multiline peer is an NSTextView and asking it
113
+ // for NSTextField's properties does not compile.
114
+ NSView *fieldOf(RnAppKitView *view) {
115
+ return view.rnEditable;
116
+ }
117
+
118
+ // What a person typing does, as far as the manager is concerned: the field's
119
+ // value changes and the delegate is told. AppKit posts that notification from
120
+ // NSControl and there is no field editor here to post it, so the test does what
121
+ // AppKit would -- which is also exactly the path a real keystroke takes.
122
+ void typeInto(NSView *field, NSString *text) {
123
+ RnPeerSetText(field, [RnPeerText(field) stringByAppendingString:text]);
124
+ // The notification a real NSTextField posts. Single line only: this helper
125
+ // stands in for AppKit, and a multiline peer posts NSTextDidChangeNotification
126
+ // through NSTextViewDelegate instead.
127
+ [((NSTextField *)field).delegate
128
+ controlTextDidChange:[NSNotification notificationWithName:NSControlTextDidChangeNotification
129
+ object:field]];
130
+ }
131
+
132
+ // What AppKit posts when editing ends -- the field losing focus, or the user
133
+ // pressing Tab. Single line only, for the same reason typeInto is.
134
+ void endEditing(NSView *field) {
135
+ [((NSTextField *)field).delegate
136
+ controlTextDidEndEditing:[NSNotification
137
+ notificationWithName:NSControlTextDidEndEditingNotification
138
+ object:field]];
139
+ }
140
+
141
+ // One Update mutation for a field, which is what every re-render produces.
142
+ void updateField(basalt::AppKitMountingManager &manager,
143
+ Tag tag,
144
+ folly::dynamic before,
145
+ folly::dynamic after) {
146
+ ShadowViewMutationList mutations;
147
+ mutations.push_back(ShadowViewMutation::UpdateMutation(
148
+ makeTextInput(tag, std::move(before)), makeTextInput(tag, std::move(after)), kSurfaceId));
149
+ apply(manager, std::move(mutations));
150
+ }
151
+
152
+ } // namespace
153
+
154
+ TEST(textinput_mounts_a_real_field) {
155
+ @autoreleasepool {
156
+ basalt::AppKitMountingManager manager;
157
+ RnAppKitView *view = mountField(manager, 10, folly::dynamic::object("text", "hello"));
158
+
159
+ NSView *field = fieldOf(view);
160
+ EXPECT(field != nil);
161
+ EXPECT([RnPeerText(field) isEqualToString:@"hello"]);
162
+ // The view behind it draws the background and the border, so the field
163
+ // itself must paint nothing -- an NSTextField's own bezel would sit on top
164
+ // of whatever the style asked for.
165
+ EXPECT(!((NSTextField *)field).isBordered);
166
+ EXPECT(!((NSTextField *)field).drawsBackground);
167
+
168
+ manager.destroySurfaceRoot(kSurfaceId);
169
+ }
170
+ }
171
+
172
+ // Applying the `text` prop must not look like the user typing. If it did, the
173
+ // change reported would provoke a re-render that sets it again, forever.
174
+ TEST(textinput_applying_a_prop_is_not_typing) {
175
+ @autoreleasepool {
176
+ basalt::AppKitMountingManager manager;
177
+ RnAppKitView *view = mountField(manager, 20, folly::dynamic::object("text", "one"));
178
+
179
+ ShadowViewMutationList update;
180
+ update.push_back(ShadowViewMutation::UpdateMutation(
181
+ makeTextInput(20, folly::dynamic::object("text", "one")),
182
+ makeTextInput(20, folly::dynamic::object("text", "two")),
183
+ kSurfaceId));
184
+ apply(manager, std::move(update));
185
+
186
+ EXPECT([RnPeerText(fieldOf(view)) isEqualToString:@"two"]);
187
+
188
+ // No emitter is attached to these hand-built shadow views, so what this
189
+ // pins is that the round trip did not throw or recurse -- the loop it would
190
+ // enter has no exit, so a failure here is a hang rather than a wrong value.
191
+ manager.destroySurfaceRoot(kSurfaceId);
192
+ }
193
+ }
194
+
195
+ // The field is placed inside the content inset, so paddingHorizontal on a field
196
+ // means what it means on a <View>. Without this the text sits flush against the
197
+ // border, ignoring the style.
198
+ // An uncontrolled field keeps what was typed into it.
199
+ //
200
+ // React Native re-sends `mostRecentEventCount` on every change, which is an
201
+ // Update mutation on its own, and `text` for an uncontrolled field is the empty
202
+ // string forever -- `TextInput.js` sends `text={value ?? defaultValue}` and an
203
+ // uncontrolled field with no default sends undefined. So a manager that applies
204
+ // the prop whenever it differs from the field wipes it on the user's second
205
+ // keystroke. Applying it only when the *prop* changes is what makes this work;
206
+ // found on Windows in phase 46, and this platform had the same bug.
207
+ TEST(textinput_keeps_what_was_typed_into_an_uncontrolled_field) {
208
+ @autoreleasepool {
209
+ basalt::AppKitMountingManager manager;
210
+ RnAppKitView *view = mountField(manager, 30, folly::dynamic::object("text", ""));
211
+
212
+ typeInto(fieldOf(view), @"abc");
213
+ EXPECT([RnPeerText(fieldOf(view)) isEqualToString:@"abc"]);
214
+
215
+ // The re-render an uncontrolled field produces: no text, a bumped count.
216
+ updateField(manager,
217
+ 30,
218
+ folly::dynamic::object("text", ""),
219
+ folly::dynamic::object("text", "")("mostRecentEventCount", 1));
220
+ EXPECT([RnPeerText(fieldOf(view)) isEqualToString:@"abc"]);
221
+
222
+ manager.destroySurfaceRoot(kSurfaceId);
223
+ }
224
+ }
225
+
226
+ // The same staleness rule the commands already follow, applied to the prop. A
227
+ // value React rendered before the user's latest keystroke must not be applied,
228
+ // or a fast typist watches characters reorder themselves -- and it must not be
229
+ // *forgotten* either, or the value is lost once JavaScript catches up.
230
+ TEST(textinput_drops_a_text_prop_older_than_what_was_typed) {
231
+ @autoreleasepool {
232
+ basalt::AppKitMountingManager manager;
233
+ RnAppKitView *view = mountField(manager, 31, folly::dynamic::object("text", ""));
234
+
235
+ typeInto(fieldOf(view), @"fast");
236
+
237
+ updateField(manager,
238
+ 31,
239
+ folly::dynamic::object("text", ""),
240
+ folly::dynamic::object("text", "F")("mostRecentEventCount", 0));
241
+ EXPECT([RnPeerText(fieldOf(view)) isEqualToString:@"fast"]);
242
+
243
+ updateField(manager,
244
+ 31,
245
+ folly::dynamic::object("text", ""),
246
+ folly::dynamic::object("text", "F")("mostRecentEventCount", 1));
247
+ EXPECT([RnPeerText(fieldOf(view)) isEqualToString:@"F"]);
248
+
249
+ manager.destroySurfaceRoot(kSurfaceId);
250
+ }
251
+ }
252
+
253
+ // A `selection` prop on a field that has no focus.
254
+ //
255
+ // On AppKit a selection lives on the *field editor* -- a shared NSTextView the
256
+ // window lends to whichever field is first responder -- so an unfocused field
257
+ // has nowhere to put one. Props routinely arrive before focus does, so the
258
+ // only correct behaviour is to leave the text alone and not reach through a
259
+ // nil editor, which is what this pins.
260
+ //
261
+ // The other half, that a selection actually lands, needs a field editor and so
262
+ // needs a window, which this suite deliberately does not have. GTK's
263
+ // test_textinput.cpp covers that half on a host where the selection belongs to
264
+ // the widget itself.
265
+ TEST(textinput_a_selection_prop_without_focus_is_harmless) {
266
+ @autoreleasepool {
267
+ basalt::AppKitMountingManager manager;
268
+ RnAppKitView *view = mountField(manager, 20, folly::dynamic::object("text", "abcdef"));
269
+
270
+ ShadowViewMutationList update;
271
+ update.push_back(ShadowViewMutation::UpdateMutation(
272
+ makeTextInput(20, folly::dynamic::object("text", "abcdef")),
273
+ makeTextInput(20,
274
+ folly::dynamic::object("text", "abcdef")(
275
+ "selection", folly::dynamic::object("start", 2)("end", 5))),
276
+ kSurfaceId));
277
+ apply(manager, std::move(update));
278
+
279
+ EXPECT(RnPeerEditor(fieldOf(view)) == nil);
280
+ EXPECT([RnPeerText(fieldOf(view)) isEqualToString:@"abcdef"]);
281
+ }
282
+ }
283
+
284
+ TEST(textinput_sits_inside_the_content_inset) {
285
+ @autoreleasepool {
286
+ basalt::AppKitMountingManager manager;
287
+ RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
288
+ [root setRnFrameX:0 y:0 width:400 height:300];
289
+
290
+ ShadowView shadowView = makeTextInput(30, folly::dynamic::object("text", ""));
291
+ shadowView.layoutMetrics.contentInsets = {.left = 12, .top = 4, .right = 12, .bottom = 4};
292
+
293
+ ShadowViewMutationList mutations;
294
+ mutations.push_back(ShadowViewMutation::CreateMutation(shadowView));
295
+ mutations.push_back(ShadowViewMutation::InsertMutation(kSurfaceId, shadowView, 0));
296
+ apply(manager, std::move(mutations));
297
+
298
+ NSView *field = fieldOf(manager.viewForTag(30));
299
+ EXPECT(field != nil);
300
+ EXPECT_NEAR(field.frame.origin.x, 12.0, 0.001);
301
+ EXPECT_NEAR(field.frame.origin.y, 4.0, 0.001);
302
+ // 200 wide less 12 either side; 40 tall less 4 top and bottom.
303
+ EXPECT_NEAR(field.frame.size.width, 176.0, 0.001);
304
+ EXPECT_NEAR(field.frame.size.height, 32.0, 0.001);
305
+
306
+ manager.destroySurfaceRoot(kSurfaceId);
307
+ }
308
+ }
309
+
310
+ // secureTextEntry is a different *class* on AppKit, not a property, so turning
311
+ // it on has to rebuild the field -- and the text has to survive that.
312
+ TEST(textinput_secure_entry_rebuilds_the_field) {
313
+ @autoreleasepool {
314
+ basalt::AppKitMountingManager manager;
315
+ RnAppKitView *view = mountField(manager, 40, folly::dynamic::object("text", "shh"));
316
+
317
+ EXPECT(![fieldOf(view) isKindOfClass:[NSSecureTextField class]]);
318
+
319
+ ShadowViewMutationList update;
320
+ update.push_back(ShadowViewMutation::UpdateMutation(
321
+ makeTextInput(40, folly::dynamic::object("text", "shh")),
322
+ makeTextInput(40, folly::dynamic::object("text", "shh")("secureTextEntry", true)),
323
+ kSurfaceId));
324
+ apply(manager, std::move(update));
325
+
326
+ EXPECT([fieldOf(view) isKindOfClass:[NSSecureTextField class]]);
327
+ EXPECT([RnPeerText(fieldOf(view)) isEqualToString:@"shh"]);
328
+
329
+ manager.destroySurfaceRoot(kSurfaceId);
330
+ }
331
+ }
332
+
333
+ // `editable: false` and `readOnly: true` are the same request spelled two ways,
334
+ // and React Native honours both.
335
+ TEST(textinput_honours_both_spellings_of_read_only) {
336
+ @autoreleasepool {
337
+ basalt::AppKitMountingManager manager;
338
+
339
+ RnAppKitView *plain = mountField(manager, 50, folly::dynamic::object("text", ""));
340
+ EXPECT(((NSTextField *)fieldOf(plain)).isEditable);
341
+ manager.destroySurfaceRoot(kSurfaceId);
342
+
343
+ basalt::AppKitMountingManager second;
344
+ RnAppKitView *locked =
345
+ mountField(second, 51, folly::dynamic::object("text", "")("editable", false));
346
+ EXPECT(!((NSTextField *)fieldOf(locked)).isEditable);
347
+ second.destroySurfaceRoot(kSurfaceId);
348
+
349
+ basalt::AppKitMountingManager third;
350
+ RnAppKitView *readOnly =
351
+ mountField(third, 52, folly::dynamic::object("text", "")("readOnly", true));
352
+ EXPECT(!((NSTextField *)fieldOf(readOnly)).isEditable);
353
+ third.destroySurfaceRoot(kSurfaceId);
354
+ }
355
+ }
356
+
357
+ // The field's contents have to show up in the tree dump: they live in the
358
+ // NSTextField, not in anything the view draws, so they would otherwise be
359
+ // invisible to every test that reads the tree -- and to the cross-platform
360
+ // diff, which is where a difference in them would matter most.
361
+ TEST(textinput_contents_are_reported_in_the_tree) {
362
+ @autoreleasepool {
363
+ basalt::AppKitMountingManager manager;
364
+ RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
365
+ [root setRnFrameX:0 y:0 width:400 height:300];
366
+
367
+ ShadowViewMutationList mutations;
368
+ mutations.push_back(
369
+ ShadowViewMutation::CreateMutation(makeTextInput(60, folly::dynamic::object("text", "abc"))));
370
+ mutations.push_back(ShadowViewMutation::InsertMutation(
371
+ kSurfaceId, makeTextInput(60, folly::dynamic::object("text", "abc")), 0));
372
+ apply(manager, std::move(mutations));
373
+
374
+ const std::string described = [root describeTree].UTF8String;
375
+ EXPECT(described.find("editable=\"abc\"") != std::string::npos);
376
+
377
+ manager.destroySurfaceRoot(kSurfaceId);
378
+ }
379
+ }
380
+
381
+ // A command for a field that was never mounted, and one that is not ours. Both
382
+ // have to be survivable: a focus arriving between a Remove and its Delete is
383
+ // exactly this shape.
384
+ // --- multiline ---------------------------------------------------------------
385
+ //
386
+ // Deliberately the same four questions test_textinput.cpp asks of GTK, because
387
+ // both hosts now reach their peer through a seam and what these really check
388
+ // is whether the two seams agree.
389
+
390
+ TEST(textinput_multiline_builds_a_text_view) {
391
+ @autoreleasepool {
392
+ basalt::AppKitMountingManager manager;
393
+ RnAppKitView *view =
394
+ mountField(manager, 60, folly::dynamic::object("text", "hello")("multiline", true));
395
+
396
+ NSView *peer = fieldOf(view);
397
+ EXPECT(peer != nil);
398
+ EXPECT(RnPeerIsMultiline(peer));
399
+ EXPECT([peer isKindOfClass:[NSTextView class]]);
400
+
401
+ // And a plain field is still an NSTextField, which is the half that must
402
+ // not have moved.
403
+ RnAppKitView *single = mountField(manager, 61, folly::dynamic::object("text", "hello"));
404
+ EXPECT(!RnPeerIsMultiline(fieldOf(single)));
405
+ EXPECT([fieldOf(single) isKindOfClass:[NSTextField class]]);
406
+ }
407
+ }
408
+
409
+ TEST(textinput_multiline_text_round_trips) {
410
+ @autoreleasepool {
411
+ basalt::AppKitMountingManager manager;
412
+ RnAppKitView *view =
413
+ mountField(manager, 62, folly::dynamic::object("text", "one")("multiline", true));
414
+ EXPECT([RnPeerText(fieldOf(view)) isEqualToString:@"one"]);
415
+
416
+ // Newlines are the point, and are what a single-line field would refuse.
417
+ ShadowViewMutationList update;
418
+ update.push_back(ShadowViewMutation::UpdateMutation(
419
+ makeTextInput(62, folly::dynamic::object("text", "one")("multiline", true)),
420
+ makeTextInput(62, folly::dynamic::object("text", "one\ntwo")("multiline", true)),
421
+ kSurfaceId));
422
+ apply(manager, std::move(update));
423
+ EXPECT([RnPeerText(fieldOf(view)) isEqualToString:@"one\ntwo"]);
424
+ }
425
+ }
426
+
427
+ TEST(textinput_multiline_owns_its_selection) {
428
+ @autoreleasepool {
429
+ basalt::AppKitMountingManager manager;
430
+ RnAppKitView *view =
431
+ mountField(manager, 63, folly::dynamic::object("text", "abcdef")("multiline", true));
432
+
433
+ // The one place multiline is simpler: an NSTextView *is* the editor, so it
434
+ // has a selection whether or not anything has focus -- where an unfocused
435
+ // NSTextField has no field editor and so none at all.
436
+ NSView *peer = fieldOf(view);
437
+ EXPECT(RnPeerEditor(peer) != nil);
438
+
439
+ RnPeerSetSelection(peer, NSMakeRange(1, 3));
440
+ const NSRange selected = RnPeerSelection(peer);
441
+ EXPECT_EQ((long)selected.location, 1L);
442
+ EXPECT_EQ((long)selected.length, 3L);
443
+ }
444
+ }
445
+
446
+ TEST(textinput_switching_multiline_rebuilds_the_peer) {
447
+ @autoreleasepool {
448
+ basalt::AppKitMountingManager manager;
449
+ RnAppKitView *view = mountField(manager, 64, folly::dynamic::object("text", "hello"));
450
+ EXPECT(!RnPeerIsMultiline(fieldOf(view)));
451
+
452
+ // Not a property that can be flipped, so the widget is replaced -- and the
453
+ // text has to survive that, because React did not ask for it to be cleared.
454
+ ShadowViewMutationList update;
455
+ update.push_back(ShadowViewMutation::UpdateMutation(
456
+ makeTextInput(64, folly::dynamic::object("text", "hello")),
457
+ makeTextInput(64, folly::dynamic::object("text", "hello")("multiline", true)),
458
+ kSurfaceId));
459
+ apply(manager, std::move(update));
460
+
461
+ EXPECT(RnPeerIsMultiline(fieldOf(view)));
462
+ EXPECT([RnPeerText(fieldOf(view)) isEqualToString:@"hello"]);
463
+ }
464
+ }
465
+
466
+ // A single-line field clips rather than wrapping, which is what it does on
467
+ // every other platform -- it scrolls horizontally instead. An NSTextField
468
+ // wraps by default, and that was invisible here until a field held more than
469
+ // it could show.
470
+ TEST(textinput_single_line_does_not_wrap) {
471
+ @autoreleasepool {
472
+ basalt::AppKitMountingManager manager;
473
+ RnAppKitView *view = mountField(manager, 65, folly::dynamic::object("text", "hello"));
474
+ NSTextField *field = (NSTextField *)fieldOf(view);
475
+ EXPECT(field.usesSingleLineMode);
476
+ EXPECT(!field.cell.wraps);
477
+ }
478
+ }
479
+
480
+ // maxLength, which AppKit offers nothing for on either peer.
481
+ TEST(textinput_max_length_is_enforced_on_both_peers) {
482
+ @autoreleasepool {
483
+ basalt::AppKitMountingManager manager;
484
+
485
+ // A single-line field takes a formatter, which is what AppKit offers.
486
+ RnAppKitView *single =
487
+ mountField(manager, 70, folly::dynamic::object("text", "")("maxLength", 5));
488
+ NSTextField *field = (NSTextField *)fieldOf(single);
489
+ EXPECT(field.formatter != nil);
490
+ NSString *replacement = nil;
491
+ NSString *error = nil;
492
+ EXPECT([field.formatter isPartialStringValid:@"abcde"
493
+ newEditingString:&replacement
494
+ errorDescription:&error]);
495
+ EXPECT(![field.formatter isPartialStringValid:@"abcdef"
496
+ newEditingString:&replacement
497
+ errorDescription:&error]);
498
+
499
+ // A multiline one has no formatter, so the limit is asked about instead --
500
+ // refused whole rather than truncated.
501
+ RnAppKitView *multi = mountField(
502
+ manager, 71, folly::dynamic::object("text", "abcd")("multiline", true)("maxLength", 5));
503
+ NSView *peer = fieldOf(multi);
504
+ EXPECT(RnPeerAllowsChange(peer, NSMakeRange(4, 0), @"e"));
505
+ EXPECT(!RnPeerAllowsChange(peer, NSMakeRange(4, 0), @"efgh"));
506
+ // Replacing a selection makes room, so length alone is not the question.
507
+ EXPECT(RnPeerAllowsChange(peer, NSMakeRange(0, 4), @"vwxyz"));
508
+
509
+ // And no limit means no limit.
510
+ RnAppKitView *free = mountField(manager, 72, folly::dynamic::object("text", "")("multiline", true));
511
+ EXPECT(RnPeerAllowsChange(fieldOf(free), NSMakeRange(0, 0), @"as long as you like"));
512
+ }
513
+ }
514
+
515
+ // A placeholder on the peer that has no property for it.
516
+ TEST(textinput_multiline_takes_a_placeholder) {
517
+ @autoreleasepool {
518
+ basalt::AppKitMountingManager manager;
519
+ RnAppKitView *view = mountField(
520
+ manager, 73,
521
+ folly::dynamic::object("text", "")("multiline", true)("placeholder", "type here"));
522
+
523
+ NSAttributedString *placeholder = RnPeerPlaceholder(fieldOf(view));
524
+ EXPECT(placeholder != nil);
525
+ EXPECT([placeholder.string isEqualToString:@"type here"]);
526
+
527
+ // And the single-line peer still takes its own, through the property
528
+ // AppKit gives it.
529
+ RnAppKitView *single =
530
+ mountField(manager, 74, folly::dynamic::object("text", "")("placeholder", "type here"));
531
+ EXPECT([RnPeerPlaceholder(fieldOf(single)).string isEqualToString:@"type here"]);
532
+ }
533
+ }
534
+
535
+ TEST(textinput_commands_survive_an_unknown_tag) {
536
+ @autoreleasepool {
537
+ basalt::AppKitMountingManager manager;
538
+ mountField(manager, 70, folly::dynamic::object("text", ""));
539
+
540
+ manager.applyCommand(9999, "focus", folly::dynamic::array());
541
+ manager.applyCommand(70, "somethingElse", folly::dynamic::array());
542
+ manager.applyCommand(70, "blur", folly::dynamic::array());
543
+
544
+ manager.destroySurfaceRoot(kSurfaceId);
545
+ }
546
+ }
547
+
548
+ // --- What React actually hears ---------------------------------------------
549
+ //
550
+ // Everything above pins the field's own state. These pin the events, which
551
+ // nothing below the end-to-end suite could see until EventRecorder.h -- see
552
+ // that header for why a stub emitter cannot do this and a real dispatcher can.
553
+
554
+ TEST(textinput_change_reaches_the_emitter) {
555
+ @autoreleasepool {
556
+ basalt::testing::EventRecorder recorder;
557
+ basalt::AppKitMountingManager manager;
558
+ RnAppKitView *view = mountField(manager,
559
+ 80,
560
+ folly::dynamic::object("text", ""),
561
+ recorder.emitter<TextInputEventEmitter>());
562
+
563
+ typeInto(fieldOf(view), @"a");
564
+
565
+ const auto seen = recorder.seen();
566
+ EXPECT_EQ(seen.size(), 1u);
567
+ EXPECT_EQ(seen.empty() ? std::string{} : seen[0], std::string{"topChange"});
568
+
569
+ manager.destroySurfaceRoot(kSurfaceId);
570
+ }
571
+ }
572
+
573
+ // A genuine ordering assertion, and the first one in either suite: one call
574
+ // site emits two events, and React Native's contract is blur before
575
+ // endEditing. Nothing checked that they both fired, never mind in which
576
+ // order.
577
+ TEST(textinput_blur_reports_blur_before_end_editing) {
578
+ @autoreleasepool {
579
+ basalt::testing::EventRecorder recorder;
580
+ basalt::AppKitMountingManager manager;
581
+ RnAppKitView *view = mountField(manager,
582
+ 81,
583
+ folly::dynamic::object("text", "hello"),
584
+ recorder.emitter<TextInputEventEmitter>());
585
+
586
+ endEditing(fieldOf(view));
587
+
588
+ const auto seen = recorder.seen();
589
+ EXPECT_EQ(seen.size(), 2u);
590
+ if (seen.size() == 2) {
591
+ EXPECT_EQ(seen[0], std::string{"topBlur"});
592
+ EXPECT_EQ(seen[1], std::string{"topEndEditing"});
593
+ }
594
+
595
+ manager.destroySurfaceRoot(kSurfaceId);
596
+ }
597
+ }
598
+
599
+ // A field nobody typed into reports nothing. Worth pinning because the
600
+ // recorder would be just as quiet if it were wired up wrong, and every
601
+ // assertion above rests on it hearing what it should.
602
+ TEST(textinput_mounting_alone_reports_nothing) {
603
+ @autoreleasepool {
604
+ basalt::testing::EventRecorder recorder;
605
+ basalt::AppKitMountingManager manager;
606
+ mountField(manager,
607
+ 82,
608
+ folly::dynamic::object("text", "hello"),
609
+ recorder.emitter<TextInputEventEmitter>());
610
+
611
+ EXPECT(recorder.seen().empty());
612
+
613
+ manager.destroySurfaceRoot(kSurfaceId);
614
+ }
615
+ }
616
+
617
+ // --- Focus and keys, through a real window ----------------------------------
618
+ //
619
+ // These need a window, which the rest of this file does not: focus is
620
+ // AppKit's to give, and the manager watches keys with a local NSEvent monitor
621
+ // rather than through the delegate -- deliberately, so that it sees the key
622
+ // on its way to the responder chain, before the edit and therefore before
623
+ // onChange.
624
+ //
625
+ // The window is never ordered front, like the ones in
626
+ // test_appkit_titlebar.mm, and that is also the limit of what can be pinned
627
+ // here. NSApp routes a key to the *key* window, and a window belonging to a
628
+ // process that is not active cannot become key -- `makeKeyWindow` on one
629
+ // leaves `isKeyWindow` false. So the monitor can be driven and the edit it
630
+ // precedes cannot, and onKeyPress-before-onChange stays the end-to-end
631
+ // suite's to check rather than being arranged here and called a test.
632
+ //
633
+ // Payload values are out of reach for the same reason everywhere else: which
634
+ // key it was lives in a jsi::Object. What is checked is that a key press is
635
+ // reported, and for which keys it is not.
636
+
637
+ namespace {
638
+
639
+ // A field that is first responder in a window nobody can see.
640
+ NSWindow *windowAround(basalt::AppKitMountingManager &manager, NSView *field) {
641
+ NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 400, 300)
642
+ styleMask:NSWindowStyleMaskTitled
643
+ backing:NSBackingStoreBuffered
644
+ defer:NO];
645
+ [window.contentView addSubview:manager.getSurfaceRoot(kSurfaceId)];
646
+ [window makeFirstResponder:field];
647
+ return window;
648
+ }
649
+
650
+ // One key, through NSApp -- which is what invokes a local event monitor.
651
+ // Posted and pulled rather than handed straight to sendEvent: so that it
652
+ // travels the queue a real keystroke travels.
653
+ void pressKey(NSWindow *window, unsigned short keyCode, NSString *characters) {
654
+ NSEvent *key = [NSEvent keyEventWithType:NSEventTypeKeyDown
655
+ location:NSZeroPoint
656
+ modifierFlags:0
657
+ timestamp:0
658
+ windowNumber:window.windowNumber
659
+ context:nil
660
+ characters:characters
661
+ charactersIgnoringModifiers:characters
662
+ isARepeat:NO
663
+ keyCode:keyCode];
664
+ [NSApp postEvent:key atStart:YES];
665
+ NSEvent *pulled = [NSApp nextEventMatchingMask:NSEventMaskAny
666
+ untilDate:[NSDate distantPast]
667
+ inMode:NSDefaultRunLoopMode
668
+ dequeue:YES];
669
+ if (pulled != nil) {
670
+ [NSApp sendEvent:pulled];
671
+ }
672
+ }
673
+
674
+ } // namespace
675
+
676
+ // Focus is AppKit's to give and React's to hear about. Nothing checked that
677
+ // it was passed on.
678
+ TEST(textinput_focus_reaches_the_emitter) {
679
+ @autoreleasepool {
680
+ [NSApplication sharedApplication];
681
+
682
+ basalt::testing::EventRecorder recorder;
683
+ basalt::AppKitMountingManager manager;
684
+ RnAppKitView *view = mountField(manager,
685
+ 83,
686
+ folly::dynamic::object("text", ""),
687
+ recorder.emitter<TextInputEventEmitter>());
688
+
689
+ NSWindow *window = windowAround(manager, fieldOf(view));
690
+ EXPECT(window.firstResponder != window);
691
+
692
+ const auto seen = recorder.seen();
693
+ EXPECT_EQ(seen.size(), 1u);
694
+ EXPECT_EQ(seen.empty() ? std::string{} : seen[0], std::string{"topFocus"});
695
+
696
+ manager.destroySurfaceRoot(kSurfaceId);
697
+ }
698
+ }
699
+
700
+ TEST(textinput_reports_a_key_press_from_the_event_queue) {
701
+ @autoreleasepool {
702
+ [NSApplication sharedApplication];
703
+
704
+ basalt::testing::EventRecorder recorder;
705
+ basalt::AppKitMountingManager manager;
706
+ RnAppKitView *view = mountField(manager,
707
+ 84,
708
+ folly::dynamic::object("text", ""),
709
+ recorder.emitter<TextInputEventEmitter>());
710
+
711
+ NSWindow *window = windowAround(manager, fieldOf(view));
712
+ recorder.clear(); // The focus above, which the test before this one owns.
713
+
714
+ pressKey(window, 0, @"a");
715
+
716
+ const auto seen = recorder.seen();
717
+ EXPECT_EQ(seen.size(), 1u);
718
+ EXPECT_EQ(seen.empty() ? std::string{} : seen[0], std::string{"topKeyPress"});
719
+
720
+ manager.destroySurfaceRoot(kSurfaceId);
721
+ }
722
+ }
723
+
724
+ // A key that types nothing reports nothing, which is what iOS does. The
725
+ // arrows and the function keys live in the Unicode private use area, and the
726
+ // manager filters on that -- a filter nothing exercised.
727
+ TEST(textinput_says_nothing_about_a_key_that_types_nothing) {
728
+ @autoreleasepool {
729
+ [NSApplication sharedApplication];
730
+
731
+ basalt::testing::EventRecorder recorder;
732
+ basalt::AppKitMountingManager manager;
733
+ RnAppKitView *view = mountField(manager,
734
+ 85,
735
+ folly::dynamic::object("text", ""),
736
+ recorder.emitter<TextInputEventEmitter>());
737
+
738
+ NSWindow *window = windowAround(manager, fieldOf(view));
739
+ recorder.clear();
740
+
741
+ // Left arrow: keyCode 123, and NSLeftArrowFunctionKey as its character.
742
+ pressKey(window, 123, [NSString stringWithFormat:@"%C", (unichar)NSLeftArrowFunctionKey]);
743
+
744
+ EXPECT(recorder.seen().empty());
745
+
746
+ manager.destroySurfaceRoot(kSurfaceId);
747
+ }
748
+ }