basalt-appkit 0.0.0-rc5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/native/CMakeLists.txt +16 -2
  2. package/native/appkit/AppKitAppWindow.h +24 -0
  3. package/native/appkit/AppKitImageLoader.h +48 -1
  4. package/native/appkit/AppKitImageLoader.mm +228 -10
  5. package/native/appkit/AppKitMenuBar.mm +58 -0
  6. package/native/appkit/AppKitMountingManager.h +34 -0
  7. package/native/appkit/AppKitMountingManager.mm +582 -12
  8. package/native/appkit/AppKitScrollView.h +48 -0
  9. package/native/appkit/AppKitScrollView.mm +210 -0
  10. package/native/appkit/AppKitSkiaPeer.h +15 -0
  11. package/native/appkit/AppKitTextInput.h +38 -1
  12. package/native/appkit/AppKitTextInput.mm +235 -8
  13. package/native/appkit/AppKitTextPeer.h +60 -0
  14. package/native/appkit/AppKitTextPeer.mm +307 -0
  15. package/native/appkit/AppKitTouchDispatcher.h +56 -0
  16. package/native/appkit/AppKitTouchDispatcher.mm +140 -0
  17. package/native/appkit/AppKitWindowControl.mm +38 -3
  18. package/native/appkit/ComponentRegistryAppKit.mm +28 -3
  19. package/native/appkit/CoreTextLayout.h +64 -2
  20. package/native/appkit/CoreTextLayout.mm +431 -71
  21. package/native/appkit/CoreTextLayoutManager.mm +57 -6
  22. package/native/appkit/FontRegistryCoreText.mm +6 -5
  23. package/native/appkit/PlatformServicesAppKit.mm +101 -20
  24. package/native/appkit/RnAppKitView.h +492 -0
  25. package/native/appkit/RnAppKitView.mm +2266 -23
  26. package/native/appkit/RnTextLayout.h +106 -0
  27. package/native/appkit/RnTextLayout.mm +321 -2
  28. package/native/appkit/main_appkit.mm +174 -3
  29. package/native/tests/test_appkit_accessibility.mm +289 -0
  30. package/native/tests/test_appkit_borders.mm +975 -0
  31. package/native/tests/test_appkit_fonts.mm +203 -0
  32. package/native/tests/test_appkit_image.mm +392 -17
  33. package/native/tests/test_appkit_input.mm +209 -0
  34. package/native/tests/test_appkit_mounting.mm +360 -0
  35. package/native/tests/test_appkit_scrollbar.mm +125 -0
  36. package/native/tests/test_appkit_scrollview.mm +371 -0
  37. package/native/tests/test_appkit_selection.mm +349 -0
  38. package/native/tests/test_appkit_services.mm +51 -1
  39. package/native/tests/test_appkit_text.mm +1628 -0
  40. package/native/tests/test_appkit_textinput.mm +819 -0
  41. package/native/tests/test_appkit_titlebar.mm +43 -0
  42. package/native/tests/test_appkit_view.mm +609 -0
  43. package/package.json +4 -3
@@ -4,15 +4,105 @@
4
4
 
5
5
  // Both peers report gaining focus the same way, and neither superclass does it
6
6
  // for us. See the protocol's header comment for why only the gaining half.
7
+ // The two properties, on an NSTextView. Unset leaves the platform's own answer
8
+ // alone, which is the whole reason the flag has three states.
9
+ static void RnApplyTextChecking(NSTextView *_Nullable editor,
10
+ RnTextChecking spellCheck,
11
+ RnTextChecking autoCorrect) {
12
+ if (editor == nil) {
13
+ return;
14
+ }
15
+ if (spellCheck != RnTextCheckingUnset) {
16
+ editor.continuousSpellCheckingEnabled = spellCheck == RnTextCheckingOn;
17
+ }
18
+ if (autoCorrect != RnTextCheckingUnset) {
19
+ editor.automaticSpellingCorrectionEnabled = autoCorrect == RnTextCheckingOn;
20
+ }
21
+ }
22
+
23
+ // `selectionColor` and `cursorColor`, on the same NSTextView and under the same
24
+ // rule: nil leaves AppKit's own answer alone, which is the third state the
25
+ // props have.
26
+ static void RnApplyEditorColours(NSTextView *_Nullable editor,
27
+ NSColor *_Nullable selection,
28
+ NSColor *_Nullable caret) {
29
+ if (editor == nil) {
30
+ return;
31
+ }
32
+ if (caret != nil) {
33
+ editor.insertionPointColor = caret;
34
+ }
35
+ if (selection != nil) {
36
+ // One key of the dictionary rather than the whole of it: the rest is
37
+ // AppKit's, including how a selection dims when its window loses focus.
38
+ NSMutableDictionary *attributes = [editor.selectedTextAttributes mutableCopy];
39
+ if (attributes == nil) {
40
+ attributes = [NSMutableDictionary dictionary];
41
+ }
42
+ attributes[NSBackgroundColorAttributeName] = selection;
43
+ editor.selectedTextAttributes = attributes;
44
+ }
45
+ }
46
+
7
47
  @interface RnAppKitTextField : NSTextField
8
48
  @property(nonatomic, assign) NSInteger rnTag;
49
+ @property(nonatomic, strong, nullable) NSColor *rnSelectionColour;
50
+ @property(nonatomic, strong, nullable) NSColor *rnCaretColour;
9
51
  @property(nonatomic, weak) id<RnAppKitTextPeerOwner> rnOwner;
52
+ @property(nonatomic, assign) RnTextChecking rnSpellCheck;
53
+ @property(nonatomic, assign) RnTextChecking rnAutoCorrect;
54
+ // `contextMenuHidden`. A flag rather than a torn-down menu: AppKit builds the
55
+ // menu on demand, through -menuForEvent:, so refusing there is the one place
56
+ // that covers a right-click and a Control-click both.
57
+ @property(nonatomic, assign) BOOL rnContextMenuHidden;
10
58
  @end
11
59
 
12
60
  @implementation RnAppKitTextField
61
+ - (NSMenu *)menuForEvent:(NSEvent *)event {
62
+ // `contextMenuHidden`: nil is how a view says it has no context menu, and
63
+ // AppKit then offers none rather than falling back to a default.
64
+ if (_rnContextMenuHidden) {
65
+ return nil;
66
+ }
67
+ return [super menuForEvent:event];
68
+ }
69
+
70
+ // The same refusal, for the menu this field does not own.
71
+ //
72
+ // A focused single-line field is edited through the *window's shared field
73
+ // editor*, a plain NSTextView nobody here subclasses, and that view is the one
74
+ // under the pointer: -menuForEvent: above is never asked, so the flag reached
75
+ // the field and stopped there. Assigning `menu = nil` on the editor does not
76
+ // help either, which a probe established before this was written -- the editor
77
+ // rebuilds its menu inside its own -menuForEvent:.
78
+ //
79
+ // This is the documented way into that rebuild. A field editor's delegate is
80
+ // the control it is editing for, which is this field, and NSTextView asks its
81
+ // delegate to approve the menu it has just built. Returning nil is the whole
82
+ // of it.
83
+ - (NSMenu *)textView:(NSTextView *)view
84
+ menu:(NSMenu *)menu
85
+ forEvent:(NSEvent *)event
86
+ atIndex:(NSUInteger)index {
87
+ (void)view;
88
+ (void)event;
89
+ (void)index;
90
+ if (_rnContextMenuHidden) {
91
+ return nil;
92
+ }
93
+ return menu;
94
+ }
95
+
13
96
  - (BOOL)becomeFirstResponder {
14
97
  const BOOL became = [super becomeFirstResponder];
15
98
  if (became) {
99
+ // The field editor exists now and did not before, which is the only moment
100
+ // the spell-checking flags can be put on it. See RnPeerSetTextChecking.
101
+ if ([self.currentEditor isKindOfClass:NSTextView.class]) {
102
+ RnApplyTextChecking((NSTextView *)self.currentEditor, _rnSpellCheck, _rnAutoCorrect);
103
+ RnApplyEditorColours(
104
+ (NSTextView *)self.currentEditor, _rnSelectionColour, _rnCaretColour);
105
+ }
16
106
  [_rnOwner rnFieldDidBecomeFirstResponder:_rnTag];
17
107
  }
18
108
  return became;
@@ -21,13 +111,63 @@
21
111
 
22
112
  @interface RnAppKitSecureTextField : NSSecureTextField
23
113
  @property(nonatomic, assign) NSInteger rnTag;
114
+ @property(nonatomic, strong, nullable) NSColor *rnSelectionColour;
115
+ @property(nonatomic, strong, nullable) NSColor *rnCaretColour;
24
116
  @property(nonatomic, weak) id<RnAppKitTextPeerOwner> rnOwner;
117
+ @property(nonatomic, assign) RnTextChecking rnSpellCheck;
118
+ @property(nonatomic, assign) RnTextChecking rnAutoCorrect;
119
+ // `contextMenuHidden`. A flag rather than a torn-down menu: AppKit builds the
120
+ // menu on demand, through -menuForEvent:, so refusing there is the one place
121
+ // that covers a right-click and a Control-click both.
122
+ @property(nonatomic, assign) BOOL rnContextMenuHidden;
25
123
  @end
26
124
 
27
125
  @implementation RnAppKitSecureTextField
126
+ - (NSMenu *)menuForEvent:(NSEvent *)event {
127
+ // `contextMenuHidden`: nil is how a view says it has no context menu, and
128
+ // AppKit then offers none rather than falling back to a default.
129
+ if (_rnContextMenuHidden) {
130
+ return nil;
131
+ }
132
+ return [super menuForEvent:event];
133
+ }
134
+
135
+ // The same refusal, for the menu this field does not own.
136
+ //
137
+ // A focused single-line field is edited through the *window's shared field
138
+ // editor*, a plain NSTextView nobody here subclasses, and that view is the one
139
+ // under the pointer: -menuForEvent: above is never asked, so the flag reached
140
+ // the field and stopped there. Assigning `menu = nil` on the editor does not
141
+ // help either, which a probe established before this was written -- the editor
142
+ // rebuilds its menu inside its own -menuForEvent:.
143
+ //
144
+ // This is the documented way into that rebuild. A field editor's delegate is
145
+ // the control it is editing for, which is this field, and NSTextView asks its
146
+ // delegate to approve the menu it has just built. Returning nil is the whole
147
+ // of it.
148
+ - (NSMenu *)textView:(NSTextView *)view
149
+ menu:(NSMenu *)menu
150
+ forEvent:(NSEvent *)event
151
+ atIndex:(NSUInteger)index {
152
+ (void)view;
153
+ (void)event;
154
+ (void)index;
155
+ if (_rnContextMenuHidden) {
156
+ return nil;
157
+ }
158
+ return menu;
159
+ }
160
+
28
161
  - (BOOL)becomeFirstResponder {
29
162
  const BOOL became = [super becomeFirstResponder];
30
163
  if (became) {
164
+ // The field editor exists now and did not before, which is the only moment
165
+ // the spell-checking flags can be put on it. See RnPeerSetTextChecking.
166
+ if ([self.currentEditor isKindOfClass:NSTextView.class]) {
167
+ RnApplyTextChecking((NSTextView *)self.currentEditor, _rnSpellCheck, _rnAutoCorrect);
168
+ RnApplyEditorColours(
169
+ (NSTextView *)self.currentEditor, _rnSelectionColour, _rnCaretColour);
170
+ }
31
171
  [_rnOwner rnFieldDidBecomeFirstResponder:_rnTag];
32
172
  }
33
173
  return became;
@@ -36,11 +176,28 @@
36
176
 
37
177
  @interface RnAppKitTextView : NSTextView
38
178
  @property(nonatomic, assign) NSInteger rnTag;
179
+ @property(nonatomic, strong, nullable) NSColor *rnSelectionColour;
180
+ @property(nonatomic, strong, nullable) NSColor *rnCaretColour;
39
181
  @property(nonatomic, weak) id<RnAppKitTextPeerOwner> rnOwner;
40
182
  @property(nonatomic, strong, nullable) NSAttributedString *rnPlaceholder;
183
+ @property(nonatomic, assign) RnTextChecking rnSpellCheck;
184
+ @property(nonatomic, assign) RnTextChecking rnAutoCorrect;
185
+ // `contextMenuHidden`. A flag rather than a torn-down menu: AppKit builds the
186
+ // menu on demand, through -menuForEvent:, so refusing there is the one place
187
+ // that covers a right-click and a Control-click both.
188
+ @property(nonatomic, assign) BOOL rnContextMenuHidden;
41
189
  @end
42
190
 
43
191
  @implementation RnAppKitTextView
192
+ - (NSMenu *)menuForEvent:(NSEvent *)event {
193
+ // `contextMenuHidden`: nil is how a view says it has no context menu, and
194
+ // AppKit then offers none rather than falling back to a default.
195
+ if (_rnContextMenuHidden) {
196
+ return nil;
197
+ }
198
+ return [super menuForEvent:event];
199
+ }
200
+
44
201
  - (BOOL)becomeFirstResponder {
45
202
  const BOOL became = [super becomeFirstResponder];
46
203
  if (became) {
@@ -196,6 +353,156 @@ void RnPeerSetSelection(NSView *peer, NSRange range) {
196
353
  }
197
354
  }
198
355
 
356
+ void RnPeerSetTextChecking(NSView *peer,
357
+ RnTextChecking spellCheck,
358
+ RnTextChecking autoCorrect) {
359
+ if (peer == nil) {
360
+ return;
361
+ }
362
+ if (RnPeerIsMultiline(peer)) {
363
+ // An NSTextView is its own editor, so this is the simple case -- the
364
+ // request is still remembered, because AppKit may decline it and a test
365
+ // needs to know what was asked.
366
+ ((RnAppKitTextView *)peer).rnSpellCheck = spellCheck;
367
+ ((RnAppKitTextView *)peer).rnAutoCorrect = autoCorrect;
368
+ RnApplyTextChecking((NSTextView *)peer, spellCheck, autoCorrect);
369
+ return;
370
+ }
371
+ // Remembered first, because a field that is not focused has no editor to put
372
+ // them on and `becomeFirstResponder` is what applies them then.
373
+ if ([peer isKindOfClass:RnAppKitTextField.class]) {
374
+ ((RnAppKitTextField *)peer).rnSpellCheck = spellCheck;
375
+ ((RnAppKitTextField *)peer).rnAutoCorrect = autoCorrect;
376
+ } else if ([peer isKindOfClass:RnAppKitSecureTextField.class]) {
377
+ ((RnAppKitSecureTextField *)peer).rnSpellCheck = spellCheck;
378
+ ((RnAppKitSecureTextField *)peer).rnAutoCorrect = autoCorrect;
379
+ }
380
+ if ([((NSTextField *)peer).currentEditor isKindOfClass:NSTextView.class]) {
381
+ RnApplyTextChecking(
382
+ (NSTextView *)((NSTextField *)peer).currentEditor, spellCheck, autoCorrect);
383
+ }
384
+ }
385
+
386
+ void RnPeerSetEditorColours(NSView *peer, NSColor *selection, NSColor *caret) {
387
+ if (peer == nil) {
388
+ return;
389
+ }
390
+ if (RnPeerIsMultiline(peer)) {
391
+ // Its own editor, so the colours could go straight on -- they are still
392
+ // remembered, so that reading them back answers what the app asked for
393
+ // rather than what AppKit currently holds.
394
+ ((RnAppKitTextView *)peer).rnSelectionColour = selection;
395
+ ((RnAppKitTextView *)peer).rnCaretColour = caret;
396
+ RnApplyEditorColours((NSTextView *)peer, selection, caret);
397
+ return;
398
+ }
399
+ // Remembered first, because a field with no focus has no field editor to put
400
+ // them on and `becomeFirstResponder` is what applies them then.
401
+ if ([peer isKindOfClass:RnAppKitTextField.class]) {
402
+ ((RnAppKitTextField *)peer).rnSelectionColour = selection;
403
+ ((RnAppKitTextField *)peer).rnCaretColour = caret;
404
+ } else if ([peer isKindOfClass:RnAppKitSecureTextField.class]) {
405
+ ((RnAppKitSecureTextField *)peer).rnSelectionColour = selection;
406
+ ((RnAppKitSecureTextField *)peer).rnCaretColour = caret;
407
+ }
408
+ if ([((NSTextField *)peer).currentEditor isKindOfClass:NSTextView.class]) {
409
+ RnApplyEditorColours((NSTextView *)((NSTextField *)peer).currentEditor, selection, caret);
410
+ }
411
+ }
412
+
413
+ NSColor *RnPeerSelectionColour(NSView *peer) {
414
+ if ([peer isKindOfClass:RnAppKitTextView.class]) {
415
+ return ((RnAppKitTextView *)peer).rnSelectionColour;
416
+ }
417
+ if ([peer isKindOfClass:RnAppKitTextField.class]) {
418
+ return ((RnAppKitTextField *)peer).rnSelectionColour;
419
+ }
420
+ if ([peer isKindOfClass:RnAppKitSecureTextField.class]) {
421
+ return ((RnAppKitSecureTextField *)peer).rnSelectionColour;
422
+ }
423
+ return nil;
424
+ }
425
+
426
+ NSColor *RnPeerCaretColour(NSView *peer) {
427
+ if ([peer isKindOfClass:RnAppKitTextView.class]) {
428
+ return ((RnAppKitTextView *)peer).rnCaretColour;
429
+ }
430
+ if ([peer isKindOfClass:RnAppKitTextField.class]) {
431
+ return ((RnAppKitTextField *)peer).rnCaretColour;
432
+ }
433
+ if ([peer isKindOfClass:RnAppKitSecureTextField.class]) {
434
+ return ((RnAppKitSecureTextField *)peer).rnCaretColour;
435
+ }
436
+ return nil;
437
+ }
438
+
439
+ // The remembered request, from whichever peer this is. A multiline view has no
440
+ // need to remember -- it is its own editor -- so its own flags are read back.
441
+ RnTextChecking RnPeerSpellCheck(NSView *peer) {
442
+ if (peer == nil) {
443
+ return RnTextCheckingUnset;
444
+ }
445
+ if ([peer isKindOfClass:RnAppKitTextField.class]) {
446
+ return ((RnAppKitTextField *)peer).rnSpellCheck;
447
+ }
448
+ if ([peer isKindOfClass:RnAppKitSecureTextField.class]) {
449
+ return ((RnAppKitSecureTextField *)peer).rnSpellCheck;
450
+ }
451
+ if ([peer isKindOfClass:RnAppKitTextView.class]) {
452
+ return ((RnAppKitTextView *)peer).rnSpellCheck;
453
+ }
454
+ return RnTextCheckingUnset;
455
+ }
456
+
457
+ RnTextChecking RnPeerAutoCorrect(NSView *peer) {
458
+ if (peer == nil) {
459
+ return RnTextCheckingUnset;
460
+ }
461
+ if ([peer isKindOfClass:RnAppKitTextField.class]) {
462
+ return ((RnAppKitTextField *)peer).rnAutoCorrect;
463
+ }
464
+ if ([peer isKindOfClass:RnAppKitSecureTextField.class]) {
465
+ return ((RnAppKitSecureTextField *)peer).rnAutoCorrect;
466
+ }
467
+ if ([peer isKindOfClass:RnAppKitTextView.class]) {
468
+ return ((RnAppKitTextView *)peer).rnAutoCorrect;
469
+ }
470
+ return RnTextCheckingUnset;
471
+ }
472
+
473
+ void RnPeerSetContextMenuHidden(NSView *peer, BOOL hidden) {
474
+ if (peer == nil) {
475
+ return;
476
+ }
477
+ // Every peer class this file makes carries the flag, and they are the only
478
+ // classes that reach here: a secure field is not an NSTextField subclass of
479
+ // this one's, so the three are named rather than tested for a common base.
480
+ if ([peer isKindOfClass:RnAppKitTextView.class]) {
481
+ ((RnAppKitTextView *)peer).rnContextMenuHidden = hidden;
482
+ return;
483
+ }
484
+ if ([peer isKindOfClass:RnAppKitSecureTextField.class]) {
485
+ ((RnAppKitSecureTextField *)peer).rnContextMenuHidden = hidden;
486
+ return;
487
+ }
488
+ if ([peer isKindOfClass:RnAppKitTextField.class]) {
489
+ ((RnAppKitTextField *)peer).rnContextMenuHidden = hidden;
490
+ }
491
+ }
492
+
493
+ BOOL RnPeerContextMenuHidden(NSView *peer) {
494
+ if ([peer isKindOfClass:RnAppKitTextView.class]) {
495
+ return ((RnAppKitTextView *)peer).rnContextMenuHidden;
496
+ }
497
+ if ([peer isKindOfClass:RnAppKitSecureTextField.class]) {
498
+ return ((RnAppKitSecureTextField *)peer).rnContextMenuHidden;
499
+ }
500
+ if ([peer isKindOfClass:RnAppKitTextField.class]) {
501
+ return ((RnAppKitTextField *)peer).rnContextMenuHidden;
502
+ }
503
+ return NO;
504
+ }
505
+
199
506
  void RnPeerSetEditable(NSView *peer, BOOL editable) {
200
507
  if (peer == nil) {
201
508
  return;
@@ -21,10 +21,13 @@
21
21
  #include "AppKitMountingManager.h"
22
22
  #include "HoverTracker.h"
23
23
  #include "PointerButtons.h"
24
+ #include "TextSelection.h"
24
25
 
25
26
  #include <react/renderer/components/view/TouchEventEmitter.h>
26
27
  #include <react/renderer/graphics/Point.h>
27
28
 
29
+ #include <utility>
30
+
28
31
  namespace basalt {
29
32
 
30
33
  // The tag of the React Native view under a point, in the root's coordinates, or
@@ -67,6 +70,15 @@ class AppKitTouchDispatcher {
67
70
  // negative coordinate means the pointer left the surface.
68
71
  void synthesiseHover(double x, double y);
69
72
 
73
+ // --- Selecting text -------------------------------------------------------
74
+ //
75
+ // The AppKit half of what the GTK dispatcher does, in the same three calls
76
+ // and for the same reasons: a press inside a selectable paragraph is
77
+ // remembered and nothing else, and the gesture becomes a selection only once
78
+ // the pointer has moved far enough to be a sweep rather than a tap -- at
79
+ // which point the touch sequence this was reporting is cancelled. The state
80
+ // machine is core/TextSelection.h.
81
+
70
82
  // The main-thread half, called by the view that received the mouse event.
71
83
  // Public because the Objective-C trampoline has to reach them.
72
84
  // `button` decides whether this presses anything. Only the primary one drives
@@ -91,8 +103,37 @@ class AppKitTouchDispatcher {
91
103
  void dispatchHoverLeave();
92
104
 
93
105
  private:
106
+
107
+ // The innermost selectable paragraph under a point in root coordinates, and
108
+ // the character index in its text, or {nil, -1}.
109
+ std::pair<RnAppKitView *, NSInteger> selectableTextAt(double x, double y) const;
110
+
111
+ // The index in `view`'s text nearest a point in *root* coordinates, which is
112
+ // not the same question: a drag that has left the paragraph still extends the
113
+ // selection inside it.
114
+ NSInteger textIndexIn(RnAppKitView *view, double x, double y) const;
115
+
116
+ // Pushes the model's range onto the paragraph it belongs to, and takes the
117
+ // highlight off whatever held one before.
118
+ void drawSelection();
94
119
  enum class TouchKind { Start, Move, End, Cancel };
95
120
 
121
+ // The window stopped being key while the mouse was down.
122
+ //
123
+ // React Native expects a touch to end one of two ways, and a press that
124
+ // simply stops is the shape that leaves a `<Pressable>` highlighted for the
125
+ // rest of the screen's life: `onPressOut` never runs and the responder is
126
+ // never released. AppKit has no gesture recogniser to cancel, which is what
127
+ // the GTK host uses (`GtkGesture::cancelled`); this is the nearest thing, and
128
+ // it is what happens when the person switches app or a panel takes over
129
+ // mid-press.
130
+ //
131
+ // Observed here rather than in the host's window delegate so that the whole
132
+ // path has a test: the delegate is in `main_appkit.mm`, which the suite does
133
+ // not link, and the part worth testing is that the notification reaches the
134
+ // press.
135
+ void windowResignedKey(void *window);
136
+
96
137
  // Hands the pointer to a gesture recogniser that has activated, cancelling
97
138
  // React Native's touch. True when that happened, which means the caller has
98
139
  // nothing left to report.
@@ -118,11 +159,26 @@ class AppKitTouchDispatcher {
118
159
  void emitPointerMove(
119
160
  facebook::react::Tag target, double originX, double originY, double x, double y);
120
161
 
162
+ // What is selected, which paragraph it is in, and which is holding a
163
+ // highlight -- the last two differ when a selection moves to another
164
+ // paragraph, and after a release the highlight outlives the drag.
165
+ //
166
+ // Strong references, unlike the GTK side's weak pointers: an NSView is
167
+ // retained by its superview and these keep a paragraph alive a moment longer
168
+ // than its unmounting, which is a retain cycle nobody can observe rather than
169
+ // a dangling pointer. Cleared on press, on release and in the destructor.
170
+ basalt::TextSelection selection_;
171
+ RnAppKitView *selectionView_{nil};
172
+ RnAppKitView *highlightView_{nil};
173
+
121
174
  AppKitMountingManager *mountingManager_;
122
175
  RnAppKitView *surfaceRoot_;
123
176
  // The Objective-C object the root forwards mouse events to. Held so it
124
177
  // outlives the root's weak reference to it.
125
178
  id inputTarget_;
179
+ // The notification observer for a window that stops being key; see
180
+ // windowResignedKey.
181
+ id resignObserver_;
126
182
 
127
183
  // The view a gesture started on. React Native reports every touch in a
128
184
  // gesture against the target it began on, even after the pointer leaves that
@@ -8,6 +8,7 @@
8
8
  #include <react/renderer/components/view/TouchEvent.h>
9
9
 
10
10
  #include <cstdint>
11
+ #include <string>
11
12
 
12
13
  // The bridge between AppKit's protocol and the C++ dispatcher. A C++ object
13
14
  // cannot conform to an Objective-C protocol, and the root holds its handler
@@ -125,13 +126,53 @@ AppKitTouchDispatcher::AppKitTouchDispatcher(AppKitMountingManager *mountingMana
125
126
  // The root's tracking area is only wanted once there is somewhere to send
126
127
  // hover, and AppKit will not ask again just because a property changed.
127
128
  [surfaceRoot_ updateTrackingAreas];
129
+
130
+ // What `Copy` in a menu and Cmd+C copy when a paragraph has a selection.
131
+ // Neither of those can reach this object -- one is in the platform services
132
+ // and the other in the application's own key handling -- so this is where
133
+ // they ask. See core/TextSelection.h.
134
+ basalt::setSelectedTextProvider([this]() -> std::string {
135
+ NSString *text = highlightView_ == nil ? nil : [highlightView_ rnSelectedText];
136
+ return text == nil ? std::string{} : std::string(text.UTF8String);
137
+ });
138
+
139
+ // A press that the window stops being able to finish. Registered with no
140
+ // object, and filtered on delivery: at construction the root is not in a
141
+ // window yet, so there is nothing to register *for*.
142
+ resignObserver_ = [NSNotificationCenter.defaultCenter
143
+ addObserverForName:NSWindowDidResignKeyNotification
144
+ object:nil
145
+ queue:nil
146
+ usingBlock:^(NSNotification *notification) {
147
+ this->windowResignedKey((__bridge void *)notification.object);
148
+ }];
149
+ }
150
+
151
+ void AppKitTouchDispatcher::windowResignedKey(void *window) {
152
+ // Only this root's window. Another window losing focus is not this press's
153
+ // business, and with several windows open every one of them would otherwise
154
+ // cancel every other's touch.
155
+ if (surfaceRoot_ == nil || surfaceRoot_.window != (__bridge NSWindow *)window) {
156
+ return;
157
+ }
158
+ // A no-op when nothing is down, which is why this needs no state of its own.
159
+ dispatchTouchCancel();
128
160
  }
129
161
 
130
162
  AppKitTouchDispatcher::~AppKitTouchDispatcher() {
163
+ if (resignObserver_ != nil) {
164
+ [NSNotificationCenter.defaultCenter removeObserver:resignObserver_];
165
+ resignObserver_ = nil;
166
+ }
131
167
  // The root's reference is weak, so it goes nil on its own; clearing the back
132
168
  // pointer first means an event already in flight cannot reach a dead object.
133
169
  ((RnAppKitInputTarget *)inputTarget_).dispatcher = nullptr;
134
170
  inputTarget_ = nil;
171
+ // The provider holds `this`: a Copy role performed after a reload would
172
+ // otherwise ask a destroyed dispatcher for the selection.
173
+ basalt::setSelectedTextProvider(nullptr);
174
+ selectionView_ = nil;
175
+ highlightView_ = nil;
135
176
  }
136
177
 
137
178
  void AppKitTouchDispatcher::synthesiseTap(double x, double y) {
@@ -227,6 +268,53 @@ static Tag targetUnder(RnAppKitView *surfaceRoot, double x, double y, NSPoint *o
227
268
  return 0;
228
269
  }
229
270
 
271
+ std::pair<RnAppKitView *, NSInteger> AppKitTouchDispatcher::selectableTextAt(double x,
272
+ double y) const {
273
+ if (surfaceRoot_ == nil) {
274
+ return {nil, -1};
275
+ }
276
+ // From the resolved hit, like every other hit test here, so that a box-none
277
+ // ancestor that swallows a press swallows a selection too.
278
+ RnAppKitView *hit = RnAppKitHitTest(surfaceRoot_, x, y);
279
+ for (NSView *view = hit; view != nil; view = view.superview) {
280
+ if ([view isKindOfClass:[RnAppKitView class]] && ((RnAppKitView *)view).rnTextSelectable) {
281
+ RnAppKitView *paragraph = (RnAppKitView *)view;
282
+ return {paragraph, textIndexIn(paragraph, x, y)};
283
+ }
284
+ if (view == surfaceRoot_) {
285
+ break;
286
+ }
287
+ }
288
+ return {nil, -1};
289
+ }
290
+
291
+ NSInteger AppKitTouchDispatcher::textIndexIn(RnAppKitView *view, double x, double y) const {
292
+ if (view == nil || surfaceRoot_ == nil) {
293
+ return -1;
294
+ }
295
+ // AppKit computes the transform, so a scrolled or transformed ancestor counts
296
+ // -- the same reason targetUnder asks it rather than summing frames.
297
+ const NSPoint inView = [view convertPoint:NSMakePoint(x, y) fromView:surfaceRoot_];
298
+ return [view rnTextIndexAtPoint:CGPointMake(inView.x, inView.y)];
299
+ }
300
+
301
+ void AppKitTouchDispatcher::drawSelection() {
302
+ // The paragraph that was holding a highlight, when it is not this one: a
303
+ // press in a second <Text> ends the first one's selection, which is what
304
+ // every desktop does.
305
+ if (highlightView_ != nil && highlightView_ != selectionView_) {
306
+ [highlightView_ setRnTextSelectionStart:0 length:0];
307
+ highlightView_ = nil;
308
+ }
309
+ if (selectionView_ == nil) {
310
+ return;
311
+ }
312
+
313
+ const basalt::TextSelectionRange range = selection_.range();
314
+ [selectionView_ setRnTextSelectionStart:range.start length:range.length];
315
+ highlightView_ = range.empty() ? nil : selectionView_;
316
+ }
317
+
230
318
  void AppKitTouchDispatcher::dispatchTouchStart(double x, double y, basalt::PointerButton button) {
231
319
  NSPoint origin = NSZeroPoint;
232
320
  const Tag target = targetUnder(surfaceRoot_, x, y, &origin);
@@ -246,6 +334,21 @@ void AppKitTouchDispatcher::dispatchTouchStart(double x, double y, basalt::Point
246
334
  return;
247
335
  }
248
336
 
337
+ // Selectable text remembers where the press landed and nothing more: a press
338
+ // is also how a <Pressable> around this text is pressed, so claiming here
339
+ // would stop every tap on a selectable label. The press *does* clear whatever
340
+ // was selected, which is how a click dismisses a highlight. See
341
+ // core/TextSelection.h.
342
+ {
343
+ const auto [paragraph, index] = selectableTextAt(x, y);
344
+ selectionView_ = paragraph;
345
+ selection_.press(paragraph != nil ? static_cast<Tag>(paragraph.rnTag) : 0,
346
+ index < 0 ? 0 : static_cast<int>(index),
347
+ Point{.x = static_cast<facebook::react::Float>(x),
348
+ .y = static_cast<facebook::react::Float>(y)});
349
+ drawSelection();
350
+ }
351
+
249
352
  if (!basalt::gestures().empty()) {
250
353
  basalt::gestures().pointerDown(
251
354
  hitChain(surfaceRoot_, x, y), x, y, basalt::monotonicMilliseconds());
@@ -261,6 +364,19 @@ void AppKitTouchDispatcher::dispatchTouchStart(double x, double y, basalt::Point
261
364
  }
262
365
 
263
366
  void AppKitTouchDispatcher::dispatchTouchMove(double x, double y) {
367
+ // A selection that has claimed the pointer owns every move after it, and this
368
+ // is before the guards below on purpose: claiming *cancelled* the touch, so
369
+ // `isDown_` is false and `activeTarget_` is zero from then on, and a check
370
+ // after them would extend a selection exactly once. The gesture recognisers
371
+ // are not fed either -- they were sent a cancel with the touch.
372
+ if (selection_.dragging()) {
373
+ selection_.moveTo(static_cast<int>(textIndexIn(selectionView_, x, y)),
374
+ Point{.x = static_cast<facebook::react::Float>(x),
375
+ .y = static_cast<facebook::react::Float>(y)});
376
+ drawSelection();
377
+ return;
378
+ }
379
+
264
380
  if (!basalt::gestures().empty()) {
265
381
  basalt::gestures().pointerMove(x, y, basalt::monotonicMilliseconds());
266
382
  }
@@ -271,6 +387,17 @@ void AppKitTouchDispatcher::dispatchTouchMove(double x, double y) {
271
387
  if (!isDown_ || activeTarget_ == 0) {
272
388
  return;
273
389
  }
390
+
391
+ // The move that turns a press inside selectable text into a selection: far
392
+ // enough from where it started to be a sweep rather than a tap.
393
+ if (selection_.moveTo(static_cast<int>(textIndexIn(selectionView_, x, y)),
394
+ Point{.x = static_cast<facebook::react::Float>(x),
395
+ .y = static_cast<facebook::react::Float>(y)})) {
396
+ drawSelection();
397
+ dispatchTouchCancel();
398
+ return;
399
+ }
400
+
274
401
  if (yieldToGesture(x, y)) {
275
402
  return;
276
403
  }
@@ -289,12 +416,25 @@ void AppKitTouchDispatcher::dispatchTouchEnd(double x, double y, basalt::Pointer
289
416
  }
290
417
  }
291
418
 
419
+ // The pointer is up, so a selection stops growing. The highlight stays: a
420
+ // person lets go and expects the text to stay selected until they click.
421
+ const bool wasSelecting = selection_.dragging();
422
+ selection_.release();
423
+
292
424
  // And nothing else for a button that never pressed anything -- there is no
293
425
  // touch to end, no gesture to finish and no <Switch> to toggle.
294
426
  if (!basalt::isPressButton(button)) {
295
427
  return;
296
428
  }
297
429
 
430
+ // A selection already cancelled the touch sequence, so there is no touch to
431
+ // end and nothing to press: the release belongs to the selection.
432
+ if (wasSelecting) {
433
+ isDown_ = false;
434
+ activeTarget_ = 0;
435
+ return;
436
+ }
437
+
298
438
  if (!basalt::gestures().empty()) {
299
439
  basalt::gestures().pointerUp(x, y, basalt::monotonicMilliseconds());
300
440
  }