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