basalt-appkit 0.0.0-rc5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/cli/runMacos.js +33 -0
  3. package/cli/shared.js +75 -0
  4. package/native/CMakeLists.txt +298 -0
  5. package/native/appkit/AppKitAnimationChoreographer.h +54 -0
  6. package/native/appkit/AppKitAnimationChoreographer.mm +125 -0
  7. package/native/appkit/AppKitDropTarget.h +34 -0
  8. package/native/appkit/AppKitDropTarget.mm +292 -0
  9. package/native/appkit/AppKitFocus.h +120 -0
  10. package/native/appkit/AppKitFocus.mm +293 -0
  11. package/native/appkit/AppKitImageLoader.h +80 -0
  12. package/native/appkit/AppKitImageLoader.mm +156 -0
  13. package/native/appkit/AppKitKeyEvents.h +26 -0
  14. package/native/appkit/AppKitKeyEvents.mm +97 -0
  15. package/native/appkit/AppKitMenuBar.mm +424 -0
  16. package/native/appkit/AppKitMountingManager.h +135 -0
  17. package/native/appkit/AppKitMountingManager.mm +946 -0
  18. package/native/appkit/AppKitRunLoopObserver.h +34 -0
  19. package/native/appkit/AppKitRunLoopObserver.mm +42 -0
  20. package/native/appkit/AppKitScrollView.h +168 -0
  21. package/native/appkit/AppKitScrollView.mm +550 -0
  22. package/native/appkit/AppKitSkiaContext.h +33 -0
  23. package/native/appkit/AppKitSkiaContext.mm +255 -0
  24. package/native/appkit/AppKitSkiaModule.h +53 -0
  25. package/native/appkit/AppKitSkiaModule.mm +81 -0
  26. package/native/appkit/AppKitSkiaPeer.h +37 -0
  27. package/native/appkit/AppKitSkiaPeer.mm +123 -0
  28. package/native/appkit/AppKitSkiaView.h +62 -0
  29. package/native/appkit/AppKitSnapshot.h +21 -0
  30. package/native/appkit/AppKitSnapshot.mm +91 -0
  31. package/native/appkit/AppKitTextInput.h +140 -0
  32. package/native/appkit/AppKitTextInput.mm +588 -0
  33. package/native/appkit/AppKitTextPeer.h +74 -0
  34. package/native/appkit/AppKitTextPeer.mm +353 -0
  35. package/native/appkit/AppKitTitleBar.h +90 -0
  36. package/native/appkit/AppKitTitleBar.mm +186 -0
  37. package/native/appkit/AppKitTouchDispatcher.h +138 -0
  38. package/native/appkit/AppKitTouchDispatcher.mm +514 -0
  39. package/native/appkit/AppKitWindowControl.mm +222 -0
  40. package/native/appkit/AppKitWindowModule.h +62 -0
  41. package/native/appkit/AppKitWindowModule.mm +194 -0
  42. package/native/appkit/ColorSchemeAppKit.mm +71 -0
  43. package/native/appkit/ComponentRegistryAppKit.mm +121 -0
  44. package/native/appkit/CoreTextLayout.h +43 -0
  45. package/native/appkit/CoreTextLayout.mm +252 -0
  46. package/native/appkit/CoreTextLayoutManager.mm +138 -0
  47. package/native/appkit/FontRegistryCoreText.mm +148 -0
  48. package/native/appkit/PlatformServicesAppKit.mm +540 -0
  49. package/native/appkit/RnAppKitView.h +417 -0
  50. package/native/appkit/RnAppKitView.mm +1605 -0
  51. package/native/appkit/RnTextLayout.h +38 -0
  52. package/native/appkit/RnTextLayout.mm +168 -0
  53. package/native/appkit/demo_layout_appkit.mm +93 -0
  54. package/native/appkit/main_appkit.mm +1860 -0
  55. package/native/appkit/mount_harness_appkit.mm +197 -0
  56. package/native/tests/test_appkit_accessibility.mm +170 -0
  57. package/native/tests/test_appkit_image.mm +237 -0
  58. package/native/tests/test_appkit_input.mm +852 -0
  59. package/native/tests/test_appkit_key_events.mm +150 -0
  60. package/native/tests/test_appkit_mounting.mm +430 -0
  61. package/native/tests/test_appkit_scrollbar.mm +132 -0
  62. package/native/tests/test_appkit_services.mm +57 -0
  63. package/native/tests/test_appkit_skia.mm +212 -0
  64. package/native/tests/test_appkit_text.mm +180 -0
  65. package/native/tests/test_appkit_textinput.mm +748 -0
  66. package/native/tests/test_appkit_titlebar.mm +107 -0
  67. package/native/tests/test_appkit_view.mm +237 -0
  68. package/package.json +26 -0
  69. package/react-native.config.js +22 -0
@@ -0,0 +1,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