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.
- package/native/CMakeLists.txt +16 -2
- package/native/appkit/AppKitAppWindow.h +24 -0
- package/native/appkit/AppKitImageLoader.h +48 -1
- package/native/appkit/AppKitImageLoader.mm +228 -10
- package/native/appkit/AppKitMenuBar.mm +58 -0
- package/native/appkit/AppKitMountingManager.h +34 -0
- package/native/appkit/AppKitMountingManager.mm +582 -12
- package/native/appkit/AppKitScrollView.h +48 -0
- package/native/appkit/AppKitScrollView.mm +210 -0
- package/native/appkit/AppKitSkiaPeer.h +15 -0
- package/native/appkit/AppKitTextInput.h +38 -1
- package/native/appkit/AppKitTextInput.mm +235 -8
- package/native/appkit/AppKitTextPeer.h +60 -0
- package/native/appkit/AppKitTextPeer.mm +307 -0
- package/native/appkit/AppKitTouchDispatcher.h +56 -0
- package/native/appkit/AppKitTouchDispatcher.mm +140 -0
- package/native/appkit/AppKitWindowControl.mm +38 -3
- package/native/appkit/ComponentRegistryAppKit.mm +28 -3
- package/native/appkit/CoreTextLayout.h +64 -2
- package/native/appkit/CoreTextLayout.mm +431 -71
- package/native/appkit/CoreTextLayoutManager.mm +57 -6
- package/native/appkit/FontRegistryCoreText.mm +6 -5
- package/native/appkit/PlatformServicesAppKit.mm +101 -20
- package/native/appkit/RnAppKitView.h +492 -0
- package/native/appkit/RnAppKitView.mm +2266 -23
- package/native/appkit/RnTextLayout.h +106 -0
- package/native/appkit/RnTextLayout.mm +321 -2
- package/native/appkit/main_appkit.mm +174 -3
- package/native/tests/test_appkit_accessibility.mm +289 -0
- package/native/tests/test_appkit_borders.mm +975 -0
- package/native/tests/test_appkit_fonts.mm +203 -0
- package/native/tests/test_appkit_image.mm +392 -17
- package/native/tests/test_appkit_input.mm +209 -0
- package/native/tests/test_appkit_mounting.mm +360 -0
- package/native/tests/test_appkit_scrollbar.mm +125 -0
- package/native/tests/test_appkit_scrollview.mm +371 -0
- package/native/tests/test_appkit_selection.mm +349 -0
- package/native/tests/test_appkit_services.mm +51 -1
- package/native/tests/test_appkit_text.mm +1628 -0
- package/native/tests/test_appkit_textinput.mm +819 -0
- package/native/tests/test_appkit_titlebar.mm +43 -0
- package/native/tests/test_appkit_view.mm +609 -0
- 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
|
}
|