basalt-appkit 0.0.0-rc5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) 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 +582 -12
  8. package/native/appkit/AppKitScrollView.h +48 -0
  9. package/native/appkit/AppKitScrollView.mm +210 -0
  10. package/native/appkit/AppKitSkiaPeer.h +15 -0
  11. package/native/appkit/AppKitTextInput.h +38 -1
  12. package/native/appkit/AppKitTextInput.mm +235 -8
  13. package/native/appkit/AppKitTextPeer.h +60 -0
  14. package/native/appkit/AppKitTextPeer.mm +307 -0
  15. package/native/appkit/AppKitTouchDispatcher.h +56 -0
  16. package/native/appkit/AppKitTouchDispatcher.mm +140 -0
  17. package/native/appkit/AppKitWindowControl.mm +38 -3
  18. package/native/appkit/ComponentRegistryAppKit.mm +28 -3
  19. package/native/appkit/CoreTextLayout.h +64 -2
  20. package/native/appkit/CoreTextLayout.mm +431 -71
  21. package/native/appkit/CoreTextLayoutManager.mm +57 -6
  22. package/native/appkit/FontRegistryCoreText.mm +6 -5
  23. package/native/appkit/PlatformServicesAppKit.mm +101 -20
  24. package/native/appkit/RnAppKitView.h +492 -0
  25. package/native/appkit/RnAppKitView.mm +2266 -23
  26. package/native/appkit/RnTextLayout.h +106 -0
  27. package/native/appkit/RnTextLayout.mm +321 -2
  28. package/native/appkit/main_appkit.mm +174 -3
  29. package/native/tests/test_appkit_accessibility.mm +289 -0
  30. package/native/tests/test_appkit_borders.mm +975 -0
  31. package/native/tests/test_appkit_fonts.mm +203 -0
  32. package/native/tests/test_appkit_image.mm +392 -17
  33. package/native/tests/test_appkit_input.mm +209 -0
  34. package/native/tests/test_appkit_mounting.mm +360 -0
  35. package/native/tests/test_appkit_scrollbar.mm +125 -0
  36. package/native/tests/test_appkit_scrollview.mm +371 -0
  37. package/native/tests/test_appkit_selection.mm +349 -0
  38. package/native/tests/test_appkit_services.mm +51 -1
  39. package/native/tests/test_appkit_text.mm +1628 -0
  40. package/native/tests/test_appkit_textinput.mm +819 -0
  41. package/native/tests/test_appkit_titlebar.mm +43 -0
  42. package/native/tests/test_appkit_view.mm +609 -0
  43. package/package.json +4 -3
@@ -746,3 +746,822 @@ TEST(textinput_says_nothing_about_a_key_that_types_nothing) {
746
746
  manager.destroySurfaceRoot(kSurfaceId);
747
747
  }
748
748
  }
749
+
750
+ // `spellCheck` and `autoCorrect`, which on this platform are two properties of
751
+ // an NSTextView: a multiline field *is* one, and a single-line field borrows
752
+ // one as its field editor while it is focused.
753
+ //
754
+ // What is asserted unconditionally is the *request*, because AppKit may decline
755
+ // it: macOS gates continuous spell checking on a user-wide setting, and when
756
+ // the person has turned spelling off for everything it refuses a per-view
757
+ // request. Measured rather than guessed -- `NSAllowContinuousSpellChecking` is
758
+ // 0 on the machine this was written on, and `setContinuousSpellCheckingEnabled:
759
+ // YES` was ignored on a bare NSTextView with and without a window. AppKit's own
760
+ // answer is asserted where the machine allows it, and the autocorrection flag
761
+ // has no such gate and is asserted always.
762
+ namespace {
763
+
764
+ bool continuousSpellCheckingAllowed() {
765
+ NSNumber *allowed =
766
+ [NSUserDefaults.standardUserDefaults objectForKey:@"NSAllowContinuousSpellChecking"];
767
+ return allowed == nil || allowed.boolValue;
768
+ }
769
+
770
+ } // namespace
771
+
772
+ TEST(textinput_spell_check_and_autocorrect_are_asked_for_on_a_multiline_field) {
773
+ @autoreleasepool {
774
+ basalt::AppKitMountingManager manager;
775
+ RnAppKitView *view = mountField(manager,
776
+ 10,
777
+ folly::dynamic::object("multiline", true)(
778
+ "spellCheck", true)("autoCorrect", true));
779
+
780
+ NSView *field = fieldOf(view);
781
+ EXPECT(RnPeerIsMultiline(field));
782
+ EXPECT(RnPeerSpellCheck(field) == RnTextCheckingOn);
783
+ EXPECT(RnPeerAutoCorrect(field) == RnTextCheckingOn);
784
+
785
+ NSTextView *editor = (NSTextView *)field;
786
+ // No gate on this one.
787
+ EXPECT(editor.isAutomaticSpellingCorrectionEnabled);
788
+ if (continuousSpellCheckingAllowed()) {
789
+ EXPECT(editor.isContinuousSpellCheckingEnabled);
790
+ }
791
+
792
+ manager.destroySurfaceRoot(kSurfaceId);
793
+ }
794
+ }
795
+
796
+ TEST(textinput_spell_check_and_autocorrect_can_be_refused) {
797
+ @autoreleasepool {
798
+ basalt::AppKitMountingManager manager;
799
+ RnAppKitView *view = mountField(manager,
800
+ 10,
801
+ folly::dynamic::object("multiline", true)(
802
+ "spellCheck", false)("autoCorrect", false));
803
+
804
+ NSTextView *editor = (NSTextView *)fieldOf(view);
805
+ EXPECT(RnPeerSpellCheck(editor) == RnTextCheckingOff);
806
+ EXPECT(RnPeerAutoCorrect(editor) == RnTextCheckingOff);
807
+ // Refusing always sticks: the user setting above can only take the feature
808
+ // away, never force it on.
809
+ EXPECT(!editor.isContinuousSpellCheckingEnabled);
810
+ EXPECT(!editor.isAutomaticSpellingCorrectionEnabled);
811
+
812
+ manager.destroySurfaceRoot(kSurfaceId);
813
+ }
814
+ }
815
+
816
+ // Unset is not false, which is the rule core/TextChecking.h exists for: a field
817
+ // that says nothing keeps whatever AppKit does by default, and this asserts the
818
+ // host did not decide for it.
819
+ TEST(textinput_an_unset_checking_flag_leaves_appkits_own_answer) {
820
+ @autoreleasepool {
821
+ basalt::AppKitMountingManager manager;
822
+
823
+ // AppKit's defaults, read off a peer of its own rather than assumed: they
824
+ // are AppKit's business and have changed before.
825
+ NSTextView *bare = [[NSTextView alloc] initWithFrame:NSMakeRect(0, 0, 100, 40)];
826
+ const BOOL spellByDefault = bare.isContinuousSpellCheckingEnabled;
827
+ const BOOL correctByDefault = bare.isAutomaticSpellingCorrectionEnabled;
828
+
829
+ RnAppKitView *view = mountField(manager, 10, folly::dynamic::object("multiline", true));
830
+ NSTextView *editor = (NSTextView *)fieldOf(view);
831
+ EXPECT(RnPeerSpellCheck(editor) == RnTextCheckingUnset);
832
+ EXPECT(RnPeerAutoCorrect(editor) == RnTextCheckingUnset);
833
+ EXPECT_EQ(editor.isContinuousSpellCheckingEnabled, spellByDefault);
834
+ EXPECT_EQ(editor.isAutomaticSpellingCorrectionEnabled, correctByDefault);
835
+
836
+ manager.destroySurfaceRoot(kSurfaceId);
837
+ }
838
+ }
839
+
840
+ // And the single-line half, which is the awkward one: an NSTextField has
841
+ // neither property, so the peer remembers the request and applies it to the
842
+ // field editor when one appears.
843
+ TEST(textinput_a_single_line_field_checks_its_editor_when_it_focuses) {
844
+ @autoreleasepool {
845
+ basalt::AppKitMountingManager manager;
846
+ RnAppKitView *view =
847
+ mountField(manager, 10, folly::dynamic::object("spellCheck", false)("autoCorrect", false));
848
+ NSView *field = fieldOf(view);
849
+ EXPECT(!RnPeerIsMultiline(field));
850
+ EXPECT(RnPeerSpellCheck(field) == RnTextCheckingOff);
851
+
852
+ // A window, because a field editor belongs to the *window* and an
853
+ // unparented field never gets one.
854
+ NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 400, 300)
855
+ styleMask:NSWindowStyleMaskTitled
856
+ backing:NSBackingStoreBuffered
857
+ defer:NO];
858
+ // Not released when closed: see test_appkit_services.mm for the double
859
+ // release that taught this suite to say so.
860
+ window.releasedWhenClosed = NO;
861
+ [window.contentView addSubview:manager.getSurfaceRoot(kSurfaceId)];
862
+ EXPECT([window makeFirstResponder:field]);
863
+
864
+ NSTextView *editor = (NSTextView *)((NSTextField *)field).currentEditor;
865
+ EXPECT(editor != nil);
866
+ EXPECT(editor != nil && !editor.isContinuousSpellCheckingEnabled);
867
+ EXPECT(editor != nil && !editor.isAutomaticSpellingCorrectionEnabled);
868
+
869
+ [window orderOut:nil];
870
+ [window close];
871
+ manager.destroySurfaceRoot(kSurfaceId);
872
+ }
873
+ }
874
+
875
+ // `autoCapitalize` and `keyboardType`, which this platform reports and cannot
876
+ // act on: there is no per-field automatic capitalisation on macOS
877
+ // (`NSSpellChecker`'s is the person's system setting) and no software keyboard
878
+ // to choose a layout for.
879
+ //
880
+ // Asserted anyway, and in the words GTK prints: the dumps are diffed line by
881
+ // line, so a field that asked for something has to say the same thing on both
882
+ // hosts and the difference belongs in the support page rather than in a missing
883
+ // line.
884
+ TEST(textinput_reports_the_capitalisation_and_keyboard_it_cannot_honour) {
885
+ @autoreleasepool {
886
+ basalt::AppKitMountingManager manager;
887
+ RnAppKitView *view = mountField(manager,
888
+ 10,
889
+ folly::dynamic::object("autoCapitalize", "characters")(
890
+ "keyboardType", "email-address"));
891
+
892
+ const std::string dumped = [view describeTree].UTF8String;
893
+ EXPECT(dumped.find("autocapitalize=characters") != std::string::npos);
894
+ EXPECT(dumped.find("keyboard=email-address") != std::string::npos);
895
+
896
+ manager.destroySurfaceRoot(kSurfaceId);
897
+ }
898
+ }
899
+
900
+ // And the default, which React Native decides rather than this platform:
901
+ // `sentences`, as on iOS. A field that mentions neither prop still reports
902
+ // both, because both are plain enums with a default rather than optionals.
903
+ TEST(textinput_reports_react_natives_own_defaults_for_the_pair) {
904
+ @autoreleasepool {
905
+ basalt::AppKitMountingManager manager;
906
+ RnAppKitView *view = mountField(manager, 10, folly::dynamic::object("text", "plain"));
907
+
908
+ const std::string dumped = [view describeTree].UTF8String;
909
+ EXPECT(dumped.find("autocapitalize=sentences") != std::string::npos);
910
+ EXPECT(dumped.find("keyboard=default") != std::string::npos);
911
+
912
+ manager.destroySurfaceRoot(kSurfaceId);
913
+ }
914
+ }
915
+
916
+ // --- The three colours a field has ------------------------------------------
917
+ //
918
+ // `placeholderTextColor`, `selectionColor` and `cursorColor`, which this host
919
+ // read none of until 2026-10-10 -- and which the backlog recorded as done for
920
+ // the first of them, because the system's placeholder grey looks like an
921
+ // answer. The per-row support audit is what caught that.
922
+ //
923
+ // Each is a third state rather than a default: `SharedColor`'s unset value is
924
+ // zero, so "the app said nothing" has to stay distinguishable from "the app
925
+ // said black". nil is what crosses, and nil leaves AppKit's own colour alone.
926
+
927
+ TEST(textinput_placeholder_takes_the_colour_the_app_asked_for) {
928
+ @autoreleasepool {
929
+ basalt::AppKitMountingManager manager;
930
+ RnAppKitView *view = mountField(
931
+ manager,
932
+ 10,
933
+ folly::dynamic::object("placeholder", "Search")("placeholderTextColor", 0xffff0000));
934
+ NSView *field = fieldOf(view);
935
+
936
+ NSAttributedString *placeholder = RnPeerPlaceholder(field);
937
+ EXPECT(placeholder != nil);
938
+ if (placeholder == nil) {
939
+ manager.destroySurfaceRoot(kSurfaceId);
940
+ return;
941
+ }
942
+ NSColor *colour = [placeholder attribute:NSForegroundColorAttributeName
943
+ atIndex:0
944
+ effectiveRange:nullptr];
945
+ EXPECT(colour != nil);
946
+ // Red, in sRGB, which is what the app asked for rather than the system's
947
+ // placeholder grey.
948
+ NSColor *const sRGB = [colour colorUsingColorSpace:NSColorSpace.sRGBColorSpace];
949
+ EXPECT(sRGB != nil);
950
+ EXPECT(sRGB != nil && sRGB.redComponent > 0.9);
951
+ EXPECT(sRGB != nil && sRGB.greenComponent < 0.1);
952
+ EXPECT(sRGB != nil && sRGB.blueComponent < 0.1);
953
+
954
+ manager.destroySurfaceRoot(kSurfaceId);
955
+ }
956
+ }
957
+
958
+ // And a field that asked for nothing keeps the system's grey, which is the
959
+ // third state: a host that resolved unset to a colour would paint every
960
+ // placeholder transparent black.
961
+ TEST(textinput_a_placeholder_with_no_colour_keeps_the_systems) {
962
+ @autoreleasepool {
963
+ basalt::AppKitMountingManager manager;
964
+ RnAppKitView *view =
965
+ mountField(manager, 10, folly::dynamic::object("placeholder", "Search"));
966
+ NSAttributedString *placeholder = RnPeerPlaceholder(fieldOf(view));
967
+ EXPECT(placeholder != nil);
968
+ if (placeholder != nil) {
969
+ NSColor *colour = [placeholder attribute:NSForegroundColorAttributeName
970
+ atIndex:0
971
+ effectiveRange:nullptr];
972
+ EXPECT(colour == NSColor.placeholderTextColor);
973
+ }
974
+ manager.destroySurfaceRoot(kSurfaceId);
975
+ }
976
+ }
977
+
978
+ // The selection and the caret are remembered before they can be applied: a
979
+ // single-line field borrows the window's field editor and has none until it is
980
+ // focused, which is the same reason the spell-checking flags are remembered.
981
+ TEST(textinput_selection_and_cursor_colours_are_remembered_for_the_editor) {
982
+ @autoreleasepool {
983
+ basalt::AppKitMountingManager manager;
984
+ RnAppKitView *view = mountField(
985
+ manager,
986
+ 10,
987
+ folly::dynamic::object("selectionColor", 0xff00ff00)("cursorColor", 0xff0000ff));
988
+ NSView *field = fieldOf(view);
989
+
990
+ NSColor *selection =
991
+ [RnPeerSelectionColour(field) colorUsingColorSpace:NSColorSpace.sRGBColorSpace];
992
+ NSColor *caret =
993
+ [RnPeerCaretColour(field) colorUsingColorSpace:NSColorSpace.sRGBColorSpace];
994
+ EXPECT(selection != nil);
995
+ EXPECT(caret != nil);
996
+ EXPECT(selection != nil && selection.greenComponent > 0.9);
997
+ EXPECT(caret != nil && caret.blueComponent > 0.9);
998
+
999
+ manager.destroySurfaceRoot(kSurfaceId);
1000
+ }
1001
+ }
1002
+
1003
+ // `cursorColor` falls back to `selectionColor`, which is React Native's
1004
+ // contract: `selectionColor` is the highlight *and* the cursor, and
1005
+ // `cursorColor` exists to override the second on its own. A field given only
1006
+ // the first should not have a theme-coloured caret.
1007
+ TEST(textinput_a_cursor_with_no_colour_of_its_own_follows_the_selection) {
1008
+ @autoreleasepool {
1009
+ basalt::AppKitMountingManager manager;
1010
+ RnAppKitView *view =
1011
+ mountField(manager, 10, folly::dynamic::object("selectionColor", 0xff00ff00));
1012
+ NSView *field = fieldOf(view);
1013
+
1014
+ NSColor *caret =
1015
+ [RnPeerCaretColour(field) colorUsingColorSpace:NSColorSpace.sRGBColorSpace];
1016
+ EXPECT(caret != nil);
1017
+ EXPECT(caret != nil && caret.greenComponent > 0.9);
1018
+
1019
+ // And a field that asked for neither has neither, so AppKit's own colours
1020
+ // stand.
1021
+ RnAppKitView *plain = mountField(manager, 11, folly::dynamic::object("text", "x"));
1022
+ EXPECT(RnPeerSelectionColour(fieldOf(plain)) == nil);
1023
+ EXPECT(RnPeerCaretColour(fieldOf(plain)) == nil);
1024
+
1025
+ manager.destroySurfaceRoot(kSurfaceId);
1026
+ }
1027
+ }
1028
+
1029
+ // And they reach the real field editor when the field is focused, which is the
1030
+ // half the remembering exists for.
1031
+ TEST(textinput_the_field_editor_takes_the_colours_when_it_appears) {
1032
+ @autoreleasepool {
1033
+ basalt::AppKitMountingManager manager;
1034
+ RnAppKitView *view = mountField(
1035
+ manager,
1036
+ 10,
1037
+ folly::dynamic::object("selectionColor", 0xff00ff00)("cursorColor", 0xff0000ff));
1038
+ NSView *field = fieldOf(view);
1039
+
1040
+ NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 400, 300)
1041
+ styleMask:NSWindowStyleMaskTitled
1042
+ backing:NSBackingStoreBuffered
1043
+ defer:NO];
1044
+ window.releasedWhenClosed = NO;
1045
+ [window.contentView addSubview:manager.getSurfaceRoot(kSurfaceId)];
1046
+ EXPECT([window makeFirstResponder:field]);
1047
+
1048
+ NSTextView *editor = (NSTextView *)((NSTextField *)field).currentEditor;
1049
+ EXPECT(editor != nil);
1050
+ if (editor != nil) {
1051
+ NSColor *caret = [editor.insertionPointColor
1052
+ colorUsingColorSpace:NSColorSpace.sRGBColorSpace];
1053
+ EXPECT(caret != nil && caret.blueComponent > 0.9);
1054
+
1055
+ NSColor *selection = [editor.selectedTextAttributes[NSBackgroundColorAttributeName]
1056
+ colorUsingColorSpace:NSColorSpace.sRGBColorSpace];
1057
+ EXPECT(selection != nil);
1058
+ EXPECT(selection != nil && selection.greenComponent > 0.9);
1059
+ }
1060
+
1061
+ [window orderOut:nil];
1062
+ [window close];
1063
+ manager.destroySurfaceRoot(kSurfaceId);
1064
+ }
1065
+ }
1066
+
1067
+ // A multiline peer is its own editor, so the colours go straight on -- and are
1068
+ // still remembered, so that reading them back says what the app asked for.
1069
+ TEST(textinput_a_multiline_field_takes_the_colours_directly) {
1070
+ @autoreleasepool {
1071
+ basalt::AppKitMountingManager manager;
1072
+ RnAppKitView *view = mountField(
1073
+ manager,
1074
+ 10,
1075
+ folly::dynamic::object("multiline", true)("selectionColor", 0xff00ff00)(
1076
+ "cursorColor", 0xff0000ff));
1077
+ NSView *field = fieldOf(view);
1078
+ EXPECT(RnPeerIsMultiline(field));
1079
+
1080
+ NSTextView *editor = (NSTextView *)field;
1081
+ NSColor *caret =
1082
+ [editor.insertionPointColor colorUsingColorSpace:NSColorSpace.sRGBColorSpace];
1083
+ EXPECT(caret != nil && caret.blueComponent > 0.9);
1084
+ NSColor *selection = [editor.selectedTextAttributes[NSBackgroundColorAttributeName]
1085
+ colorUsingColorSpace:NSColorSpace.sRGBColorSpace];
1086
+ EXPECT(selection != nil && selection.greenComponent > 0.9);
1087
+ EXPECT(RnPeerSelectionColour(field) != nil);
1088
+
1089
+ manager.destroySurfaceRoot(kSurfaceId);
1090
+ }
1091
+ }
1092
+
1093
+ // --- clearTextOnFocus and selectTextOnFocus ---------------------------------
1094
+ //
1095
+ // What happens when a field takes focus, which no host read until 2026-10-10.
1096
+ // Both are about the moment focus arrives rather than about the props, so both
1097
+ // need a real window: an NSTextField has no field editor until it is first
1098
+ // responder, and a selection with nowhere to live is not a selection.
1099
+
1100
+ TEST(textinput_clear_text_on_focus_empties_the_field) {
1101
+ @autoreleasepool {
1102
+ basalt::AppKitMountingManager manager;
1103
+ RnAppKitView *view = mountField(
1104
+ manager, 10, folly::dynamic::object("text", "keep me")("clearTextOnFocus", true));
1105
+ NSView *field = fieldOf(view);
1106
+ EXPECT([RnPeerText(field) isEqualToString:@"keep me"]);
1107
+
1108
+ NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 400, 300)
1109
+ styleMask:NSWindowStyleMaskTitled
1110
+ backing:NSBackingStoreBuffered
1111
+ defer:NO];
1112
+ window.releasedWhenClosed = NO;
1113
+ [window.contentView addSubview:manager.getSurfaceRoot(kSurfaceId)];
1114
+ EXPECT([window makeFirstResponder:field]);
1115
+
1116
+ // Empty, and empty where the user can see it rather than only in the props.
1117
+ EXPECT([RnPeerText(field) isEqualToString:@""]);
1118
+
1119
+ [window orderOut:nil];
1120
+ [window close];
1121
+ manager.destroySurfaceRoot(kSurfaceId);
1122
+ }
1123
+ }
1124
+
1125
+ // And a field that did not ask keeps its text, which is the control: a host
1126
+ // that cleared unconditionally would pass the test above.
1127
+ TEST(textinput_a_field_that_did_not_ask_keeps_its_text_on_focus) {
1128
+ @autoreleasepool {
1129
+ basalt::AppKitMountingManager manager;
1130
+ RnAppKitView *view = mountField(manager, 10, folly::dynamic::object("text", "keep me"));
1131
+ NSView *field = fieldOf(view);
1132
+
1133
+ NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 400, 300)
1134
+ styleMask:NSWindowStyleMaskTitled
1135
+ backing:NSBackingStoreBuffered
1136
+ defer:NO];
1137
+ window.releasedWhenClosed = NO;
1138
+ [window.contentView addSubview:manager.getSurfaceRoot(kSurfaceId)];
1139
+ EXPECT([window makeFirstResponder:field]);
1140
+
1141
+ EXPECT([RnPeerText(field) isEqualToString:@"keep me"]);
1142
+
1143
+ [window orderOut:nil];
1144
+ [window close];
1145
+ manager.destroySurfaceRoot(kSurfaceId);
1146
+ }
1147
+ }
1148
+
1149
+ // **`selectTextOnFocus` is not observable on the user-focus path on this host**,
1150
+ // and that is worth saying rather than pretending otherwise: an NSTextField
1151
+ // selects all of its text on becoming first responder whatever the prop says,
1152
+ // so a test that focused a field and found everything selected would pass with
1153
+ // the prop removed. It is a sabotage run that establishes that -- the first
1154
+ // version of this file had exactly that test, and it survived.
1155
+ //
1156
+ // What the prop changes is the *programmatic* path, where this host collapses
1157
+ // the selection on purpose, so that is where it is asserted: below, both ways
1158
+ // round.
1159
+ //
1160
+ // `autoFocus` with no `selectTextOnFocus` gets a caret at the end, which is
1161
+ // what this host goes out of its way to arrange: an NSTextField selects
1162
+ // everything on becoming first responder, and `flushAutoFocus` collapses that
1163
+ // because putting a caret in a field is what focusing one means. The prop is
1164
+ // what turns the collapse off, so this is the other side of the test above --
1165
+ // and it needs the window *before* the mount, `flushAutoFocus` having nowhere
1166
+ // to focus otherwise.
1167
+ TEST(textinput_auto_focus_alone_leaves_a_caret_rather_than_a_selection) {
1168
+ @autoreleasepool {
1169
+ basalt::AppKitMountingManager manager;
1170
+ RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
1171
+ [root setRnFrameX:0 y:0 width:400 height:300];
1172
+
1173
+ NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 400, 300)
1174
+ styleMask:NSWindowStyleMaskTitled
1175
+ backing:NSBackingStoreBuffered
1176
+ defer:NO];
1177
+ window.releasedWhenClosed = NO;
1178
+ [window.contentView addSubview:root];
1179
+
1180
+ RnAppKitView *view =
1181
+ mountField(manager, 10, folly::dynamic::object("text", "select me")("autoFocus", true));
1182
+ NSView *field = fieldOf(view);
1183
+ EXPECT(window.firstResponder == RnPeerEditor(field));
1184
+ EXPECT(RnPeerSelection(field).length == 0);
1185
+
1186
+ // And the same field asking for the prop keeps the whole selection, which
1187
+ // is the one line of difference between the two paths.
1188
+ RnAppKitView *selecting = mountField(
1189
+ manager,
1190
+ 11,
1191
+ folly::dynamic::object("text", "select me")("autoFocus", true)("selectTextOnFocus", true));
1192
+ EXPECT(RnPeerSelection(fieldOf(selecting)).length == 9);
1193
+
1194
+ [window orderOut:nil];
1195
+ [window close];
1196
+ manager.destroySurfaceRoot(kSurfaceId);
1197
+ }
1198
+ }
1199
+
1200
+ // Both at once, in the order that means something: cleared first, so there is
1201
+ // nothing left to select. A host that selected first would leave a selection
1202
+ // over text it then threw away.
1203
+ TEST(textinput_clearing_and_selecting_at_once_leaves_an_empty_field) {
1204
+ @autoreleasepool {
1205
+ basalt::AppKitMountingManager manager;
1206
+ RnAppKitView *view = mountField(manager,
1207
+ 10,
1208
+ folly::dynamic::object("text", "both")("clearTextOnFocus", true)(
1209
+ "selectTextOnFocus", true));
1210
+ NSView *field = fieldOf(view);
1211
+
1212
+ NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 400, 300)
1213
+ styleMask:NSWindowStyleMaskTitled
1214
+ backing:NSBackingStoreBuffered
1215
+ defer:NO];
1216
+ window.releasedWhenClosed = NO;
1217
+ [window.contentView addSubview:manager.getSurfaceRoot(kSurfaceId)];
1218
+ EXPECT([window makeFirstResponder:field]);
1219
+
1220
+ EXPECT([RnPeerText(field) isEqualToString:@""]);
1221
+ EXPECT(RnPeerSelection(field).length == 0);
1222
+
1223
+ [window orderOut:nil];
1224
+ [window close];
1225
+ manager.destroySurfaceRoot(kSurfaceId);
1226
+ }
1227
+ }
1228
+
1229
+ // --- caretHidden and contextMenuHidden ---------------------------------------
1230
+ //
1231
+ // Two props with no AppKit property between them, going in by different routes:
1232
+ // the caret through the same remembered colour `cursorColor` uses, the menu by
1233
+ // the peer refusing to build one.
1234
+
1235
+ TEST(textinput_caret_hidden_asks_for_a_clear_insertion_point) {
1236
+ @autoreleasepool {
1237
+ basalt::AppKitMountingManager manager;
1238
+ RnAppKitView *view = mountField(manager, 10, folly::dynamic::object("caretHidden", true));
1239
+ NSView *field = fieldOf(view);
1240
+
1241
+ // Clear rather than nil: nil means AppKit's own, which is a caret you can
1242
+ // see. An NSTextView draws its insertion point as a filled rectangle, so a
1243
+ // clear one is drawn and invisible.
1244
+ NSColor *const caret = RnPeerCaretColour(field);
1245
+ EXPECT(caret != nil);
1246
+ if (caret != nil) {
1247
+ EXPECT_NEAR([caret colorUsingColorSpace:NSColorSpace.sRGBColorSpace].alphaComponent,
1248
+ 0.0,
1249
+ 0.001);
1250
+ }
1251
+
1252
+ manager.destroySurfaceRoot(kSurfaceId);
1253
+ }
1254
+ }
1255
+
1256
+ TEST(textinput_caret_hidden_wins_over_a_cursor_colour) {
1257
+ @autoreleasepool {
1258
+ // A field that asked for both means the hidden one: it cannot be both red
1259
+ // and invisible, and "hide it" is the more specific instruction.
1260
+ basalt::AppKitMountingManager manager;
1261
+ RnAppKitView *view = mountField(
1262
+ manager, 10, folly::dynamic::object("caretHidden", true)("cursorColor", 0xffff0000));
1263
+ NSColor *const caret =
1264
+ [RnPeerCaretColour(fieldOf(view)) colorUsingColorSpace:NSColorSpace.sRGBColorSpace];
1265
+ EXPECT(caret != nil);
1266
+ if (caret != nil) {
1267
+ EXPECT_NEAR(caret.alphaComponent, 0.0, 0.001);
1268
+ }
1269
+
1270
+ manager.destroySurfaceRoot(kSurfaceId);
1271
+ }
1272
+ }
1273
+
1274
+ TEST(textinput_a_field_that_did_not_ask_keeps_its_caret) {
1275
+ @autoreleasepool {
1276
+ // The control: nothing remembered, so AppKit's own caret.
1277
+ basalt::AppKitMountingManager manager;
1278
+ RnAppKitView *view = mountField(manager, 10, folly::dynamic::object("text", "visible"));
1279
+ EXPECT(RnPeerCaretColour(fieldOf(view)) == nil);
1280
+
1281
+ manager.destroySurfaceRoot(kSurfaceId);
1282
+ }
1283
+ }
1284
+
1285
+ namespace {
1286
+
1287
+ // A right-click, which is what `-menuForEvent:` is asked about.
1288
+ NSEvent *secondaryClick() {
1289
+ return [NSEvent mouseEventWithType:NSEventTypeRightMouseDown
1290
+ location:NSMakePoint(10, 10)
1291
+ modifierFlags:0
1292
+ timestamp:0
1293
+ windowNumber:0
1294
+ context:nil
1295
+ eventNumber:1
1296
+ clickCount:1
1297
+ pressure:1];
1298
+ }
1299
+
1300
+ } // namespace
1301
+
1302
+ // `contextMenuHidden` on a **multiline** field, which is the half of the prop
1303
+ // this host can answer: the peer there is this file's own NSTextView subclass,
1304
+ // and it is the view the click lands on.
1305
+ //
1306
+ // A single-line field is the half it cannot, and that is measured rather than
1307
+ // assumed -- see the test below.
1308
+ TEST(textinput_context_menu_hidden_refuses_to_build_one_on_a_multiline_field) {
1309
+ @autoreleasepool {
1310
+ basalt::AppKitMountingManager manager;
1311
+ RnAppKitView *view = mountField(
1312
+ manager, 10, folly::dynamic::object("multiline", true)("contextMenuHidden", true));
1313
+ NSView *area = fieldOf(view);
1314
+ EXPECT(RnPeerContextMenuHidden(area));
1315
+ // The behaviour rather than the flag: nil is how a view tells AppKit it has
1316
+ // no context menu.
1317
+ EXPECT([area menuForEvent:secondaryClick()] == nil);
1318
+
1319
+ manager.destroySurfaceRoot(kSurfaceId);
1320
+ }
1321
+ }
1322
+
1323
+ TEST(textinput_a_multiline_field_that_did_not_ask_keeps_its_context_menu) {
1324
+ @autoreleasepool {
1325
+ basalt::AppKitMountingManager manager;
1326
+ RnAppKitView *view = mountField(manager, 10, folly::dynamic::object("multiline", true));
1327
+ NSView *area = fieldOf(view);
1328
+ EXPECT(!RnPeerContextMenuHidden(area));
1329
+ EXPECT([area menuForEvent:secondaryClick()] != nil);
1330
+
1331
+ manager.destroySurfaceRoot(kSurfaceId);
1332
+ }
1333
+ }
1334
+
1335
+ // The single-line twin of the multiline test above, and the same claim: a
1336
+ // field that asked for `contextMenuHidden` shows no context menu. It sits
1337
+ // beside its twin because the difference between them is the whole of what
1338
+ // this host does not do yet, and reading the two together is the only way to
1339
+ // see it.
1340
+ //
1341
+ // The difference is which object AppKit asks. A multiline peer is this
1342
+ // project's own NSTextView subclass, so the click lands on a view that can
1343
+ // refuse, and `-menuForEvent:` returning nil is the whole of the prop. A
1344
+ // single-line field hands editing to the window's shared *field editor* the
1345
+ // moment it is focused, and that editor is a plain NSTextView nobody here
1346
+ // subclasses: it is the first responder, it is the view under the pointer, and
1347
+ // it is therefore the object that answers for the menu. The flag on the field
1348
+ // never gets asked. That is why this test focuses the field and questions
1349
+ // `currentEditor` rather than the peer -- questioning the peer would pass
1350
+ // today while a real right-click still opened Cut, Copy and Paste on a field
1351
+ // that had asked for none of them.
1352
+ //
1353
+ // Without this test the prop looks done on macOS: the flag is set, the tree
1354
+ // dump says `context-menu=hidden`, and the multiline half really does work, so
1355
+ // every assertion in the file passes while the common case -- a single-line
1356
+ // `<TextInput>` -- is unchanged. backlog/textinput.md records that taking the
1357
+ // editor's own menu away does not help either, because it rebuilds one inside
1358
+ // `-menuForEvent:`, so there is nothing to assert about `menu = nil`. What
1359
+ // closes it is supplying the field editor through
1360
+ // `-windowWillReturnFieldEditor:toObject:`, which is a window-level hook
1361
+ // rather than a prop-level one, and this test is what will say it worked.
1362
+ TEST(textinput_context_menu_hidden_refuses_to_build_one_on_a_single_line_field) {
1363
+ @autoreleasepool {
1364
+ basalt::AppKitMountingManager manager;
1365
+ RnAppKitView *view =
1366
+ mountField(manager, 10, folly::dynamic::object("contextMenuHidden", true));
1367
+ NSView *field = fieldOf(view);
1368
+ EXPECT(!RnPeerIsMultiline(field));
1369
+ EXPECT(RnPeerContextMenuHidden(field));
1370
+
1371
+ // A window, because an unfocused NSTextField has no field editor at all
1372
+ // and so nothing to ask: the editor is borrowed from the window when the
1373
+ // field starts editing. Never ordered front, which is what the rest of
1374
+ // this file's focus tests do too.
1375
+ NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 400, 300)
1376
+ styleMask:NSWindowStyleMaskTitled
1377
+ backing:NSBackingStoreBuffered
1378
+ defer:NO];
1379
+ window.releasedWhenClosed = NO;
1380
+ [window.contentView addSubview:manager.getSurfaceRoot(kSurfaceId)];
1381
+ EXPECT([window makeFirstResponder:field]);
1382
+
1383
+ NSTextView *editor = (NSTextView *)((NSTextField *)field).currentEditor;
1384
+ EXPECT(editor != nil);
1385
+ if (editor != nil) {
1386
+ // The behaviour rather than the flag, asked of the object that actually
1387
+ // answers it: nil is how a view tells AppKit it has no context menu.
1388
+ EXPECT([editor menuForEvent:secondaryClick()] == nil);
1389
+ }
1390
+
1391
+ [window orderOut:nil];
1392
+ [window close];
1393
+ manager.destroySurfaceRoot(kSurfaceId);
1394
+ }
1395
+ }
1396
+
1397
+ // And what a single-line field does with the prop, which is carry it: the menu
1398
+ // a focused NSTextField shows belongs to the window's shared *field editor*,
1399
+ // an NSTextView this file does not own, and that view rebuilds its menu inside
1400
+ // `-menuForEvent:` -- assigning `menu = nil` on it does not stop it, which was
1401
+ // measured rather than guessed.
1402
+ //
1403
+ // So the flag reaches the peer and the peer is not the view being asked.
1404
+ // backlog/textinput.md names the route that would close it, which is supplying
1405
+ // the field editor through `-windowWillReturnFieldEditor:toObject:`.
1406
+ // And both are in the tree dump, which is where the cross-host diff reads them:
1407
+ // each is the absence of something, so there is nothing else to compare.
1408
+ TEST(textinput_the_hiding_props_are_reported_in_the_tree) {
1409
+ @autoreleasepool {
1410
+ basalt::AppKitMountingManager manager;
1411
+ RnAppKitView *view = mountField(
1412
+ manager, 10, folly::dynamic::object("caretHidden", true)("contextMenuHidden", true));
1413
+ const std::string hidden = [view describeTree].UTF8String;
1414
+ EXPECT(hidden.find("caret=hidden") != std::string::npos);
1415
+ EXPECT(hidden.find("context-menu=hidden") != std::string::npos);
1416
+
1417
+ RnAppKitView *plain = mountField(manager, 11, folly::dynamic::object("text", "visible"));
1418
+ const std::string shown = [plain describeTree].UTF8String;
1419
+ EXPECT(shown.find("caret=hidden") == std::string::npos);
1420
+ EXPECT(shown.find("context-menu=hidden") == std::string::npos);
1421
+
1422
+ manager.destroySurfaceRoot(kSurfaceId);
1423
+ }
1424
+ }
1425
+
1426
+ TEST(textinput_a_single_line_field_carries_the_prop_it_cannot_act_on) {
1427
+ @autoreleasepool {
1428
+ basalt::AppKitMountingManager manager;
1429
+ RnAppKitView *view =
1430
+ mountField(manager, 10, folly::dynamic::object("contextMenuHidden", true));
1431
+ EXPECT(RnPeerContextMenuHidden(fieldOf(view)));
1432
+
1433
+ manager.destroySurfaceRoot(kSurfaceId);
1434
+ }
1435
+ }
1436
+
1437
+ // --- submitBehavior ----------------------------------------------------------
1438
+ //
1439
+ // What Return does. React Native resolves the default itself --
1440
+ // `getNonDefaultSubmitBehavior()` answers `blurAndSubmit` for a single-line
1441
+ // field and `newline` for a multiline one -- and this host used to leave it to
1442
+ // AppKit instead: an NSTextField keeps focus on Return, so every field behaved
1443
+ // as `submit` whatever the prop said.
1444
+ //
1445
+ // Entered through the delegate callback AppKit uses, which is the seam the
1446
+ // manager is wired to: a real Return needs a window, a key window and a run
1447
+ // loop, and what it would reach is this method.
1448
+ namespace {
1449
+
1450
+ // Return, as AppKit delivers it to a single-line field's delegate. Answers
1451
+ // whether the key was used up, which is what tells AppKit to insert nothing.
1452
+ bool pressReturn(basalt::AppKitMountingManager &manager, NSView *field) {
1453
+ id<NSTextFieldDelegate> delegate = ((NSTextField *)field).delegate;
1454
+ (void)manager;
1455
+ // The field editor is the `textView` AppKit passes, and the manager ignores
1456
+ // it -- which the cast here makes explicit rather than passing nil, since the
1457
+ // parameter is declared non-null.
1458
+ NSTextView *editor = (NSTextView *)((NSTextField *)field).currentEditor;
1459
+ if (editor == nil) {
1460
+ editor = [[NSTextView alloc] initWithFrame:NSZeroRect];
1461
+ }
1462
+ return [delegate control:(NSControl *)field
1463
+ textView:editor
1464
+ doCommandBySelector:@selector(insertNewline:)] == YES;
1465
+ }
1466
+
1467
+ } // namespace
1468
+
1469
+ TEST(textinput_return_submits_by_default) {
1470
+ @autoreleasepool {
1471
+ basalt::testing::EventRecorder recorder;
1472
+ basalt::AppKitMountingManager manager;
1473
+ RnAppKitView *view = mountField(manager,
1474
+ 90,
1475
+ folly::dynamic::object("text", "query"),
1476
+ recorder.emitter<TextInputEventEmitter>());
1477
+
1478
+ EXPECT(pressReturn(manager, fieldOf(view)));
1479
+
1480
+ const auto seen = recorder.seen();
1481
+ EXPECT(!seen.empty());
1482
+ EXPECT_EQ(seen.empty() ? std::string{} : seen.front(), std::string{"topSubmitEditing"});
1483
+
1484
+ manager.destroySurfaceRoot(kSurfaceId);
1485
+ }
1486
+ }
1487
+
1488
+ TEST(textinput_submit_behavior_newline_reports_nothing) {
1489
+ @autoreleasepool {
1490
+ // The point of the prop: a chat input that inserts newlines must not send a
1491
+ // message on Return. And the key is *not* used up, which is what lets a
1492
+ // multiline field insert the newline itself.
1493
+ basalt::testing::EventRecorder recorder;
1494
+ basalt::AppKitMountingManager manager;
1495
+ RnAppKitView *view =
1496
+ mountField(manager,
1497
+ 91,
1498
+ folly::dynamic::object("text", "query")("submitBehavior", "newline"),
1499
+ recorder.emitter<TextInputEventEmitter>());
1500
+
1501
+ EXPECT(!pressReturn(manager, fieldOf(view)));
1502
+ EXPECT(recorder.seen().empty());
1503
+
1504
+ manager.destroySurfaceRoot(kSurfaceId);
1505
+ }
1506
+ }
1507
+
1508
+ TEST(textinput_submit_behavior_submit_reports_and_stays) {
1509
+ @autoreleasepool {
1510
+ basalt::testing::EventRecorder recorder;
1511
+ basalt::AppKitMountingManager manager;
1512
+ RnAppKitView *view =
1513
+ mountField(manager,
1514
+ 92,
1515
+ folly::dynamic::object("text", "query")("submitBehavior", "submit"),
1516
+ recorder.emitter<TextInputEventEmitter>());
1517
+
1518
+ EXPECT(pressReturn(manager, fieldOf(view)));
1519
+
1520
+ // One event and no blur: `submit` is the half of the prop that keeps focus,
1521
+ // and a blur would arrive as `topBlur` and `topEndEditing` through the
1522
+ // delegate -- which is what the `blurAndSubmit` test below sees.
1523
+ const auto seen = recorder.seen();
1524
+ EXPECT_EQ(seen.size(), 1u);
1525
+ EXPECT_EQ(seen.empty() ? std::string{} : seen[0], std::string{"topSubmitEditing"});
1526
+
1527
+ manager.destroySurfaceRoot(kSurfaceId);
1528
+ }
1529
+ }
1530
+
1531
+ TEST(textinput_submit_behavior_blur_and_submit_gives_up_focus) {
1532
+ @autoreleasepool {
1533
+ // A real window, because giving up focus is a window's business: the field
1534
+ // is made first responder and the key is what has to take that away.
1535
+ basalt::testing::EventRecorder recorder;
1536
+ basalt::AppKitMountingManager manager;
1537
+ RnAppKitView *view =
1538
+ mountField(manager,
1539
+ 93,
1540
+ folly::dynamic::object("text", "query")("submitBehavior", "blurAndSubmit"),
1541
+ recorder.emitter<TextInputEventEmitter>());
1542
+
1543
+ NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 400, 300)
1544
+ styleMask:NSWindowStyleMaskTitled
1545
+ backing:NSBackingStoreBuffered
1546
+ defer:NO];
1547
+ [window.contentView addSubview:view];
1548
+ [window makeKeyAndOrderFront:nil];
1549
+ NSView *field = fieldOf(view);
1550
+ [window makeFirstResponder:field];
1551
+ EXPECT(window.firstResponder != window);
1552
+
1553
+ EXPECT(pressReturn(manager, field));
1554
+
1555
+ // Focus handed back to the window, which is this host's "nothing is
1556
+ // focused" -- the same move the `blur` command makes.
1557
+ EXPECT(window.firstResponder == window);
1558
+
1559
+ // Among the events rather than the first: making the field first
1560
+ // responder in a real window reports a focus and a selection on the way,
1561
+ // which is the price of needing a window to ask about focus at all.
1562
+ const auto seen = recorder.seen();
1563
+ EXPECT(std::find(seen.begin(), seen.end(), std::string{"topSubmitEditing"}) != seen.end());
1564
+
1565
+ manager.destroySurfaceRoot(kSurfaceId);
1566
+ }
1567
+ }