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,588 @@
1
+ #import "AppKitTextInput.h"
2
+
3
+ #import "AppKitTextPeer.h"
4
+
5
+ #import "CoreTextLayout.h"
6
+
7
+ #include <react/renderer/components/iostextinput/TextInputProps.h>
8
+
9
+ #include <algorithm>
10
+ #include <cmath>
11
+ #include <string_view>
12
+
13
+ // The bridge between AppKit's delegate protocol and the C++ manager, for the
14
+ // same reason the touch dispatcher and the scroll manager have one.
15
+ @interface RnAppKitTextInputDelegate
16
+ : NSObject <NSTextFieldDelegate, NSTextViewDelegate, RnAppKitTextPeerOwner>
17
+ @property(nonatomic, assign) basalt::AppKitTextInputManager *manager;
18
+ @property(nonatomic, strong) id keyMonitor;
19
+ @end
20
+
21
+ @implementation RnAppKitTextInputDelegate
22
+
23
+ static NSInteger RnTagOf(id object) {
24
+ if ([object respondsToSelector:@selector(rnTag)]) {
25
+ return [object rnTag];
26
+ }
27
+ return 0;
28
+ }
29
+
30
+ - (instancetype)init {
31
+ self = [super init];
32
+ if (self != nil) {
33
+ // Object nil, because the field editor is on loan from the window and a
34
+ // different one can arrive with the next focus -- there is nothing stable
35
+ // to observe. The handler filters by which field is using it.
36
+ [[NSNotificationCenter defaultCenter] addObserver:self
37
+ selector:@selector(rnSelectionDidChange:)
38
+ name:NSTextViewDidChangeSelectionNotification
39
+ object:nil];
40
+ [self rnInstallKeyMonitor];
41
+ }
42
+ return self;
43
+ }
44
+
45
+ - (void)dealloc {
46
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
47
+ if (_keyMonitor != nil) {
48
+ [NSEvent removeMonitor:_keyMonitor];
49
+ _keyMonitor = nil;
50
+ }
51
+ }
52
+
53
+ - (void)rnInstallKeyMonitor {
54
+ // A local monitor rather than a delegate method, because AppKit has no hook
55
+ // for an ordinary character reaching a field. `control:textView:
56
+ // doCommandBySelector:` sees only the named commands -- newline, delete,
57
+ // the arrows -- and the other route is supplying a custom field editor
58
+ // through the *window's* delegate, which this manager does not own.
59
+ //
60
+ // A monitor sees the key on its way to the responder chain, so onKeyPress
61
+ // fires before the edit and therefore before onChange, which is the order
62
+ // React Native promises.
63
+ __weak RnAppKitTextInputDelegate *weakSelf = self;
64
+ _keyMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskKeyDown
65
+ handler:^NSEvent *(NSEvent *event) {
66
+ RnAppKitTextInputDelegate *strongSelf = weakSelf;
67
+ if (strongSelf != nil && strongSelf.manager != nullptr) {
68
+ strongSelf.manager->handleKeyDown((__bridge void *)event);
69
+ }
70
+ // Always passed on: this observes, it does not consume.
71
+ return event;
72
+ }];
73
+ }
74
+
75
+ - (void)rnSelectionDidChange:(NSNotification *)notification {
76
+ if (_manager != nullptr) {
77
+ _manager->handleSelectionChanged((__bridge void *)notification.object);
78
+ }
79
+ }
80
+
81
+ - (void)controlTextDidChange:(NSNotification *)notification {
82
+ if (_manager != nullptr) {
83
+ _manager->handleChanged(static_cast<facebook::react::Tag>(RnTagOf(notification.object)));
84
+ }
85
+ }
86
+
87
+ - (void)controlTextDidEndEditing:(NSNotification *)notification {
88
+ if (_manager != nullptr) {
89
+ _manager->handleBlur(static_cast<facebook::react::Tag>(RnTagOf(notification.object)));
90
+ }
91
+ }
92
+
93
+ // The multiline halves of the two above. An NSTextView is not an NSControl, so
94
+ // it posts NSText's notifications through NSTextViewDelegate rather than
95
+ // NSControl's -- different names, same two moments.
96
+ // maxLength for a multiline peer, which has no formatter to hold it.
97
+ - (BOOL)textView:(NSTextView *)textView
98
+ shouldChangeTextInRange:(NSRange)range
99
+ replacementString:(NSString *)replacement {
100
+ return RnPeerAllowsChange(textView, range, replacement != nil ? replacement : @"");
101
+ }
102
+
103
+ - (void)textDidChange:(NSNotification *)notification {
104
+ if (_manager != nullptr) {
105
+ _manager->handleChanged(static_cast<facebook::react::Tag>(RnTagOf(notification.object)));
106
+ }
107
+ }
108
+
109
+ - (void)textDidEndEditing:(NSNotification *)notification {
110
+ if (_manager != nullptr) {
111
+ _manager->handleBlur(static_cast<facebook::react::Tag>(RnTagOf(notification.object)));
112
+ }
113
+ }
114
+
115
+ - (void)rnFieldDidBecomeFirstResponder:(NSInteger)tag {
116
+ if (_manager != nullptr) {
117
+ _manager->handleFocus(static_cast<facebook::react::Tag>(tag));
118
+ }
119
+ }
120
+
121
+ // Return. React Native calls this submitEditing, and follows it with
122
+ // endEditing on platforms where the field also gives up focus; an NSTextField
123
+ // keeps focus on Return, so only the submit is reported here -- the same
124
+ // asymmetry the GTK side has with GtkText's `activate`.
125
+ - (BOOL)control:(NSControl *)control
126
+ textView:(NSTextView *)textView
127
+ doCommandBySelector:(SEL)selector {
128
+ (void)textView;
129
+ if (selector == @selector(insertNewline:) && _manager != nullptr) {
130
+ _manager->handleSubmit(static_cast<facebook::react::Tag>(RnTagOf(control)));
131
+ return YES;
132
+ }
133
+ return NO;
134
+ }
135
+
136
+ @end
137
+
138
+ namespace basalt {
139
+
140
+ using facebook::react::AttributedString;
141
+ using facebook::react::ShadowView;
142
+ using facebook::react::Tag;
143
+ using facebook::react::TextInputEventEmitter;
144
+ using facebook::react::TextInputProps;
145
+
146
+ AppKitTextInputManager::AppKitTextInputManager(EmitterLookup lookup) : lookup_(std::move(lookup)) {
147
+ RnAppKitTextInputDelegate *delegate = [[RnAppKitTextInputDelegate alloc] init];
148
+ delegate.manager = this;
149
+ delegate_ = delegate;
150
+ }
151
+
152
+ AppKitTextInputManager::~AppKitTextInputManager() {
153
+ ((RnAppKitTextInputDelegate *)delegate_).manager = nullptr;
154
+ delegate_ = nil;
155
+ }
156
+
157
+ AppKitTextInputManager::Entry *AppKitTextInputManager::entryFor(Tag tag) {
158
+ const auto it = entries_.find(tag);
159
+ return it == entries_.end() ? nullptr : &it->second;
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Mutations
164
+ // ---------------------------------------------------------------------------
165
+
166
+ void AppKitTextInputManager::makeField(Entry &entry, bool secure, bool multiline) {
167
+ // The contents come across. Nothing asked for the field to be cleared --
168
+ // React changed one prop -- and the controlled loop will not put the text
169
+ // back, because from its side `text` did not change. The GTK side carries it
170
+ // the same way for the same reason.
171
+ NSString *existing = RnPeerText(entry.field);
172
+ [entry.field removeFromSuperview];
173
+
174
+ NSView *peer = RnPeerNew(multiline ? YES : NO, secure ? YES : NO, entry.tag,
175
+ (id)delegate_, (id)delegate_);
176
+ RnPeerSetText(peer, existing);
177
+
178
+ entry.field = peer;
179
+ entry.secure = secure;
180
+ entry.multiline = multiline;
181
+ entry.view.rnEditable = peer;
182
+ [entry.view addSubview:peer];
183
+ }
184
+
185
+ void AppKitTextInputManager::update(RnAppKitView *view, const ShadowView &shadowView) {
186
+ const Tag tag = shadowView.tag;
187
+ auto [it, inserted] = entries_.try_emplace(tag);
188
+ Entry &entry = it->second;
189
+
190
+ entry.view = view;
191
+ entry.tag = tag;
192
+
193
+ const auto props = std::dynamic_pointer_cast<const TextInputProps>(shadowView.props);
194
+ const bool secure = props != nullptr && props->traits.secureTextEntry;
195
+
196
+ const bool multiline = props != nullptr && props->multiline;
197
+ if (inserted || entry.field == nil || entry.secure != secure || entry.multiline != multiline) {
198
+ // A secure field is a different class on AppKit, not a property, so
199
+ // toggling secureTextEntry means building a new one. The text comes across;
200
+ // focus does not, which is the honest limit of doing it this way.
201
+ makeField(entry, secure, multiline);
202
+ }
203
+
204
+ // Yoga has resolved border and padding into the content inset; the field is
205
+ // placed inside it, so `paddingHorizontal` on a field means what it means on
206
+ // a <View>.
207
+ const auto &frame = shadowView.layoutMetrics.frame;
208
+ const auto &insets = shadowView.layoutMetrics.contentInsets;
209
+ const CGFloat innerWidth = (CGFloat)frame.size.width - insets.left - insets.right;
210
+ const CGFloat innerHeight = (CGFloat)frame.size.height - insets.top - insets.bottom;
211
+ entry.field.frame = NSMakeRect(insets.left,
212
+ insets.top,
213
+ std::max<CGFloat>(0, innerWidth),
214
+ std::max<CGFloat>(0, innerHeight));
215
+
216
+ if (props == nullptr) {
217
+ return;
218
+ }
219
+
220
+ // Controlled component: JavaScript owns the value. Three things have to be
221
+ // true at once, and each fails differently.
222
+ //
223
+ // Setting it must not look like typing, or the change we report provokes a
224
+ // re-render that sets it again and the two chase each other -- `applying`.
225
+ //
226
+ // A prop older than what the user has since typed must not be applied at all,
227
+ // or a fast typist watches characters reorder themselves. That is what
228
+ // React Native counts events for, and dropping such a value *without
229
+ // recording it* is deliberate: the next render, once JavaScript has caught
230
+ // up, applies it.
231
+ //
232
+ // And it is applied when the prop *changes*, not when it differs from the
233
+ // field, which is the only thing that tells a controlled field from an
234
+ // uncontrolled one. See the header.
235
+ const bool stale = props->mostRecentEventCount < entry.eventCount;
236
+ const bool changed = !entry.sawProps || props->text != entry.lastPropText;
237
+ if (changed && !stale) {
238
+ entry.lastPropText = props->text;
239
+ entry.sawProps = true;
240
+
241
+ const char *currentUtf8 = RnPeerText(entry.field).UTF8String;
242
+ const std::string current = currentUtf8 != nullptr ? currentUtf8 : "";
243
+ if (props->text != current) {
244
+ entry.applying = true;
245
+ NSString *incoming = [NSString stringWithUTF8String:props->text.c_str()];
246
+ RnPeerSetText(entry.field, incoming != nil ? incoming : @"");
247
+ entry.applying = false;
248
+ entry.lastReportedText = props->text;
249
+ }
250
+ }
251
+
252
+ // The field renders in AppKit's own font and colour, which has nothing to do
253
+ // with the `style` this component was given -- on a dark field that is dark
254
+ // text on dark. buildTextAttributes is what the style becomes, and it was
255
+ // written for the text layer with this as its eventual second caller.
256
+ //
257
+ // fontSizeMultiplier is 1: nothing on this platform scales text for
258
+ // accessibility settings yet, and passing 0 would multiply the size away.
259
+ NSDictionary<NSAttributedStringKey, id> *attributes =
260
+ buildTextAttributes(props->getEffectiveTextAttributes(1.0F));
261
+ NSParagraphStyle *paragraph = attributes[NSParagraphStyleAttributeName];
262
+ RnPeerSetTextStyle(entry.field, attributes[NSFontAttributeName],
263
+ attributes[NSForegroundColorAttributeName],
264
+ paragraph != nil ? paragraph.alignment : NSTextAlignmentNatural);
265
+
266
+ // A controlled *selection*, under the same staleness rule the text is under:
267
+ // JavaScript that has not yet seen the last keystroke must not drag the caret
268
+ // back to where it thought it was. Applied on change rather than on
269
+ // difference, for the reason `text` is -- an uncontrolled field sends no
270
+ // selection at all, and re-asserting one every render would fight the user's
271
+ // own arrow keys.
272
+ //
273
+ // Only while the field has a field editor: with no focus there is nothing
274
+ // holding a selection, and AppKit will make one the moment focus arrives.
275
+ if (props->selection.has_value() && !stale) {
276
+ const auto &selection = *props->selection;
277
+ const bool selectionChanged = !entry.lastPropSelection.has_value() ||
278
+ entry.lastPropSelection->start != selection.start ||
279
+ entry.lastPropSelection->end != selection.end;
280
+ if (selectionChanged) {
281
+ entry.lastPropSelection = selection;
282
+ // Only when something is holding a selection. A single-line field with no
283
+ // focus has no field editor and nowhere to put one; a multiline peer
284
+ // always can, because it is its own editor.
285
+ if (RnPeerEditor(entry.field) != nil) {
286
+ entry.applying = true;
287
+ RnPeerSetSelection(entry.field,
288
+ NSMakeRange((NSUInteger)selection.start,
289
+ (NSUInteger)MAX(0, selection.end - selection.start)));
290
+ entry.applying = false;
291
+ entry.lastReportedSelection = facebook::react::AttributedString::Range{
292
+ selection.start, selection.end - selection.start};
293
+ }
294
+ }
295
+ }
296
+
297
+ if (props->placeholder.empty()) {
298
+ RnPeerSetPlaceholder(entry.field, nil);
299
+ } else {
300
+ NSString *placeholder = [NSString stringWithUTF8String:props->placeholder.c_str()];
301
+ if (placeholder == nil) {
302
+ placeholder = @"";
303
+ }
304
+ // Styled like the text, so a placeholder in a 20pt field is not 13pt.
305
+ NSMutableDictionary *placeholderAttributes = [attributes mutableCopy];
306
+ placeholderAttributes[NSForegroundColorAttributeName] = NSColor.placeholderTextColor;
307
+ RnPeerSetPlaceholder(entry.field,
308
+ [[NSAttributedString alloc] initWithString:placeholder
309
+ attributes:placeholderAttributes]);
310
+ }
311
+
312
+ // `editable` is the prop; `readOnly` is the newer spelling of its inverse,
313
+ // and React Native honours both.
314
+ RnPeerSetEditable(entry.field, props->traits.editable && !props->readOnly);
315
+
316
+ // Zero means no limit, and so does the absurd default React Native uses when
317
+ // the prop is absent.
318
+ RnPeerSetMaxLength(entry.field,
319
+ (props->maxLength > 0 && props->maxLength < 1000000)
320
+ ? (NSInteger)props->maxLength
321
+ : 0);
322
+
323
+ if (inserted && props->autoFocus) {
324
+ [entry.view.window makeFirstResponder:entry.field];
325
+ }
326
+ }
327
+
328
+ void AppKitTextInputManager::remove(Tag tag) {
329
+ const auto it = entries_.find(tag);
330
+ if (it == entries_.end()) {
331
+ return;
332
+ }
333
+ // The delegate dispatches by tag, so dropping the entry is what stops events
334
+ // reaching a dead view; taking the field out of the hierarchy is what stops
335
+ // it being drawn.
336
+ [it->second.field removeFromSuperview];
337
+ // Both peers carry a delegate, and neither is an NSView property -- so it is
338
+ // cleared through the class that has it.
339
+ if (RnPeerIsMultiline(it->second.field)) {
340
+ ((NSTextView *)it->second.field).delegate = nil;
341
+ } else {
342
+ ((NSTextField *)it->second.field).delegate = nil;
343
+ }
344
+ entries_.erase(it);
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Events
349
+ // ---------------------------------------------------------------------------
350
+
351
+ std::shared_ptr<const TextInputEventEmitter> AppKitTextInputManager::emitterFor(Tag tag) const {
352
+ return std::dynamic_pointer_cast<const TextInputEventEmitter>(lookup_(tag));
353
+ }
354
+
355
+ TextInputEventEmitter::Metrics AppKitTextInputManager::metricsFor(const Entry &entry) const {
356
+ TextInputEventEmitter::Metrics metrics{};
357
+ const char *utf8 = RnPeerText(entry.field).UTF8String;
358
+ metrics.text = utf8 != nullptr ? utf8 : "";
359
+ metrics.eventCount = entry.eventCount;
360
+ metrics.target = entry.tag;
361
+
362
+ // The whole selection, from the field editor -- an NSTextField has none of
363
+ // its own, because the editing is done by a shared NSTextView on loan from
364
+ // the window while the field has focus. With no editor there is no selection
365
+ // to report, and the end of the text is where a caret would appear.
366
+ int location = static_cast<int>(metrics.text.size());
367
+ int length = 0;
368
+ if (entry.field != nil) {
369
+ if (RnPeerEditor(entry.field) != nil) {
370
+ const NSRange selected = RnPeerSelection(entry.field);
371
+ location = static_cast<int>(selected.location);
372
+ length = static_cast<int>(selected.length);
373
+ }
374
+ }
375
+ metrics.selectionRange = AttributedString::Range{location, length};
376
+
377
+ // The scroll-shaped fields exist because iOS's text view is a scroll view.
378
+ // Nothing here scrolls yet, so they describe a viewport the size of the
379
+ // field, which is true and keeps JavaScript's arithmetic sane.
380
+ const auto width = static_cast<facebook::react::Float>(
381
+ entry.view != nil ? entry.view.bounds.size.width : 0);
382
+ const auto height = static_cast<facebook::react::Float>(
383
+ entry.view != nil ? entry.view.bounds.size.height : 0);
384
+ metrics.containerSize = {.width = width, .height = height};
385
+ metrics.contentSize = metrics.containerSize;
386
+ metrics.layoutMeasurement = metrics.containerSize;
387
+ metrics.zoomScale = 1.0F;
388
+
389
+ return metrics;
390
+ }
391
+
392
+ void AppKitTextInputManager::handleChanged(Tag tag) {
393
+ Entry *entry = entryFor(tag);
394
+ if (entry == nullptr || entry->applying) {
395
+ // This is a prop being applied, not the user typing.
396
+ return;
397
+ }
398
+
399
+ const char *utf8 = RnPeerText(entry->field).UTF8String;
400
+ const std::string value = utf8 != nullptr ? utf8 : "";
401
+ if (value == entry->lastReportedText) {
402
+ return;
403
+ }
404
+ entry->lastReportedText = value;
405
+ entry->eventCount++;
406
+
407
+ if (const auto emitter = emitterFor(tag)) {
408
+ emitter->onChange(metricsFor(*entry));
409
+ }
410
+ }
411
+
412
+ void AppKitTextInputManager::handleSubmit(Tag tag) {
413
+ Entry *entry = entryFor(tag);
414
+ if (entry == nullptr) {
415
+ return;
416
+ }
417
+ if (const auto emitter = emitterFor(tag)) {
418
+ emitter->onSubmitEditing(metricsFor(*entry));
419
+ }
420
+ }
421
+
422
+ void AppKitTextInputManager::handleFocus(Tag tag) {
423
+ Entry *entry = entryFor(tag);
424
+ if (entry == nullptr) {
425
+ return;
426
+ }
427
+ if (const auto emitter = emitterFor(tag)) {
428
+ emitter->onFocus(metricsFor(*entry));
429
+ }
430
+ }
431
+
432
+ bool AppKitTextInputManager::handleKeyDown(void *event) {
433
+ if (event == nullptr) {
434
+ return false;
435
+ }
436
+ NSEvent *keyEvent = (__bridge NSEvent *)event;
437
+
438
+ // Only when the key is going to a field of ours. The monitor sees every key
439
+ // down in the process, including those meant for anything else on screen.
440
+ for (auto &[tag, entry] : entries_) {
441
+ NSResponder *editor = RnPeerEditor(entry.field);
442
+ if (editor == nil || entry.field.window.firstResponder != editor) {
443
+ continue;
444
+ }
445
+
446
+ // React Native's contract: 'Enter' and 'Backspace' by name, the typed
447
+ // character otherwise -- including ' ' for space. Keys that produce no
448
+ // character send nothing, which is what iOS does.
449
+ std::string key;
450
+ switch (keyEvent.keyCode) {
451
+ case 36: // Return
452
+ case 76: // Enter, on the keypad
453
+ key = "Enter";
454
+ break;
455
+ case 51: // Delete, which is Backspace everywhere but on Apple keycaps
456
+ key = "Backspace";
457
+ break;
458
+ default: {
459
+ NSString *characters = keyEvent.characters;
460
+ if (characters.length == 0) {
461
+ return false;
462
+ }
463
+ const unichar first = [characters characterAtIndex:0];
464
+ // Function keys and the arrows live in the Unicode private use area,
465
+ // and control characters are not typing either.
466
+ if (first >= 0xF700 || (first < 0x20 && first != 0x09)) {
467
+ return false;
468
+ }
469
+ key = characters.UTF8String != nullptr ? characters.UTF8String : "";
470
+ break;
471
+ }
472
+ }
473
+ if (key.empty()) {
474
+ return false;
475
+ }
476
+
477
+ if (auto emitter = emitterFor(tag)) {
478
+ TextInputEventEmitter::KeyPressMetrics metrics{};
479
+ metrics.text = key;
480
+ metrics.eventCount = entry.eventCount;
481
+ emitter->onKeyPress(metrics);
482
+ }
483
+ return true;
484
+ }
485
+ return false;
486
+ }
487
+
488
+ void AppKitTextInputManager::handleSelectionChanged(void *editor) {
489
+ if (editor == nullptr) {
490
+ return;
491
+ }
492
+ NSText *text = (__bridge NSText *)editor;
493
+
494
+ for (auto &[tag, entry] : entries_) {
495
+ if (entry.field == nil || RnPeerEditor(entry.field) != (NSResponder *)text) {
496
+ continue;
497
+ }
498
+ // Not while a prop is being pushed in: applying `text` moves the caret, and
499
+ // reporting that as the user selecting something would make a controlled
500
+ // field fight its own render.
501
+ if (entry.applying) {
502
+ return;
503
+ }
504
+
505
+ const auto metrics = metricsFor(entry);
506
+ if (metrics.selectionRange.location == entry.lastReportedSelection.location &&
507
+ metrics.selectionRange.length == entry.lastReportedSelection.length) {
508
+ return;
509
+ }
510
+ entry.lastReportedSelection = metrics.selectionRange;
511
+
512
+ if (auto emitter = emitterFor(tag)) {
513
+ emitter->onSelectionChange(metrics);
514
+ }
515
+ return;
516
+ }
517
+ }
518
+
519
+ void AppKitTextInputManager::handleBlur(Tag tag) {
520
+ Entry *entry = entryFor(tag);
521
+ if (entry == nullptr) {
522
+ return;
523
+ }
524
+ if (const auto emitter = emitterFor(tag)) {
525
+ emitter->onBlur(metricsFor(*entry));
526
+ emitter->onEndEditing(metricsFor(*entry));
527
+ }
528
+ }
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // Commands
532
+ // ---------------------------------------------------------------------------
533
+
534
+ bool AppKitTextInputManager::dispatchCommand(Tag tag,
535
+ const std::string &name,
536
+ const folly::dynamic &args) {
537
+ Entry *entry = entryFor(tag);
538
+ if (entry == nullptr || entry->field == nil) {
539
+ return false;
540
+ }
541
+
542
+ if (name == "focus") {
543
+ [entry->view.window makeFirstResponder:entry->field];
544
+ return true;
545
+ }
546
+
547
+ if (name == "blur") {
548
+ // Handing focus back to the window is the closest thing AppKit has to
549
+ // "unfocus this": focus moves, it is not dropped. It ends the edit, so the
550
+ // delegate reports a blur and JavaScript still sees onBlur.
551
+ NSWindow *window = entry->view.window;
552
+ if (window != nil && window.firstResponder != window) {
553
+ [window makeFirstResponder:window];
554
+ }
555
+ return true;
556
+ }
557
+
558
+ if (name == "setTextAndSelection") {
559
+ // [eventCount, text, start, end]. An eventCount older than what the user
560
+ // has since typed means this command is stale and must be dropped, which is
561
+ // the whole reason React Native counts them.
562
+ if (args.isArray() && args.size() >= 2) {
563
+ const int eventCount = static_cast<int>(args[0].asInt());
564
+ if (eventCount < entry->eventCount) {
565
+ return true;
566
+ }
567
+ entry->applying = true;
568
+ const auto text = args[1].isString() ? args[1].asString() : std::string{};
569
+ NSString *incoming = [NSString stringWithUTF8String:text.c_str()];
570
+ RnPeerSetText(entry->field, incoming != nil ? incoming : @"");
571
+ if (args.size() >= 4 && args[2].isInt() && args[3].isInt()) {
572
+ const NSInteger start = static_cast<NSInteger>(args[2].asInt());
573
+ const NSInteger end = static_cast<NSInteger>(args[3].asInt());
574
+ if (RnPeerEditor(entry->field) != nil) {
575
+ RnPeerSetSelection(entry->field,
576
+ NSMakeRange((NSUInteger)start, (NSUInteger)MAX(0, end - start)));
577
+ }
578
+ }
579
+ entry->applying = false;
580
+ entry->lastReportedText = text;
581
+ }
582
+ return true;
583
+ }
584
+
585
+ return false;
586
+ }
587
+
588
+ } // namespace basalt
@@ -0,0 +1,74 @@
1
+ // The same seam GtkTextPeer.h is, for AppKit.
2
+ //
3
+ // A single-line field is an NSTextField and a multiline one is an NSTextView,
4
+ // and they agree on almost nothing. An NSTextField has a `stringValue`, no
5
+ // selection of its own -- the window lends it a field editor while it has
6
+ // focus -- a `placeholderString`, and a `bezeled` flag. An NSTextView has a
7
+ // `string`, owns its own selection because it *is* the editor, has no
8
+ // placeholder, and draws its background through a different property.
9
+ //
10
+ // Rather than branch at each of the twenty-eight call sites in
11
+ // AppKitTextInput.mm, they go through here. Functions over a protocol because
12
+ // RnAppKitView needs the text for its tree dump and knows nothing about either
13
+ // concrete class.
14
+ //
15
+ // Offsets are in UTF-16 units, which is what NSRange means and what
16
+ // NSAttributedString counts -- the same thing React Native's JavaScript means
17
+ // by a string index. The GTK seam says characters for the same reason: each
18
+ // platform's native unit is the one its string type uses.
19
+
20
+ #pragma once
21
+
22
+ #import <Cocoa/Cocoa.h>
23
+
24
+ NS_ASSUME_NONNULL_BEGIN
25
+
26
+ // What a peer tells the manager when it takes focus. NSTextField cannot report
27
+ // blur usefully -- its field editor is the first responder, not it -- so only
28
+ // the gaining half is here; the delegate supplies the rest.
29
+ @protocol RnAppKitTextPeerOwner <NSObject>
30
+ - (void)rnFieldDidBecomeFirstResponder:(NSInteger)tag;
31
+ @end
32
+
33
+ // Builds the peer. `secure` and `multiline` are exclusive: React Native has no
34
+ // multiline secure field, and NSTextView has no way to be one.
35
+ NSView *RnPeerNew(BOOL multiline, BOOL secure, NSInteger tag, id owner, id delegate);
36
+
37
+ BOOL RnPeerIsMultiline(NSView *_Nullable peer);
38
+
39
+ NSString *RnPeerText(NSView *_Nullable peer);
40
+ void RnPeerSetText(NSView *_Nullable peer, NSString *text);
41
+
42
+ // The selection, in UTF-16 units. An unfocused NSTextField has no field editor
43
+ // and so no selection; the caret is reported at the end of its text, which is
44
+ // where one would appear.
45
+ NSRange RnPeerSelection(NSView *_Nullable peer);
46
+ void RnPeerSetSelection(NSView *_Nullable peer, NSRange range);
47
+
48
+ void RnPeerSetEditable(NSView *_Nullable peer, BOOL editable);
49
+ void RnPeerSetTextStyle(NSView *_Nullable peer, NSFont *_Nullable font,
50
+ NSColor *_Nullable colour, NSTextAlignment alignment);
51
+
52
+ // An NSTextField has a placeholder property; an NSTextView has none, so its
53
+ // peer draws one when it is empty. Same call for both.
54
+ void RnPeerSetPlaceholder(NSView *_Nullable peer, NSAttributedString *_Nullable placeholder);
55
+ NSAttributedString *_Nullable RnPeerPlaceholder(NSView *_Nullable peer);
56
+
57
+ // `maxLength`. Zero means no limit.
58
+ //
59
+ // AppKit offers nothing for this on either peer, so both refuse the edit that
60
+ // would cross the limit: an NSTextView through its delegate, and an NSTextField
61
+ // through an NSFormatter, which is the documented way to constrain one.
62
+ void RnPeerSetMaxLength(NSView *_Nullable peer, NSInteger maxLength);
63
+
64
+ // Whether an edit is allowed, for the delegate to ask. Refusing whole rather
65
+ // than truncating, so a paste that would overflow is rejected instead of
66
+ // half-applied.
67
+ BOOL RnPeerAllowsChange(NSView *_Nullable peer, NSRange range, NSString *_Nullable replacement);
68
+
69
+ // The responder that does the editing: the field editor for an NSTextField,
70
+ // and the view itself for an NSTextView, which is its own editor. This is what
71
+ // tells whether a key or a selection change belongs to this peer.
72
+ NSResponder *_Nullable RnPeerEditor(NSView *_Nullable peer);
73
+
74
+ NS_ASSUME_NONNULL_END