basalt-appkit 0.0.0-rc5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/native/CMakeLists.txt +16 -2
  2. package/native/appkit/AppKitAppWindow.h +24 -0
  3. package/native/appkit/AppKitImageLoader.h +48 -1
  4. package/native/appkit/AppKitImageLoader.mm +228 -10
  5. package/native/appkit/AppKitMenuBar.mm +58 -0
  6. package/native/appkit/AppKitMountingManager.h +34 -0
  7. package/native/appkit/AppKitMountingManager.mm +576 -9
  8. package/native/appkit/AppKitScrollView.h +48 -0
  9. package/native/appkit/AppKitScrollView.mm +210 -0
  10. package/native/appkit/AppKitTextInput.h +38 -1
  11. package/native/appkit/AppKitTextInput.mm +235 -8
  12. package/native/appkit/AppKitTextPeer.h +60 -0
  13. package/native/appkit/AppKitTextPeer.mm +307 -0
  14. package/native/appkit/AppKitTouchDispatcher.h +56 -0
  15. package/native/appkit/AppKitTouchDispatcher.mm +140 -0
  16. package/native/appkit/AppKitWindowControl.mm +38 -3
  17. package/native/appkit/ComponentRegistryAppKit.mm +20 -0
  18. package/native/appkit/CoreTextLayout.h +64 -2
  19. package/native/appkit/CoreTextLayout.mm +431 -71
  20. package/native/appkit/CoreTextLayoutManager.mm +57 -6
  21. package/native/appkit/FontRegistryCoreText.mm +6 -5
  22. package/native/appkit/PlatformServicesAppKit.mm +101 -20
  23. package/native/appkit/RnAppKitView.h +492 -0
  24. package/native/appkit/RnAppKitView.mm +2266 -23
  25. package/native/appkit/RnTextLayout.h +106 -0
  26. package/native/appkit/RnTextLayout.mm +321 -2
  27. package/native/appkit/main_appkit.mm +174 -3
  28. package/native/tests/test_appkit_accessibility.mm +289 -0
  29. package/native/tests/test_appkit_borders.mm +975 -0
  30. package/native/tests/test_appkit_fonts.mm +203 -0
  31. package/native/tests/test_appkit_image.mm +392 -17
  32. package/native/tests/test_appkit_input.mm +209 -0
  33. package/native/tests/test_appkit_mounting.mm +360 -0
  34. package/native/tests/test_appkit_scrollbar.mm +125 -0
  35. package/native/tests/test_appkit_scrollview.mm +371 -0
  36. package/native/tests/test_appkit_selection.mm +349 -0
  37. package/native/tests/test_appkit_services.mm +51 -1
  38. package/native/tests/test_appkit_text.mm +1628 -0
  39. package/native/tests/test_appkit_textinput.mm +819 -0
  40. package/native/tests/test_appkit_titlebar.mm +43 -0
  41. package/native/tests/test_appkit_view.mm +609 -0
  42. package/package.json +4 -3
@@ -1,16 +1,41 @@
1
1
  #import "AppKitTextInput.h"
2
2
 
3
+ #include "TextChecking.h"
4
+
5
+ #include "FontScaling.h"
6
+
3
7
  #import "AppKitTextPeer.h"
4
8
 
5
9
  #import "CoreTextLayout.h"
6
10
 
7
11
  #include <react/renderer/components/iostextinput/TextInputProps.h>
8
12
 
13
+ #include <glog/logging.h>
14
+
9
15
  #include <algorithm>
10
16
  #include <cmath>
11
17
  #include <string_view>
12
18
 
13
19
  // The bridge between AppKit's delegate protocol and the C++ manager, for the
20
+ // A colour the app asked for, or nil when it asked for nothing.
21
+ //
22
+ // nil is the whole point: `SharedColor`'s unset value is zero, so a conversion
23
+ // that always answered a colour would make "the app said nothing" and "the app
24
+ // said transparent black" the same request, and every caret would come out
25
+ // black. Explicitly sRGB, for the reason `toColor` in CoreTextLayout.mm is: a
26
+ // device colour space shifts every colour on a wide-gamut display, and a field
27
+ // that does not match Linux is the bug this project is least able to afford.
28
+ static NSColor *_Nullable RnColourOrNil(facebook::react::SharedColor colour) {
29
+ if (!colour) {
30
+ return nil;
31
+ }
32
+ const auto components = facebook::react::colorComponentsFromColor(colour);
33
+ return [NSColor colorWithSRGBRed:components.red
34
+ green:components.green
35
+ blue:components.blue
36
+ alpha:components.alpha];
37
+ }
38
+
14
39
  // same reason the touch dispatcher and the scroll manager have one.
15
40
  @interface RnAppKitTextInputDelegate
16
41
  : NSObject <NSTextFieldDelegate, NSTextViewDelegate, RnAppKitTextPeerOwner>
@@ -127,8 +152,23 @@ static NSInteger RnTagOf(id object) {
127
152
  doCommandBySelector:(SEL)selector {
128
153
  (void)textView;
129
154
  if (selector == @selector(insertNewline:) && _manager != nullptr) {
130
- _manager->handleSubmit(static_cast<facebook::react::Tag>(RnTagOf(control)));
131
- return YES;
155
+ // YES means "used up", which is what stops AppKit inserting a newline --
156
+ // and for `submitBehavior: 'newline'` on a single-line field there is
157
+ // nothing to insert anyway, so either answer looks the same.
158
+ return _manager->handleSubmit(static_cast<facebook::react::Tag>(RnTagOf(control))) ? YES
159
+ : NO;
160
+ }
161
+ return NO;
162
+ }
163
+
164
+ // The same key on the multiline peer, which is an NSTextView and posts through
165
+ // NSTextViewDelegate rather than NSControl's. Without this a `submitBehavior`
166
+ // of `submit` on a multiline field would insert a newline *and* report
167
+ // nothing: the field's own insertion is what has to be refused.
168
+ - (BOOL)textView:(NSTextView *)textView doCommandBySelector:(SEL)selector {
169
+ if (selector == @selector(insertNewline:) && _manager != nullptr) {
170
+ return _manager->handleSubmit(static_cast<facebook::react::Tag>(RnTagOf(textView))) ? YES
171
+ : NO;
132
172
  }
133
173
  return NO;
134
174
  }
@@ -254,8 +294,10 @@ void AppKitTextInputManager::update(RnAppKitView *view, const ShadowView &shadow
254
294
  // text on dark. buildTextAttributes is what the style becomes, and it was
255
295
  // written for the text layer with this as its eventual second caller.
256
296
  //
257
- // fontSizeMultiplier is 1: nothing on this platform scales text for
258
- // accessibility settings yet, and passing 0 would multiply the size away.
297
+ // 1, and not the text scale, although this is the argument React Native leaves
298
+ // for one: buildTextAttributes applies the scale itself through
299
+ // core/FontScaling.h, so passing it here would square it. The field still
300
+ // scales, and still stops when `allowFontScaling={false}`.
259
301
  NSDictionary<NSAttributedStringKey, id> *attributes =
260
302
  buildTextAttributes(props->getEffectiveTextAttributes(1.0F));
261
303
  NSParagraphStyle *paragraph = attributes[NSParagraphStyleAttributeName];
@@ -294,6 +336,43 @@ void AppKitTextInputManager::update(RnAppKitView *view, const ShadowView &shadow
294
336
  }
295
337
  }
296
338
 
339
+ // The three colours React Native has for a field, which this host read none
340
+ // of until 2026-10-10: the placeholder, the selection and the caret.
341
+ //
342
+ // Each crosses as nil when the app did not ask, which is the distinction that
343
+ // matters: `SharedColor`'s unset value is zero, so passing it through
344
+ // unconditionally would paint every caret black and every selection
345
+ // transparent. nil leaves AppKit's own colour alone.
346
+ //
347
+ // `cursorColor` falls back to `selectionColor`, which is React Native's
348
+ // documented contract rather than an invention here: `selectionColor` is "the
349
+ // highlight, selection handle and cursor color of the text input", and
350
+ // `cursorColor` exists to override the caret on its own. The GTK host reads
351
+ // the pair the same way, and on iOS both are the view's tintColor.
352
+ NSColor *const selectionColour =
353
+ RnColourOrNil(props->selectionColor);
354
+ NSColor *caretColour =
355
+ props->cursorColor ? RnColourOrNil(props->cursorColor) : selectionColour;
356
+
357
+ // `caretHidden`, through the same channel and winning over both of the above:
358
+ // a caret the app asked to hide is hidden whatever colour it also asked for.
359
+ // Clear rather than nil, nil meaning "AppKit's own" -- which is a visible
360
+ // caret. An NSTextView draws its insertion point as a filled rectangle, so a
361
+ // clear one is drawn and invisible.
362
+ if (props->traits.caretHidden) {
363
+ caretColour = NSColor.clearColor;
364
+ }
365
+ RnPeerSetEditorColours(entry.field, selectionColour, caretColour);
366
+
367
+ // `clearTextOnFocus` and `selectTextOnFocus`, remembered for the moment focus
368
+ // arrives -- which is long after the props do. See `handleFocus`.
369
+ entry.clearTextOnFocus = props->traits.clearTextOnFocus;
370
+ entry.selectTextOnFocus = props->traits.selectTextOnFocus;
371
+
372
+ // `submitBehavior`, resolved by React Native rather than here: `Default` is
373
+ // `newline` for a multiline field and `blurAndSubmit` for a single-line one.
374
+ entry.submitBehavior = props->getNonDefaultSubmitBehavior();
375
+
297
376
  if (props->placeholder.empty()) {
298
377
  RnPeerSetPlaceholder(entry.field, nil);
299
378
  } else {
@@ -302,17 +381,64 @@ void AppKitTextInputManager::update(RnAppKitView *view, const ShadowView &shadow
302
381
  placeholder = @"";
303
382
  }
304
383
  // Styled like the text, so a placeholder in a 20pt field is not 13pt.
384
+ //
385
+ // `placeholderTextColor` when the app asked, and the system's placeholder
386
+ // grey otherwise. This host painted the grey unconditionally until
387
+ // 2026-10-10 and the backlog recorded the prop as done, which is what the
388
+ // support page's per-row audit caught: an app asking for a red placeholder
389
+ // got grey.
305
390
  NSMutableDictionary *placeholderAttributes = [attributes mutableCopy];
306
- placeholderAttributes[NSForegroundColorAttributeName] = NSColor.placeholderTextColor;
391
+ NSColor *const asked = RnColourOrNil(props->placeholderTextColor);
392
+ placeholderAttributes[NSForegroundColorAttributeName] =
393
+ asked != nil ? asked : NSColor.placeholderTextColor;
307
394
  RnPeerSetPlaceholder(entry.field,
308
395
  [[NSAttributedString alloc] initWithString:placeholder
309
396
  attributes:placeholderAttributes]);
310
397
  }
311
398
 
399
+ // `contextMenuHidden`, which the peers answer from `-menuForEvent:`.
400
+ RnPeerSetContextMenuHidden(entry.field, props->traits.contextMenuHidden);
401
+
312
402
  // `editable` is the prop; `readOnly` is the newer spelling of its inverse,
313
403
  // and React Native honours both.
314
404
  RnPeerSetEditable(entry.field, props->traits.editable && !props->readOnly);
315
405
 
406
+ // `spellCheck` and `autoCorrect`, resolved in core/TextChecking.h so that the
407
+ // two hosts agree that unset is not false. AppKit has both, on the NSTextView
408
+ // a field is or borrows; GTK has only the first, as an input hint.
409
+ const auto checking = [](std::optional<bool> asked) {
410
+ switch (basalt::textCheckingFlag(asked)) {
411
+ case basalt::TextCheckingFlag::On:
412
+ return RnTextCheckingOn;
413
+ case basalt::TextCheckingFlag::Off:
414
+ return RnTextCheckingOff;
415
+ case basalt::TextCheckingFlag::Unset:
416
+ break;
417
+ }
418
+ return RnTextCheckingUnset;
419
+ };
420
+ RnPeerSetTextChecking(entry.field,
421
+ checking(props->traits.spellCheck),
422
+ checking(props->traits.autoCorrect));
423
+ // And both in the dump, as the app asked for them, in the words GTK prints.
424
+ const auto name = [](std::optional<bool> asked) -> NSString * {
425
+ const char *word = basalt::textCheckingName(basalt::textCheckingFlag(asked));
426
+ return word != nullptr ? @(word) : nil;
427
+ };
428
+ entry.view.rnSpellCheck = name(props->traits.spellCheck);
429
+ entry.view.rnAutoCorrect = name(props->traits.autoCorrect);
430
+
431
+ // `autoCapitalize` and `keyboardType`, reported and not acted on: this
432
+ // platform has no per-field automatic capitalisation and no software
433
+ // keyboard. backlog/platform-macos.md says so with the calls that do not
434
+ // exist, and the GTK host honours both.
435
+ entry.view.rnAutoCapitalize = @(basalt::autoCapitalizeName(props->traits.autocapitalizationType));
436
+ entry.view.rnKeyboardType = @(basalt::keyboardTypeName(props->traits.keyboardType));
437
+ // And the two hiding props, for the dump: each is the absence of something,
438
+ // so the dump is the only place anything can see that they arrived.
439
+ entry.view.rnCaretHidden = props->traits.caretHidden;
440
+ entry.view.rnContextMenuHidden = props->traits.contextMenuHidden;
441
+
316
442
  // Zero means no limit, and so does the absurd default React Native uses when
317
443
  // the prop is absent.
318
444
  RnPeerSetMaxLength(entry.field,
@@ -321,7 +447,65 @@ void AppKitTextInputManager::update(RnAppKitView *view, const ShadowView &shadow
321
447
  : 0);
322
448
 
323
449
  if (inserted && props->autoFocus) {
324
- [entry.view.window makeFirstResponder:entry.field];
450
+ // Recorded, not performed: see flushAutoFocus in the header for why here is
451
+ // too early. `inserted` is the only moment the prop can be acted on at all,
452
+ // since it is the only moment this manager can tell a new field from an
453
+ // updated one, so the tag is kept and the focusing happens later.
454
+ pendingAutoFocus_.push_back(tag);
455
+ }
456
+ }
457
+
458
+ void AppKitTextInputManager::flushAutoFocus() {
459
+ if (pendingAutoFocus_.empty()) {
460
+ return;
461
+ }
462
+ // Taken by value and cleared first, so a field that has since been removed, or
463
+ // one whose window is still not there, does not leave the list growing.
464
+ const std::vector<Tag> pending = std::move(pendingAutoFocus_);
465
+ pendingAutoFocus_.clear();
466
+
467
+ for (const Tag tag : pending) {
468
+ const auto it = entries_.find(tag);
469
+ if (it == entries_.end()) {
470
+ continue;
471
+ }
472
+ // Not silently. `makeFirstResponder:` answers whether it took, messaging a
473
+ // nil window is a no-op rather than an error, and this used to discard both:
474
+ // an autoFocus that did nothing looked exactly like one that worked. The
475
+ // window is non-nil on every path there is today, a view being in the
476
+ // window's hierarchy from the moment the surface root is set, so these are
477
+ // claims about what is believed rather than cases anyone has seen.
478
+ NSWindow *const window = it->second.view.window;
479
+ if (window == nil) {
480
+ LOG(WARNING) << "autoFocus: tag " << tag << " is not in a window yet";
481
+ continue;
482
+ }
483
+ if (![window makeFirstResponder:it->second.field]) {
484
+ LOG(WARNING) << "autoFocus: tag " << tag << " refused first responder";
485
+ continue;
486
+ }
487
+
488
+ // A field that asked to select its text on focus keeps what
489
+ // `becomeFirstResponder` gave it, which is the whole selection: the collapse
490
+ // below is what `selectTextOnFocus` is the absence of.
491
+ if (it->second.selectTextOnFocus) {
492
+ continue;
493
+ }
494
+
495
+ // And then collapse the selection, which is the whole of the bug GTK had.
496
+ // `NSTextField` installs the window's shared field editor on becoming first
497
+ // responder and selects all of its text, so autoFocus on a field with a
498
+ // `defaultValue` armed the next keystroke to replace it. The repository had
499
+ // already written the symptom down without recognising it: the paste role's
500
+ // scenario says "both hosts here replace", and a paste replaces what is
501
+ // selected.
502
+ //
503
+ // A caret at the end is what focusing a field is meant to leave, and what
504
+ // `gtk_text_grab_focus_without_selecting` gives on the other host.
505
+ // `NSTextView` keeps its own collapsed selection and needs none of this;
506
+ // setting it anyway costs nothing and keeps the two from drifting.
507
+ const NSUInteger length = RnPeerText(it->second.field).length;
508
+ RnPeerSetSelection(it->second.field, NSMakeRange(length, 0));
325
509
  }
326
510
  }
327
511
 
@@ -409,14 +593,35 @@ void AppKitTextInputManager::handleChanged(Tag tag) {
409
593
  }
410
594
  }
411
595
 
412
- void AppKitTextInputManager::handleSubmit(Tag tag) {
596
+ bool AppKitTextInputManager::handleSubmit(Tag tag) {
413
597
  Entry *entry = entryFor(tag);
414
598
  if (entry == nullptr) {
415
- return;
599
+ return false;
600
+ }
601
+ // **`submitBehavior` decides what Return does**, already resolved to one of
602
+ // the three: `newline` inserts one, `submit` reports a submit, and
603
+ // `blurAndSubmit` reports one and gives up focus. React Native's default is
604
+ // the last for a single-line field and the first for a multiline one, so a
605
+ // field that never set the prop behaves as it does on iOS -- where this host
606
+ // used to keep focus whatever the prop said, because an NSTextField does.
607
+ if (entry->submitBehavior == facebook::react::SubmitBehavior::Newline) {
608
+ // Not used up: a multiline peer inserts the newline, and a single-line one
609
+ // has nowhere to put it and does nothing.
610
+ return false;
416
611
  }
417
612
  if (const auto emitter = emitterFor(tag)) {
418
613
  emitter->onSubmitEditing(metricsFor(*entry));
419
614
  }
615
+ if (entry->submitBehavior == facebook::react::SubmitBehavior::BlurAndSubmit) {
616
+ // Handing focus back to the window is the closest thing AppKit has to
617
+ // "unfocus this", which the `blur` command says at more length. It ends the
618
+ // edit, so the delegate reports a blur and JavaScript sees onBlur.
619
+ NSWindow *window = entry->view.window;
620
+ if (window != nil && window.firstResponder != window) {
621
+ [window makeFirstResponder:window];
622
+ }
623
+ }
624
+ return true;
420
625
  }
421
626
 
422
627
  void AppKitTextInputManager::handleFocus(Tag tag) {
@@ -427,6 +632,28 @@ void AppKitTextInputManager::handleFocus(Tag tag) {
427
632
  if (const auto emitter = emitterFor(tag)) {
428
633
  emitter->onFocus(metricsFor(*entry));
429
634
  }
635
+
636
+ // `clearTextOnFocus` before `selectTextOnFocus`, which is the only order that
637
+ // means anything: selecting what has just been cleared is selecting nothing,
638
+ // and clearing what has just been selected throws the selection away. Both
639
+ // hosts do them in this order for that reason.
640
+ if (entry->clearTextOnFocus && RnPeerText(entry->field).length > 0) {
641
+ // Reported, not applied silently. A controlled field whose text was cleared
642
+ // behind JavaScript's back is a field that puts the old value back on the
643
+ // next unrelated render, with the user having done nothing -- so the clear
644
+ // goes through the same path a keystroke does.
645
+ RnPeerSetText(entry->field, @"");
646
+ handleChanged(tag);
647
+ }
648
+
649
+ if (entry->selectTextOnFocus) {
650
+ // An NSTextField selects everything on becoming first responder, and this
651
+ // host collapses that by hand -- see `flushAutoFocus`, where the prop is
652
+ // the reason not to. An NSTextView does not, so the selection is made here
653
+ // for both and neither depends on which peer it is.
654
+ RnPeerSetSelection(entry->field,
655
+ NSMakeRange(0, RnPeerText(entry->field).length));
656
+ }
430
657
  }
431
658
 
432
659
  bool AppKitTextInputManager::handleKeyDown(void *event) {
@@ -46,6 +46,66 @@ NSRange RnPeerSelection(NSView *_Nullable peer);
46
46
  void RnPeerSetSelection(NSView *_Nullable peer, NSRange range);
47
47
 
48
48
  void RnPeerSetEditable(NSView *_Nullable peer, BOOL editable);
49
+
50
+ // `contextMenuHidden`: whether a right-click or a Control-click opens the
51
+ // peer's own editing menu.
52
+ //
53
+ // AppKit builds that menu on demand through `-menuForEvent:`, so the peers
54
+ // refuse there rather than having a menu taken away from them -- which is the
55
+ // one place that covers both gestures, and leaves the menu intact for a field
56
+ // that stops asking.
57
+ void RnPeerSetContextMenuHidden(NSView *_Nullable peer, BOOL hidden);
58
+ BOOL RnPeerContextMenuHidden(NSView *_Nullable peer);
59
+
60
+ // `spellCheck` and `autoCorrect`, as three states: see core/TextChecking.h for
61
+ // why unset is not false. Mirrored here rather than taken from that header
62
+ // because this one is plain Objective-C and its callers should stay that way.
63
+ typedef NS_ENUM(NSInteger, RnTextChecking) {
64
+ RnTextCheckingUnset,
65
+ RnTextCheckingOn,
66
+ RnTextCheckingOff,
67
+ };
68
+
69
+ // Applies both, and remembers them.
70
+ //
71
+ // Remembering is the point: the two properties live on an NSTextView, and a
72
+ // single-line field is an NSTextField which *borrows* one -- its field editor,
73
+ // which exists only while it is focused. So a field asked before it is focused
74
+ // has nowhere to put them, and the peer applies them again when the editor
75
+ // appears.
76
+ void RnPeerSetTextChecking(NSView *_Nullable peer,
77
+ RnTextChecking spellCheck,
78
+ RnTextChecking autoCorrect);
79
+
80
+ // What the peer was last asked for, which is not always what AppKit did with
81
+ // it: macOS gates continuous spell checking on a user-wide setting
82
+ // (`NSAllowContinuousSpellChecking`), and refuses a per-view request when the
83
+ // person has turned the feature off for everything. So the request is what a
84
+ // test can assert on any machine, and AppKit's answer is asserted only where
85
+ // the machine allows it.
86
+ RnTextChecking RnPeerSpellCheck(NSView *_Nullable peer);
87
+ RnTextChecking RnPeerAutoCorrect(NSView *_Nullable peer);
88
+
89
+ // `selectionColor` and `cursorColor`, which live on the same NSTextView the
90
+ // spell-checking flags do and are remembered for the same reason: a single-line
91
+ // field borrows the window's field editor and only has one while it is focused.
92
+ // So these are stored and applied again in `becomeFirstResponder`.
93
+ //
94
+ // Null means "the app did not ask", which leaves AppKit's own colour alone --
95
+ // the system accent for a selection and the text colour for a caret. That is a
96
+ // third state rather than a default, exactly as it is for the checking flags:
97
+ // `SharedColor`'s unset value is zero, and passing that through would paint
98
+ // every caret black.
99
+ //
100
+ // The caret is `insertionPointColor` and the selection is the background colour
101
+ // inside `selectedTextAttributes`, which is a dictionary this replaces one key
102
+ // of rather than wholesale: the rest of it is AppKit's, including whether the
103
+ // selection dims when the window loses focus.
104
+ void RnPeerSetEditorColours(NSView *_Nullable peer,
105
+ NSColor *_Nullable selection,
106
+ NSColor *_Nullable caret);
107
+ NSColor *_Nullable RnPeerSelectionColour(NSView *_Nullable peer);
108
+ NSColor *_Nullable RnPeerCaretColour(NSView *_Nullable peer);
49
109
  void RnPeerSetTextStyle(NSView *_Nullable peer, NSFont *_Nullable font,
50
110
  NSColor *_Nullable colour, NSTextAlignment alignment);
51
111