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.
- package/native/CMakeLists.txt +16 -2
- package/native/appkit/AppKitAppWindow.h +24 -0
- package/native/appkit/AppKitImageLoader.h +48 -1
- package/native/appkit/AppKitImageLoader.mm +228 -10
- package/native/appkit/AppKitMenuBar.mm +58 -0
- package/native/appkit/AppKitMountingManager.h +34 -0
- package/native/appkit/AppKitMountingManager.mm +576 -9
- package/native/appkit/AppKitScrollView.h +48 -0
- package/native/appkit/AppKitScrollView.mm +210 -0
- package/native/appkit/AppKitTextInput.h +38 -1
- package/native/appkit/AppKitTextInput.mm +235 -8
- package/native/appkit/AppKitTextPeer.h +60 -0
- package/native/appkit/AppKitTextPeer.mm +307 -0
- package/native/appkit/AppKitTouchDispatcher.h +56 -0
- package/native/appkit/AppKitTouchDispatcher.mm +140 -0
- package/native/appkit/AppKitWindowControl.mm +38 -3
- package/native/appkit/ComponentRegistryAppKit.mm +20 -0
- package/native/appkit/CoreTextLayout.h +64 -2
- package/native/appkit/CoreTextLayout.mm +431 -71
- package/native/appkit/CoreTextLayoutManager.mm +57 -6
- package/native/appkit/FontRegistryCoreText.mm +6 -5
- package/native/appkit/PlatformServicesAppKit.mm +101 -20
- package/native/appkit/RnAppKitView.h +492 -0
- package/native/appkit/RnAppKitView.mm +2266 -23
- package/native/appkit/RnTextLayout.h +106 -0
- package/native/appkit/RnTextLayout.mm +321 -2
- package/native/appkit/main_appkit.mm +174 -3
- package/native/tests/test_appkit_accessibility.mm +289 -0
- package/native/tests/test_appkit_borders.mm +975 -0
- package/native/tests/test_appkit_fonts.mm +203 -0
- package/native/tests/test_appkit_image.mm +392 -17
- package/native/tests/test_appkit_input.mm +209 -0
- package/native/tests/test_appkit_mounting.mm +360 -0
- package/native/tests/test_appkit_scrollbar.mm +125 -0
- package/native/tests/test_appkit_scrollview.mm +371 -0
- package/native/tests/test_appkit_selection.mm +349 -0
- package/native/tests/test_appkit_services.mm +51 -1
- package/native/tests/test_appkit_text.mm +1628 -0
- package/native/tests/test_appkit_textinput.mm +819 -0
- package/native/tests/test_appkit_titlebar.mm +43 -0
- package/native/tests/test_appkit_view.mm +609 -0
- 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
|
+
}
|