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.
- package/LICENSE +21 -0
- package/cli/runMacos.js +33 -0
- package/cli/shared.js +75 -0
- package/native/CMakeLists.txt +298 -0
- package/native/appkit/AppKitAnimationChoreographer.h +54 -0
- package/native/appkit/AppKitAnimationChoreographer.mm +125 -0
- package/native/appkit/AppKitDropTarget.h +34 -0
- package/native/appkit/AppKitDropTarget.mm +292 -0
- package/native/appkit/AppKitFocus.h +120 -0
- package/native/appkit/AppKitFocus.mm +293 -0
- package/native/appkit/AppKitImageLoader.h +80 -0
- package/native/appkit/AppKitImageLoader.mm +156 -0
- package/native/appkit/AppKitKeyEvents.h +26 -0
- package/native/appkit/AppKitKeyEvents.mm +97 -0
- package/native/appkit/AppKitMenuBar.mm +424 -0
- package/native/appkit/AppKitMountingManager.h +135 -0
- package/native/appkit/AppKitMountingManager.mm +946 -0
- package/native/appkit/AppKitRunLoopObserver.h +34 -0
- package/native/appkit/AppKitRunLoopObserver.mm +42 -0
- package/native/appkit/AppKitScrollView.h +168 -0
- package/native/appkit/AppKitScrollView.mm +550 -0
- package/native/appkit/AppKitSkiaContext.h +33 -0
- package/native/appkit/AppKitSkiaContext.mm +255 -0
- package/native/appkit/AppKitSkiaModule.h +53 -0
- package/native/appkit/AppKitSkiaModule.mm +81 -0
- package/native/appkit/AppKitSkiaPeer.h +37 -0
- package/native/appkit/AppKitSkiaPeer.mm +123 -0
- package/native/appkit/AppKitSkiaView.h +62 -0
- package/native/appkit/AppKitSnapshot.h +21 -0
- package/native/appkit/AppKitSnapshot.mm +91 -0
- package/native/appkit/AppKitTextInput.h +140 -0
- package/native/appkit/AppKitTextInput.mm +588 -0
- package/native/appkit/AppKitTextPeer.h +74 -0
- package/native/appkit/AppKitTextPeer.mm +353 -0
- package/native/appkit/AppKitTitleBar.h +90 -0
- package/native/appkit/AppKitTitleBar.mm +186 -0
- package/native/appkit/AppKitTouchDispatcher.h +138 -0
- package/native/appkit/AppKitTouchDispatcher.mm +514 -0
- package/native/appkit/AppKitWindowControl.mm +222 -0
- package/native/appkit/AppKitWindowModule.h +62 -0
- package/native/appkit/AppKitWindowModule.mm +194 -0
- package/native/appkit/ColorSchemeAppKit.mm +71 -0
- package/native/appkit/ComponentRegistryAppKit.mm +121 -0
- package/native/appkit/CoreTextLayout.h +43 -0
- package/native/appkit/CoreTextLayout.mm +252 -0
- package/native/appkit/CoreTextLayoutManager.mm +138 -0
- package/native/appkit/FontRegistryCoreText.mm +148 -0
- package/native/appkit/PlatformServicesAppKit.mm +540 -0
- package/native/appkit/RnAppKitView.h +417 -0
- package/native/appkit/RnAppKitView.mm +1605 -0
- package/native/appkit/RnTextLayout.h +38 -0
- package/native/appkit/RnTextLayout.mm +168 -0
- package/native/appkit/demo_layout_appkit.mm +93 -0
- package/native/appkit/main_appkit.mm +1860 -0
- package/native/appkit/mount_harness_appkit.mm +197 -0
- package/native/tests/test_appkit_accessibility.mm +170 -0
- package/native/tests/test_appkit_image.mm +237 -0
- package/native/tests/test_appkit_input.mm +852 -0
- package/native/tests/test_appkit_key_events.mm +150 -0
- package/native/tests/test_appkit_mounting.mm +430 -0
- package/native/tests/test_appkit_scrollbar.mm +132 -0
- package/native/tests/test_appkit_services.mm +57 -0
- package/native/tests/test_appkit_skia.mm +212 -0
- package/native/tests/test_appkit_text.mm +180 -0
- package/native/tests/test_appkit_textinput.mm +748 -0
- package/native/tests/test_appkit_titlebar.mm +107 -0
- package/native/tests/test_appkit_view.mm +237 -0
- package/package.json +26 -0
- package/react-native.config.js +22 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#import "AppKitDropTarget.h"
|
|
2
|
+
|
|
3
|
+
#include "DragAndDrop.h"
|
|
4
|
+
|
|
5
|
+
#import <AppKit/AppKit.h>
|
|
6
|
+
|
|
7
|
+
#include <string>
|
|
8
|
+
|
|
9
|
+
namespace basalt {
|
|
10
|
+
|
|
11
|
+
facebook::react::Tag dropTargetAt(RnAppKitView *root, double x, double y, std::uint16_t accepts) {
|
|
12
|
+
if (root == nil) {
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
// `RnAppKitHitTest`, not NSView's `hitTest:`. The platform's own is what
|
|
16
|
+
// touches use -- it honours pointer-events, clipping, transforms and z
|
|
17
|
+
// order the way this renderer means them -- and AppKit's stops at the first
|
|
18
|
+
// subview whose own hitTest: answers, which for these views is the page
|
|
19
|
+
// rather than the target inside it. Using the wrong one found a view two
|
|
20
|
+
// steps below the root and no marker anywhere above it.
|
|
21
|
+
//
|
|
22
|
+
// From there the walk up is ours, for the reason core/DragAndDrop.h gives:
|
|
23
|
+
// a label inside a drop target should not need marking.
|
|
24
|
+
RnAppKitView *hit = RnAppKitHitTest(root, x, y);
|
|
25
|
+
for (NSView *view = hit; view != nil; view = view.superview) {
|
|
26
|
+
if ([view isKindOfClass:[RnAppKitView class]]) {
|
|
27
|
+
auto *candidate = (RnAppKitView *)view;
|
|
28
|
+
NSString *identifier = candidate.rnNativeId;
|
|
29
|
+
if (identifier != nil) {
|
|
30
|
+
const std::uint16_t wanted = dropAcceptsFrom(identifier.UTF8String);
|
|
31
|
+
if (wanted != DropAcceptsNone && (wanted & accepts) != 0) {
|
|
32
|
+
return (facebook::react::Tag)candidate.rnTag;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (view == root) {
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
} // namespace basalt
|
|
44
|
+
|
|
45
|
+
// What AppKit asks about the drag while it is in flight.
|
|
46
|
+
//
|
|
47
|
+
// A class of its own because RnAppKitView does not conform to
|
|
48
|
+
// NSDraggingSource, and should not: a view is a drag source only while a drag
|
|
49
|
+
// it started is running, and that is a session's lifetime rather than a
|
|
50
|
+
// view's.
|
|
51
|
+
@interface RnDragSource : NSObject <NSDraggingSource>
|
|
52
|
+
@end
|
|
53
|
+
|
|
54
|
+
@implementation RnDragSource
|
|
55
|
+
|
|
56
|
+
- (NSDragOperation)draggingSession:(NSDraggingSession *)session
|
|
57
|
+
sourceOperationMaskForDraggingContext:(NSDraggingContext)context {
|
|
58
|
+
(void)session;
|
|
59
|
+
// Copy in both contexts. This platform has no notion of a drag that moves
|
|
60
|
+
// or links, and offering one it cannot honour would be a cursor promising
|
|
61
|
+
// something the drop does not do -- the same rule the drop side applies.
|
|
62
|
+
(void)context;
|
|
63
|
+
return NSDragOperationCopy;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@end
|
|
67
|
+
|
|
68
|
+
namespace basalt {
|
|
69
|
+
namespace {
|
|
70
|
+
|
|
71
|
+
// Alive for as long as the session is. AppKit holds the source weakly, and a
|
|
72
|
+
// source collected mid-drag is a drag that stops answering.
|
|
73
|
+
RnDragSource *gDragSource = nil;
|
|
74
|
+
|
|
75
|
+
} // namespace
|
|
76
|
+
|
|
77
|
+
DragPayload dragPayloadAt(RnAppKitView *root, double x, double y) {
|
|
78
|
+
if (root == nil) {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
RnAppKitView *hit = RnAppKitHitTest(root, x, y);
|
|
82
|
+
// The same walk the drop side makes: a label inside a draggable view should
|
|
83
|
+
// be draggable without being marked itself.
|
|
84
|
+
for (NSView *view = hit; view != nil; view = view.superview) {
|
|
85
|
+
if ([view isKindOfClass:[RnAppKitView class]]) {
|
|
86
|
+
NSString *identifier = ((RnAppKitView *)view).rnNativeId;
|
|
87
|
+
if (identifier != nil) {
|
|
88
|
+
const DragPayload payload = dragPayloadFrom(identifier.UTF8String);
|
|
89
|
+
if (!payload.empty()) {
|
|
90
|
+
return payload;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (view == root) {
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
bool beginDragIfMarked(RnAppKitView *root, double x, double y) {
|
|
102
|
+
const DragPayload payload = dragPayloadAt(root, x, y);
|
|
103
|
+
if (payload.empty()) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// What the rest of the desktop will receive. A file URL for a file and a
|
|
108
|
+
// string for text, which are the two every other application understands.
|
|
109
|
+
id<NSPasteboardWriting> item = nil;
|
|
110
|
+
if (payload.hasFiles()) {
|
|
111
|
+
item = [NSURL fileURLWithPath:[NSString stringWithUTF8String:payload.files.front().c_str()]];
|
|
112
|
+
} else {
|
|
113
|
+
item = [NSString stringWithUTF8String:payload.text.c_str()];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
NSDraggingItem *dragged =
|
|
117
|
+
[[NSDraggingItem alloc] initWithPasteboardWriter:item];
|
|
118
|
+
// Something to see while dragging. A plain rectangle where the view is,
|
|
119
|
+
// rather than a snapshot: a snapshot means rendering the view again into an
|
|
120
|
+
// image, and the drag has to start on this event or not at all.
|
|
121
|
+
const NSRect frame = NSMakeRect(x - 40, y - 20, 80, 40);
|
|
122
|
+
[dragged setDraggingFrame:frame contents:nil];
|
|
123
|
+
|
|
124
|
+
NSEvent *event = NSApp.currentEvent;
|
|
125
|
+
if (event == nil) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
if (gDragSource == nil) {
|
|
129
|
+
gDragSource = [[RnDragSource alloc] init];
|
|
130
|
+
}
|
|
131
|
+
[root beginDraggingSessionWithItems:@[ dragged ] event:event source:gDragSource];
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
} // namespace basalt
|
|
136
|
+
|
|
137
|
+
// The dragging destination itself.
|
|
138
|
+
//
|
|
139
|
+
// On a view of its own, layered over the surface root, rather than on
|
|
140
|
+
// RnAppKitView: every RnAppKitView would otherwise inherit the protocol and
|
|
141
|
+
// AppKit would ask the deepest one, which is the opposite of the rule -- the
|
|
142
|
+
// deepest *marked* view decides, and most views are not marked.
|
|
143
|
+
@interface RnDropTargetView : NSView
|
|
144
|
+
@property(nonatomic, weak, nullable) RnAppKitView *rnRoot;
|
|
145
|
+
@property(nonatomic) facebook::react::Tag rnCurrent;
|
|
146
|
+
@end
|
|
147
|
+
|
|
148
|
+
@implementation RnDropTargetView
|
|
149
|
+
|
|
150
|
+
// Not drawn and not hit-testable for anything but drags: a view that swallowed
|
|
151
|
+
// clicks would break every press in the app.
|
|
152
|
+
- (NSView *)hitTest:(NSPoint)point {
|
|
153
|
+
(void)point;
|
|
154
|
+
return nil;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
- (std::uint16_t)rnOfferedBy:(id<NSDraggingInfo>)info {
|
|
158
|
+
std::uint16_t offered = basalt::DropAcceptsNone;
|
|
159
|
+
NSPasteboard *board = info.draggingPasteboard;
|
|
160
|
+
if ([board canReadObjectForClasses:@[ [NSURL class] ]
|
|
161
|
+
options:@{NSPasteboardURLReadingFileURLsOnlyKey : @YES}]) {
|
|
162
|
+
offered |= basalt::DropAcceptsFiles;
|
|
163
|
+
}
|
|
164
|
+
if ([board canReadObjectForClasses:@[ [NSString class] ] options:@{}]) {
|
|
165
|
+
offered |= basalt::DropAcceptsText;
|
|
166
|
+
}
|
|
167
|
+
return offered;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The point a drag is over, in the root's coordinates.
|
|
171
|
+
- (NSPoint)rnPointFor:(id<NSDraggingInfo>)info {
|
|
172
|
+
RnAppKitView *root = self.rnRoot;
|
|
173
|
+
if (root == nil) {
|
|
174
|
+
return NSZeroPoint;
|
|
175
|
+
}
|
|
176
|
+
return [root convertPoint:info.draggingLocation fromView:nil];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
- (void)rnReportLeaveAt:(NSPoint)point {
|
|
180
|
+
if (self.rnCurrent == 0) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
basalt::DropEvent event;
|
|
184
|
+
event.tag = self.rnCurrent;
|
|
185
|
+
event.phase = basalt::DropPhase::Leave;
|
|
186
|
+
event.x = point.x;
|
|
187
|
+
event.y = point.y;
|
|
188
|
+
basalt::reportDrop(event);
|
|
189
|
+
self.rnCurrent = 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
- (NSDragOperation)rnUpdate:(id<NSDraggingInfo>)info {
|
|
193
|
+
const NSPoint point = [self rnPointFor:info];
|
|
194
|
+
const facebook::react::Tag found =
|
|
195
|
+
basalt::dropTargetAt(self.rnRoot, point.x, point.y, [self rnOfferedBy:info]);
|
|
196
|
+
|
|
197
|
+
if (found != self.rnCurrent) {
|
|
198
|
+
[self rnReportLeaveAt:point];
|
|
199
|
+
}
|
|
200
|
+
if (found == 0) {
|
|
201
|
+
// Nothing here takes it, which is what makes the cursor say so.
|
|
202
|
+
return NSDragOperationNone;
|
|
203
|
+
}
|
|
204
|
+
self.rnCurrent = found;
|
|
205
|
+
|
|
206
|
+
basalt::DropEvent event;
|
|
207
|
+
event.tag = found;
|
|
208
|
+
event.phase = basalt::DropPhase::Over;
|
|
209
|
+
event.x = point.x;
|
|
210
|
+
event.y = point.y;
|
|
211
|
+
basalt::reportDrop(event);
|
|
212
|
+
return NSDragOperationCopy;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender {
|
|
216
|
+
return [self rnUpdate:sender];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender {
|
|
220
|
+
return [self rnUpdate:sender];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
- (void)draggingExited:(id<NSDraggingInfo>)sender {
|
|
224
|
+
[self rnReportLeaveAt:sender != nil ? [self rnPointFor:sender] : NSZeroPoint];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender {
|
|
228
|
+
NSPasteboard *board = sender.draggingPasteboard;
|
|
229
|
+
|
|
230
|
+
basalt::DragPayload payload;
|
|
231
|
+
NSArray<NSURL *> *urls =
|
|
232
|
+
[board readObjectsForClasses:@[ [NSURL class] ]
|
|
233
|
+
options:@{NSPasteboardURLReadingFileURLsOnlyKey : @YES}];
|
|
234
|
+
for (NSURL *url in urls) {
|
|
235
|
+
if (url.isFileURL && url.path != nil) {
|
|
236
|
+
// A path, not a URL: core/DragAndDrop.h says every host hands back
|
|
237
|
+
// something path-shaped, and an app that wanted a URL can make one.
|
|
238
|
+
payload.files.emplace_back(url.path.UTF8String);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
NSString *text = [board stringForType:NSPasteboardTypeString];
|
|
242
|
+
if (text != nil) {
|
|
243
|
+
payload.text = text.UTF8String;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Asked again from the contents rather than from what the pasteboard could
|
|
247
|
+
// read, for the reason the GTK host gives: this is the moment the payload is
|
|
248
|
+
// known.
|
|
249
|
+
std::uint16_t carries = basalt::DropAcceptsNone;
|
|
250
|
+
if (payload.hasFiles()) {
|
|
251
|
+
carries |= basalt::DropAcceptsFiles;
|
|
252
|
+
}
|
|
253
|
+
if (payload.hasText()) {
|
|
254
|
+
carries |= basalt::DropAcceptsText;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const NSPoint point = [self rnPointFor:sender];
|
|
258
|
+
const facebook::react::Tag found =
|
|
259
|
+
basalt::dropTargetAt(self.rnRoot, point.x, point.y, carries);
|
|
260
|
+
self.rnCurrent = 0;
|
|
261
|
+
if (found == 0) {
|
|
262
|
+
return NO;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
basalt::DropEvent event;
|
|
266
|
+
event.tag = found;
|
|
267
|
+
event.phase = basalt::DropPhase::Drop;
|
|
268
|
+
event.x = point.x;
|
|
269
|
+
event.y = point.y;
|
|
270
|
+
event.payload = payload;
|
|
271
|
+
basalt::reportDrop(event);
|
|
272
|
+
return YES;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
@end
|
|
276
|
+
|
|
277
|
+
namespace basalt {
|
|
278
|
+
|
|
279
|
+
void attachDropTarget(RnAppKitView *root) {
|
|
280
|
+
if (root == nil) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
RnDropTargetView *target = [[RnDropTargetView alloc] initWithFrame:root.bounds];
|
|
284
|
+
target.rnRoot = root;
|
|
285
|
+
target.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
|
|
286
|
+
[target registerForDraggedTypes:@[ NSPasteboardTypeFileURL, NSPasteboardTypeString ]];
|
|
287
|
+
// Above everything, so no app view can shadow the destination -- it is not
|
|
288
|
+
// hit-testable for clicks, so being on top costs nothing.
|
|
289
|
+
[root addSubview:target positioned:NSWindowAbove relativeTo:nil];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
} // namespace basalt
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Keyboard focus, for everything that is not a <TextInput>.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately the same shape as basalt-gtk's GtkFocusManager,
|
|
4
|
+
// down to what makes a view focusable and what activating one dispatches,
|
|
5
|
+
// because none of that is about the toolkit: if the two diverge, an app's
|
|
6
|
+
// buttons will be reachable from the keyboard on one desktop and not the other.
|
|
7
|
+
//
|
|
8
|
+
// ## What makes a view focusable
|
|
9
|
+
//
|
|
10
|
+
// `accessible`. React Native has a `focusable` prop and it does not reach this
|
|
11
|
+
// platform: ReactCommon parses it only into Android's and tvOS's
|
|
12
|
+
// `HostPlatformViewProps`, and the C++ host's is a bare alias of
|
|
13
|
+
// `BaseViewProps`. So the signal has to be something that does arrive, and
|
|
14
|
+
// `accessible` is both the nearest true thing and the right one -- it is what
|
|
15
|
+
// `<Pressable>` sets on everything it renders, and what a screen reader already
|
|
16
|
+
// stops on.
|
|
17
|
+
//
|
|
18
|
+
// ## What activating one does
|
|
19
|
+
//
|
|
20
|
+
// Dispatches `topClick` with an empty payload, which is what React Native for
|
|
21
|
+
// Android does from `ReactViewManager.setFocusable`. Pressability listens for
|
|
22
|
+
// it and calls `onPress`, but only when the payload has no `pointerType` -- a
|
|
23
|
+
// click that came from a pointer would fire `onPress` twice. So this cannot go
|
|
24
|
+
// through `TouchEventEmitter::onClick`, which always carries one.
|
|
25
|
+
//
|
|
26
|
+
// ## The chain, which AppKit does not contribute
|
|
27
|
+
//
|
|
28
|
+
// This is the one place the two desktops genuinely differ. GTK hands over its
|
|
29
|
+
// focus chain: a focusable widget joins the window's order and Tab works. AppKit
|
|
30
|
+
// has a key-view loop and it is not usable here -- `selectNextKeyView:` needs
|
|
31
|
+
// `nextKeyView` links or `autorecalculatesKeyViewLoop`, both of which assume a
|
|
32
|
+
// window built from a nib, and a programmatically built one with neither simply
|
|
33
|
+
// does nothing. Which is exactly what it did: seven Tab presses, no focus.
|
|
34
|
+
//
|
|
35
|
+
// So the order is walked here, in tree order, which is what GTK's chain
|
|
36
|
+
// produces anyway. A <TextInput> is a stop like any other: the responder for
|
|
37
|
+
// one is its NSTextField peer rather than the view, because that is the thing
|
|
38
|
+
// AppKit will make first responder.
|
|
39
|
+
|
|
40
|
+
#pragma once
|
|
41
|
+
|
|
42
|
+
#include "KeyEvents.h"
|
|
43
|
+
|
|
44
|
+
#import "RnAppKitView.h"
|
|
45
|
+
|
|
46
|
+
#include "AppKitMountingManager.h"
|
|
47
|
+
|
|
48
|
+
namespace basalt {
|
|
49
|
+
|
|
50
|
+
class AppKitFocusManager {
|
|
51
|
+
public:
|
|
52
|
+
AppKitFocusManager(AppKitMountingManager *mountingManager, RnAppKitView *surfaceRoot);
|
|
53
|
+
~AppKitFocusManager();
|
|
54
|
+
|
|
55
|
+
AppKitFocusManager(const AppKitFocusManager &) = delete;
|
|
56
|
+
AppKitFocusManager &operator=(const AppKitFocusManager &) = delete;
|
|
57
|
+
AppKitFocusManager(AppKitFocusManager &&) = delete;
|
|
58
|
+
AppKitFocusManager &operator=(AppKitFocusManager &&) = delete;
|
|
59
|
+
|
|
60
|
+
// Moves focus to the next or previous focusable view, and reports whether
|
|
61
|
+
// anything took it. The keyboard's own Tab goes through AppKit; this is the
|
|
62
|
+
// entry point automation uses, for the same reason `synthesiseTap` exists --
|
|
63
|
+
// a real Tab needs a window the window server considers key, which an
|
|
64
|
+
// automated run does not reliably have.
|
|
65
|
+
bool moveFocus(bool forward);
|
|
66
|
+
|
|
67
|
+
// Activates whatever has focus, as Enter or space does.
|
|
68
|
+
bool activateFocused();
|
|
69
|
+
|
|
70
|
+
// A key press arriving at `view`, which is the first responder. Answers whether
|
|
71
|
+
// some view from there outwards declared this combination -- and reports it to
|
|
72
|
+
// JavaScript when one did. The answer has to come back in this call stack,
|
|
73
|
+
// because the caller consumes the key or passes it on by it; see
|
|
74
|
+
// core/KeyEvents.h.
|
|
75
|
+
bool handleKey(RnAppKitView *view, NSEvent *event);
|
|
76
|
+
|
|
77
|
+
// BASALT_TEST_KEY. The same delivery a real press gets, minus the NSEvent
|
|
78
|
+
// translation -- which is the half this cannot exercise and which
|
|
79
|
+
// tests/test_appkit_key_events.mm covers instead. See core/TestKeys.h.
|
|
80
|
+
bool deliverTestKey(const KeyCombination &pressed);
|
|
81
|
+
|
|
82
|
+
// A real key press that the responder chain was never going to bring here.
|
|
83
|
+
//
|
|
84
|
+
// `handleKey` above is reached from `RnAppKitView keyDown:`, which AppKit only
|
|
85
|
+
// calls on the first responder. An app whose shortcut holder is the root view
|
|
86
|
+
// has no first responder inside the React tree at all -- nothing focuses it,
|
|
87
|
+
// `focusable` only makes a view eligible -- so the press goes to the window
|
|
88
|
+
// and is gone. Every shortcut in such an app is dead, which is what kino was.
|
|
89
|
+
//
|
|
90
|
+
// Called from the window's key monitor for exactly that case, and only that
|
|
91
|
+
// case: with a text field or any other view focused, the responder chain does
|
|
92
|
+
// reach `handleKey` and this must not run as well.
|
|
93
|
+
bool deliverWindowKey(NSEvent *event);
|
|
94
|
+
|
|
95
|
+
// The tag of the focused view, or 0. For tests and for automation.
|
|
96
|
+
facebook::react::Tag focusedTag() const {
|
|
97
|
+
return focusedTag_;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The main-thread halves, called by the Objective-C trampoline.
|
|
101
|
+
void viewDidChangeFocus(RnAppKitView *view, bool focused);
|
|
102
|
+
bool activate(RnAppKitView *view);
|
|
103
|
+
|
|
104
|
+
private:
|
|
105
|
+
void emitFocus(facebook::react::Tag tag, bool focused);
|
|
106
|
+
bool dispatchClick(facebook::react::Tag tag);
|
|
107
|
+
|
|
108
|
+
// The path walk and the decision, shared by a real press and by the
|
|
109
|
+
// instrument. Only the translation above them differs.
|
|
110
|
+
bool deliverKeyFrom(RnAppKitView *view, const KeyCombination &pressed);
|
|
111
|
+
|
|
112
|
+
AppKitMountingManager *mountingManager_;
|
|
113
|
+
RnAppKitView *surfaceRoot_;
|
|
114
|
+
// The object the root forwards focus changes to. Held so it outlives the
|
|
115
|
+
// root's weak reference to it, exactly as the touch dispatcher does.
|
|
116
|
+
id focusTarget_;
|
|
117
|
+
facebook::react::Tag focusedTag_{0};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
} // namespace basalt
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#import "AppKitFocus.h"
|
|
2
|
+
|
|
3
|
+
#import "AppKitTextPeer.h"
|
|
4
|
+
|
|
5
|
+
#import "AppKitKeyEvents.h"
|
|
6
|
+
|
|
7
|
+
#include <optional>
|
|
8
|
+
#include <vector>
|
|
9
|
+
|
|
10
|
+
#include <folly/dynamic.h>
|
|
11
|
+
|
|
12
|
+
#include <react/renderer/components/view/ViewEventEmitter.h>
|
|
13
|
+
#include <react/renderer/core/EventEmitter.h>
|
|
14
|
+
|
|
15
|
+
// The bridge between AppKit's protocol and the C++ manager. A C++ object cannot
|
|
16
|
+
// conform to an Objective-C protocol, and the root holds its handler weakly, so
|
|
17
|
+
// this exists and the manager owns it -- the same arrangement
|
|
18
|
+
// AppKitTouchDispatcher has.
|
|
19
|
+
@interface RnAppKitFocusTarget : NSObject <RnAppKitFocusHandler>
|
|
20
|
+
@property(nonatomic, assign) basalt::AppKitFocusManager *manager;
|
|
21
|
+
@end
|
|
22
|
+
|
|
23
|
+
@implementation RnAppKitFocusTarget
|
|
24
|
+
|
|
25
|
+
- (void)rnView:(RnAppKitView *)view didChangeFocus:(BOOL)focused {
|
|
26
|
+
if (_manager != nullptr) {
|
|
27
|
+
_manager->viewDidChangeFocus(view, focused);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
- (BOOL)rnActivateView:(RnAppKitView *)view {
|
|
32
|
+
return _manager != nullptr && _manager->activate(view);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
- (BOOL)rnMoveFocusForward:(BOOL)forward {
|
|
36
|
+
return _manager != nullptr && _manager->moveFocus(forward) ? YES : NO;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
- (BOOL)rnView:(RnAppKitView *)view handlesKey:(NSEvent *)event {
|
|
40
|
+
return _manager != nullptr && _manager->handleKey(view, event) ? YES : NO;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@end
|
|
44
|
+
|
|
45
|
+
namespace basalt {
|
|
46
|
+
|
|
47
|
+
using facebook::react::RawEvent;
|
|
48
|
+
using facebook::react::Tag;
|
|
49
|
+
|
|
50
|
+
AppKitFocusManager::AppKitFocusManager(AppKitMountingManager *mountingManager,
|
|
51
|
+
RnAppKitView *surfaceRoot)
|
|
52
|
+
: mountingManager_(mountingManager), surfaceRoot_(surfaceRoot) {
|
|
53
|
+
RnAppKitFocusTarget *target = [[RnAppKitFocusTarget alloc] init];
|
|
54
|
+
target.manager = this;
|
|
55
|
+
focusTarget_ = target;
|
|
56
|
+
surfaceRoot_.rnFocusHandler = target;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
AppKitFocusManager::~AppKitFocusManager() {
|
|
60
|
+
// The root's reference is weak, so it goes nil on its own; clearing the back
|
|
61
|
+
// pointer first means a focus change already in flight cannot reach a dead
|
|
62
|
+
// object.
|
|
63
|
+
((RnAppKitFocusTarget *)focusTarget_).manager = nullptr;
|
|
64
|
+
focusTarget_ = nil;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
void AppKitFocusManager::viewDidChangeFocus(RnAppKitView *view, bool focused) {
|
|
68
|
+
const auto tag = static_cast<Tag>(view.rnTag);
|
|
69
|
+
if (focused) {
|
|
70
|
+
// A blur for the view that had it, before the focus for the one that took
|
|
71
|
+
// it. AppKit resigns before it makes anyone else first responder, so this
|
|
72
|
+
// is usually already true; it is written out because an app reading
|
|
73
|
+
// onFocus/onBlur as a pair must not see two focuses in a row.
|
|
74
|
+
if (focusedTag_ != 0 && focusedTag_ != tag) {
|
|
75
|
+
emitFocus(focusedTag_, false);
|
|
76
|
+
}
|
|
77
|
+
focusedTag_ = tag;
|
|
78
|
+
emitFocus(tag, true);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (focusedTag_ != tag) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
focusedTag_ = 0;
|
|
85
|
+
emitFocus(tag, false);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
void AppKitFocusManager::emitFocus(Tag tag, bool focused) {
|
|
89
|
+
const auto emitter = std::dynamic_pointer_cast<const facebook::react::ViewEventEmitter>(
|
|
90
|
+
mountingManager_->eventEmitterForTag(tag));
|
|
91
|
+
if (emitter == nullptr) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// `topFocus` and `topBlur`, which React Native registers as bubbling events
|
|
95
|
+
// with an empty payload. BaseViewEventEmitter has both, so nothing here has
|
|
96
|
+
// to know the names.
|
|
97
|
+
if (focused) {
|
|
98
|
+
emitter->onFocus();
|
|
99
|
+
} else {
|
|
100
|
+
emitter->onBlur();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
bool AppKitFocusManager::activate(RnAppKitView *view) {
|
|
105
|
+
return dispatchClick(static_cast<Tag>(view.rnTag));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
bool AppKitFocusManager::handleKey(RnAppKitView *view, NSEvent *event) {
|
|
109
|
+
// Cheap first: almost every app declares no keys at all, and every one of them
|
|
110
|
+
// presses some. Without this the walk below runs on every keystroke in every
|
|
111
|
+
// text field in every app that never asked for a shortcut.
|
|
112
|
+
if (handledKeyViewCount() == 0) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const std::optional<KeyCombination> pressed = keyCombinationFrom(event);
|
|
117
|
+
if (!pressed.has_value()) {
|
|
118
|
+
// A bare modifier or a dead key. Nothing to match and nothing to consume.
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
return deliverKeyFrom(view, *pressed);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
bool AppKitFocusManager::deliverKeyFrom(RnAppKitView *view, const KeyCombination &pressed) {
|
|
125
|
+
// From the first responder outwards, so the innermost claim wins -- the order
|
|
126
|
+
// core/KeyEvents.h's `handledBy` expects. Only RnAppKitViews, because only they
|
|
127
|
+
// have a React tag; a scroll view's clip view in between is not a step in the
|
|
128
|
+
// React tree.
|
|
129
|
+
std::vector<Tag> path;
|
|
130
|
+
for (NSView *current = view; current != nil; current = current.superview) {
|
|
131
|
+
if ([current isKindOfClass:[RnAppKitView class]]) {
|
|
132
|
+
path.push_back(static_cast<Tag>(((RnAppKitView *)current).rnTag));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
std::optional<Tag> handler = handledBy(path, pressed);
|
|
137
|
+
if (!handler.has_value() && focusedTag_ == 0) {
|
|
138
|
+
// Nothing focused, so there is no path to be outside of and no text field
|
|
139
|
+
// taking keys. A window-level shortcut holder is a child of the root rather
|
|
140
|
+
// than the root itself, so the walk above cannot reach it -- and that is
|
|
141
|
+
// every app that declares its shortcuts once at the top. See
|
|
142
|
+
// core/KeyEvents.h on why this is only safe unfocused.
|
|
143
|
+
handler = handledByAny(pressed);
|
|
144
|
+
}
|
|
145
|
+
if (!handler.has_value()) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
// Decided. Reporting is asynchronous from here -- it crosses to the JavaScript
|
|
149
|
+
// thread -- which is why the answer could not have come from there.
|
|
150
|
+
reportKey(*handler, pressed);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
bool AppKitFocusManager::deliverWindowKey(NSEvent *event) {
|
|
155
|
+
// Cheap first, for the same reason handleKey checks: most apps declare no
|
|
156
|
+
// keys, and this runs on every press that reaches the window.
|
|
157
|
+
if (handledKeyViewCount() == 0) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
const std::optional<KeyCombination> pressed = keyCombinationFrom(event);
|
|
161
|
+
if (!pressed.has_value()) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
// The same walk the scripted path takes: from whatever has focus, or the root
|
|
165
|
+
// when nothing does. That the two now share it is the point -- the instrument
|
|
166
|
+
// could reach a claim a real press could not, which is how this stayed hidden
|
|
167
|
+
// while a scenario passed.
|
|
168
|
+
return deliverTestKey(*pressed);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
bool AppKitFocusManager::deliverTestKey(const KeyCombination &pressed) {
|
|
172
|
+
// From whatever has focus, or from the root when nothing does -- which is the
|
|
173
|
+
// ordinary case for a window-level shortcut holder and the one a scenario
|
|
174
|
+
// exercises. A real press reaches the first responder, and with nothing
|
|
175
|
+
// focusable focused that is the root too.
|
|
176
|
+
RnAppKitView *from = nil;
|
|
177
|
+
if (focusedTag_ != 0 && mountingManager_ != nullptr) {
|
|
178
|
+
from = mountingManager_->viewForTag(focusedTag_);
|
|
179
|
+
}
|
|
180
|
+
if (from == nil) {
|
|
181
|
+
from = surfaceRoot_;
|
|
182
|
+
}
|
|
183
|
+
return deliverKeyFrom(from, pressed);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
bool AppKitFocusManager::activateFocused() {
|
|
187
|
+
if (focusedTag_ == 0) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
// A <Switch> is toggled from here too. It listens for no click -- React
|
|
191
|
+
// Native's is a controlled component driven by its own native control -- so
|
|
192
|
+
// without this a switch that Tab reaches is one Enter cannot work. The same
|
|
193
|
+
// call the touch path makes, and a no-op for every view that is not a switch.
|
|
194
|
+
mountingManager_->pressedView(focusedTag_);
|
|
195
|
+
return dispatchClick(focusedTag_);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
bool AppKitFocusManager::dispatchClick(Tag tag) {
|
|
199
|
+
// True whether or not anything is listening: the key belongs to the focused
|
|
200
|
+
// view either way, and letting it travel on because a view between a Remove
|
|
201
|
+
// and its Delete has no emitter would deliver it somewhere else.
|
|
202
|
+
const auto emitter = mountingManager_->eventEmitterForTag(tag);
|
|
203
|
+
if (emitter == nullptr) {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
// `topClick` with an empty payload, which is exactly what React Native for
|
|
207
|
+
// Android sends from a focusable view's OnClickListener. It cannot go through
|
|
208
|
+
// TouchEventEmitter::onClick: that carries a PointerEvent, and Pressability
|
|
209
|
+
// ignores a click with a `pointerType` on it so that a real click does not
|
|
210
|
+
// fire onPress twice. See the header.
|
|
211
|
+
emitter->dispatchEvent("click", folly::dynamic::object(), RawEvent::Category::Discrete);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
namespace {
|
|
216
|
+
|
|
217
|
+
// Everything Tab should stop on, in tree order.
|
|
218
|
+
//
|
|
219
|
+
// Tree order rather than paint order: zIndex changes what is on top and must
|
|
220
|
+
// not change what Tab reaches next, which is also what the GTK side's chain
|
|
221
|
+
// does. A <TextInput> contributes its peer rather than its view, because the
|
|
222
|
+
// peer is the thing AppKit will make first responder -- so a field sits in the
|
|
223
|
+
// same order as the buttons around it without this file knowing what a text
|
|
224
|
+
// input is beyond that.
|
|
225
|
+
void collectFocusStops(RnAppKitView *view, NSMutableArray<RnAppKitView *> *stops) {
|
|
226
|
+
if (view == nil || view.hidden) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (view.rnEditable != nil || view.rnFocusable) {
|
|
230
|
+
[stops addObject:view];
|
|
231
|
+
}
|
|
232
|
+
for (NSView *child in view.subviews) {
|
|
233
|
+
if ([child isKindOfClass:[RnAppKitView class]]) {
|
|
234
|
+
collectFocusStops((RnAppKitView *)child, stops);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// What AppKit should be asked to make first responder for a stop.
|
|
240
|
+
NSResponder *responderForStop(RnAppKitView *view) {
|
|
241
|
+
return view.rnEditable != nil ? (NSResponder *)view.rnEditable : (NSResponder *)view;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Whether this stop is the one that holds focus now. Not just a pointer
|
|
245
|
+
// comparison: while a single-line field is being edited the first responder is
|
|
246
|
+
// its *field editor* rather than the field.
|
|
247
|
+
bool stopHoldsFocus(RnAppKitView *view, NSResponder *current) {
|
|
248
|
+
if (current == nil) {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
if (responderForStop(view) == current) {
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
return view.rnEditable != nil && RnPeerEditor(view.rnEditable) == current;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
} // namespace
|
|
258
|
+
|
|
259
|
+
bool AppKitFocusManager::moveFocus(bool forward) {
|
|
260
|
+
NSWindow *window = surfaceRoot_.window;
|
|
261
|
+
if (window == nil) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
NSMutableArray<RnAppKitView *> *stops = [NSMutableArray array];
|
|
266
|
+
collectFocusStops(surfaceRoot_, stops);
|
|
267
|
+
if (stops.count == 0) {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Where focus is now. Not focusedTag_: a <TextInput> holds focus through its
|
|
272
|
+
// peer and never reports a tag here, so the responder is the question with an
|
|
273
|
+
// answer for both kinds of stop.
|
|
274
|
+
NSResponder *current = window.firstResponder;
|
|
275
|
+
NSUInteger index = [stops indexOfObjectPassingTest:^BOOL(RnAppKitView *stop, NSUInteger, BOOL *) {
|
|
276
|
+
return stopHoldsFocus(stop, current) ? YES : NO;
|
|
277
|
+
}];
|
|
278
|
+
|
|
279
|
+
NSUInteger next = 0;
|
|
280
|
+
if (index == NSNotFound) {
|
|
281
|
+
next = forward ? 0 : stops.count - 1;
|
|
282
|
+
} else if (forward) {
|
|
283
|
+
next = (index + 1) % stops.count;
|
|
284
|
+
} else {
|
|
285
|
+
next = (index + stops.count - 1) % stops.count;
|
|
286
|
+
}
|
|
287
|
+
// Wrapping, unlike GTK's `gtk_widget_child_focus` on a container, which stops
|
|
288
|
+
// at the end. A window's Tab order wraps on both desktops, and this is the
|
|
289
|
+
// window's order.
|
|
290
|
+
return [window makeFirstResponder:responderForStop(stops[next])] == YES;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
} // namespace basalt
|