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.
- 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 +582 -12
- package/native/appkit/AppKitScrollView.h +48 -0
- package/native/appkit/AppKitScrollView.mm +210 -0
- package/native/appkit/AppKitSkiaPeer.h +15 -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 +28 -3
- 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
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
|
|
14
14
|
#include "TestHarness.h"
|
|
15
15
|
|
|
16
|
+
#include "EventRecorder.h"
|
|
17
|
+
|
|
18
|
+
#include <algorithm>
|
|
19
|
+
#include <string>
|
|
20
|
+
|
|
16
21
|
#include "PointerButtons.h"
|
|
17
22
|
|
|
18
23
|
#import "AppKitFocus.h"
|
|
@@ -850,3 +855,207 @@ TEST(focus_activating_nothing_is_not_a_click) {
|
|
|
850
855
|
manager.destroySurfaceRoot(kSurfaceId);
|
|
851
856
|
}
|
|
852
857
|
}
|
|
858
|
+
|
|
859
|
+
// `hitSlop`: how far outside its own box a view answers a press.
|
|
860
|
+
//
|
|
861
|
+
// Deliberately the same four questions the GTK suite asks of it, in the same
|
|
862
|
+
// order and with the same geometry, because the two hit tests are separate code
|
|
863
|
+
// answering one contract -- the day they disagree, these fail here rather than
|
|
864
|
+
// in an app. Asserted through the hit test rather than against the stored
|
|
865
|
+
// insets: a view that remembered its slop and did not widen its box would leave
|
|
866
|
+
// every target the size it was.
|
|
867
|
+
TEST(appkit_hit_slop_grows_the_target_outside_the_box) {
|
|
868
|
+
@autoreleasepool {
|
|
869
|
+
RnAppKitView *root = box(1, 0, 0, 400, 400);
|
|
870
|
+
RnAppKitView *icon = box(60, 100, 100, 20, 20);
|
|
871
|
+
[root insertRnChild:icon atIndex:0];
|
|
872
|
+
const CGFloat insets[4] = {12, 12, 12, 12};
|
|
873
|
+
[icon setRnHitSlop:insets];
|
|
874
|
+
|
|
875
|
+
EXPECT_EQ((long)hit(root, 110, 110), 60L);
|
|
876
|
+
EXPECT_EQ((long)hit(root, 92, 110), 60L);
|
|
877
|
+
EXPECT_EQ((long)hit(root, 128, 110), 60L);
|
|
878
|
+
EXPECT_EQ((long)hit(root, 110, 92), 60L);
|
|
879
|
+
EXPECT_EQ((long)hit(root, 110, 128), 60L);
|
|
880
|
+
EXPECT_EQ((long)hit(root, 80, 110), 1L);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Each edge on its own: four numbers in one struct is four chances to read one
|
|
885
|
+
// into the wrong side, and a slop that grew the top when the app asked for the
|
|
886
|
+
// bottom would pass any symmetric test.
|
|
887
|
+
TEST(appkit_hit_slop_applies_each_edge_where_it_was_asked_for) {
|
|
888
|
+
@autoreleasepool {
|
|
889
|
+
RnAppKitView *root = box(1, 0, 0, 400, 400);
|
|
890
|
+
RnAppKitView *view = box(61, 100, 100, 20, 20);
|
|
891
|
+
[root insertRnChild:view atIndex:0];
|
|
892
|
+
|
|
893
|
+
const CGFloat top[4] = {15, 0, 0, 0};
|
|
894
|
+
[view setRnHitSlop:top];
|
|
895
|
+
EXPECT_EQ((long)hit(root, 110, 90), 61L);
|
|
896
|
+
EXPECT_EQ((long)hit(root, 110, 130), 1L);
|
|
897
|
+
EXPECT_EQ((long)hit(root, 90, 110), 1L);
|
|
898
|
+
EXPECT_EQ((long)hit(root, 130, 110), 1L);
|
|
899
|
+
|
|
900
|
+
const CGFloat bottom[4] = {0, 0, 15, 0};
|
|
901
|
+
[view setRnHitSlop:bottom];
|
|
902
|
+
EXPECT_EQ((long)hit(root, 110, 130), 61L);
|
|
903
|
+
EXPECT_EQ((long)hit(root, 110, 90), 1L);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
TEST(appkit_hit_slop_can_be_taken_away_again) {
|
|
908
|
+
@autoreleasepool {
|
|
909
|
+
RnAppKitView *root = box(1, 0, 0, 400, 400);
|
|
910
|
+
RnAppKitView *view = box(62, 100, 100, 20, 20);
|
|
911
|
+
[root insertRnChild:view atIndex:0];
|
|
912
|
+
const CGFloat insets[4] = {12, 12, 12, 12};
|
|
913
|
+
[view setRnHitSlop:insets];
|
|
914
|
+
EXPECT_EQ((long)hit(root, 92, 110), 62L);
|
|
915
|
+
|
|
916
|
+
[view setRnHitSlop:nullptr];
|
|
917
|
+
EXPECT_EQ((long)hit(root, 92, 110), 1L);
|
|
918
|
+
EXPECT_EQ((long)hit(root, 110, 110), 62L);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// A slop that overlaps a sibling does not win over it: the sibling is drawn on
|
|
923
|
+
// top, and a target reaching under something visible would take presses meant
|
|
924
|
+
// for it.
|
|
925
|
+
TEST(appkit_hit_slop_does_not_beat_a_view_drawn_over_it) {
|
|
926
|
+
@autoreleasepool {
|
|
927
|
+
RnAppKitView *root = box(1, 0, 0, 400, 400);
|
|
928
|
+
RnAppKitView *first = box(63, 100, 100, 20, 20);
|
|
929
|
+
[root insertRnChild:first atIndex:0];
|
|
930
|
+
const CGFloat insets[4] = {0, 40, 0, 0};
|
|
931
|
+
[first setRnHitSlop:insets];
|
|
932
|
+
|
|
933
|
+
RnAppKitView *second = box(64, 130, 100, 20, 20);
|
|
934
|
+
[root insertRnChild:second atIndex:1];
|
|
935
|
+
|
|
936
|
+
EXPECT_EQ((long)hit(root, 125, 110), 63L);
|
|
937
|
+
EXPECT_EQ((long)hit(root, 140, 110), 64L);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
TEST(appkit_hit_slop_is_reported_in_the_tree) {
|
|
942
|
+
@autoreleasepool {
|
|
943
|
+
RnAppKitView *view = box(1, 0, 0, 20, 20);
|
|
944
|
+
EXPECT(![[view describeTree] containsString:@"hit-slop="]);
|
|
945
|
+
|
|
946
|
+
const CGFloat insets[4] = {1, 2, 3, 4};
|
|
947
|
+
[view setRnHitSlop:insets];
|
|
948
|
+
EXPECT([[view describeTree] containsString:@"hit-slop=(1,2,3,4)"]);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
namespace {
|
|
953
|
+
|
|
954
|
+
// The recorder reports every event it saw, in order; these two tests ask only
|
|
955
|
+
// whether a kind arrived at all.
|
|
956
|
+
bool saw(const basalt::testing::EventRecorder &recorder, const std::string &type) {
|
|
957
|
+
const auto seen = recorder.seen();
|
|
958
|
+
return std::find(seen.begin(), seen.end(), type) != seen.end();
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
} // namespace
|
|
962
|
+
|
|
963
|
+
// A press the window cannot finish, which is React Native's `touchCancel`.
|
|
964
|
+
//
|
|
965
|
+
// A touch has to end one of two ways. A press that simply stops is the shape
|
|
966
|
+
// that leaves a `<Pressable>` highlighted for the rest of the screen's life:
|
|
967
|
+
// `onPressOut` never runs and the responder is never released. GTK gets this
|
|
968
|
+
// from its gesture recogniser's `cancelled` signal; AppKit has none, so the
|
|
969
|
+
// dispatcher watches for its window to stop being key, which is what happens
|
|
970
|
+
// when the person switches app mid-press.
|
|
971
|
+
//
|
|
972
|
+
// The whole path is here rather than just the cancel: the press goes in through
|
|
973
|
+
// the same input handler AppKit calls, the notification is the real one, and
|
|
974
|
+
// the event comes out of a real emitter.
|
|
975
|
+
TEST(a_press_is_cancelled_when_the_window_stops_being_key) {
|
|
976
|
+
@autoreleasepool {
|
|
977
|
+
const basalt::testing::EventRecorder recorder;
|
|
978
|
+
basalt::AppKitMountingManager manager;
|
|
979
|
+
RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
|
|
980
|
+
[root setRnFrameX:0 y:0 width:400 height:300];
|
|
981
|
+
|
|
982
|
+
ShadowView view = makeView(10, 40, 50, 120, 60);
|
|
983
|
+
view.eventEmitter = recorder.emitter<facebook::react::TouchEventEmitter>();
|
|
984
|
+
ShadowViewMutationList mutations;
|
|
985
|
+
mutations.push_back(ShadowViewMutation::CreateMutation(view));
|
|
986
|
+
mutations.push_back(ShadowViewMutation::InsertMutation(kSurfaceId, view, 0));
|
|
987
|
+
apply(manager, std::move(mutations));
|
|
988
|
+
|
|
989
|
+
// A window, because the notification this listens for names one and an
|
|
990
|
+
// unparented root belongs to none.
|
|
991
|
+
NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 400, 300)
|
|
992
|
+
styleMask:NSWindowStyleMaskTitled
|
|
993
|
+
backing:NSBackingStoreBuffered
|
|
994
|
+
defer:NO];
|
|
995
|
+
// Not released when closed: see test_appkit_services.mm.
|
|
996
|
+
window.releasedWhenClosed = NO;
|
|
997
|
+
[window.contentView addSubview:root];
|
|
998
|
+
|
|
999
|
+
basalt::AppKitTouchDispatcher dispatcher(&manager, root);
|
|
1000
|
+
|
|
1001
|
+
// Down and *not* up, which is the state the cancel is about.
|
|
1002
|
+
[root.rnInputHandler rnMouseDownAt:NSMakePoint(50, 60) button:0];
|
|
1003
|
+
EXPECT(saw(recorder, "topTouchStart"));
|
|
1004
|
+
EXPECT(!saw(recorder, "topTouchCancel"));
|
|
1005
|
+
|
|
1006
|
+
[NSNotificationCenter.defaultCenter postNotificationName:NSWindowDidResignKeyNotification
|
|
1007
|
+
object:window];
|
|
1008
|
+
EXPECT(saw(recorder, "topTouchCancel"));
|
|
1009
|
+
// And not an end: a cancelled touch is not a completed one, and a
|
|
1010
|
+
// `<Pressable>` treats the two differently.
|
|
1011
|
+
EXPECT(!saw(recorder, "topTouchEnd"));
|
|
1012
|
+
|
|
1013
|
+
[window orderOut:nil];
|
|
1014
|
+
[window close];
|
|
1015
|
+
manager.destroySurfaceRoot(kSurfaceId);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Another window losing focus is not this press's business. With several
|
|
1020
|
+
// windows open, a dispatcher that cancelled on every notification would cancel
|
|
1021
|
+
// every other window's touch.
|
|
1022
|
+
TEST(a_press_survives_another_windows_focus_loss) {
|
|
1023
|
+
@autoreleasepool {
|
|
1024
|
+
const basalt::testing::EventRecorder recorder;
|
|
1025
|
+
basalt::AppKitMountingManager manager;
|
|
1026
|
+
RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
|
|
1027
|
+
[root setRnFrameX:0 y:0 width:400 height:300];
|
|
1028
|
+
|
|
1029
|
+
ShadowView view = makeView(10, 40, 50, 120, 60);
|
|
1030
|
+
view.eventEmitter = recorder.emitter<facebook::react::TouchEventEmitter>();
|
|
1031
|
+
ShadowViewMutationList mutations;
|
|
1032
|
+
mutations.push_back(ShadowViewMutation::CreateMutation(view));
|
|
1033
|
+
mutations.push_back(ShadowViewMutation::InsertMutation(kSurfaceId, view, 0));
|
|
1034
|
+
apply(manager, std::move(mutations));
|
|
1035
|
+
|
|
1036
|
+
NSWindow *mine = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 400, 300)
|
|
1037
|
+
styleMask:NSWindowStyleMaskTitled
|
|
1038
|
+
backing:NSBackingStoreBuffered
|
|
1039
|
+
defer:NO];
|
|
1040
|
+
mine.releasedWhenClosed = NO;
|
|
1041
|
+
[mine.contentView addSubview:root];
|
|
1042
|
+
NSWindow *other = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 200, 100)
|
|
1043
|
+
styleMask:NSWindowStyleMaskTitled
|
|
1044
|
+
backing:NSBackingStoreBuffered
|
|
1045
|
+
defer:NO];
|
|
1046
|
+
other.releasedWhenClosed = NO;
|
|
1047
|
+
|
|
1048
|
+
basalt::AppKitTouchDispatcher dispatcher(&manager, root);
|
|
1049
|
+
[root.rnInputHandler rnMouseDownAt:NSMakePoint(50, 60) button:0];
|
|
1050
|
+
EXPECT(saw(recorder, "topTouchStart"));
|
|
1051
|
+
|
|
1052
|
+
[NSNotificationCenter.defaultCenter postNotificationName:NSWindowDidResignKeyNotification
|
|
1053
|
+
object:other];
|
|
1054
|
+
EXPECT(!saw(recorder, "topTouchCancel"));
|
|
1055
|
+
|
|
1056
|
+
[mine orderOut:nil];
|
|
1057
|
+
[mine close];
|
|
1058
|
+
[other close];
|
|
1059
|
+
manager.destroySurfaceRoot(kSurfaceId);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
#include "TreeDump.h"
|
|
11
11
|
|
|
12
12
|
#import "AppKitMountingManager.h"
|
|
13
|
+
#import "RnAppKitView.h"
|
|
13
14
|
|
|
14
15
|
#include <react/renderer/components/view/ViewProps.h>
|
|
15
16
|
#include <react/renderer/graphics/Color.h>
|
|
@@ -428,3 +429,362 @@ TEST(no_transform_origin_anchors_the_centre) {
|
|
|
428
429
|
manager.destroySurfaceRoot(kSurfaceId);
|
|
429
430
|
}
|
|
430
431
|
}
|
|
432
|
+
|
|
433
|
+
// `accessibilityLabelledBy` through a real transaction, which is the half the
|
|
434
|
+
// view-level tests in test_appkit_accessibility.mm cannot reach: the prop names
|
|
435
|
+
// another view by its `nativeID`, and Fabric mounts a field before the label
|
|
436
|
+
// that follows it.
|
|
437
|
+
//
|
|
438
|
+
// Deliberately the same two cases the GTK suite asserts, in the same order and
|
|
439
|
+
// with the same tags, for the reason the rest of this file is: the resolution is
|
|
440
|
+
// shared in core/LabelRegistry.h, and the day the sharing stops holding these
|
|
441
|
+
// fail here rather than in an app.
|
|
442
|
+
namespace {
|
|
443
|
+
|
|
444
|
+
ShadowView makeNamedView(Tag tag, const std::string &nativeId) {
|
|
445
|
+
ShadowView view = makeView(tag, 0, 0, 100, 50);
|
|
446
|
+
auto props = std::make_shared<ViewProps>();
|
|
447
|
+
props->nativeId = nativeId;
|
|
448
|
+
view.props = props;
|
|
449
|
+
return view;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
ShadowView makeLabelledView(Tag tag, std::vector<std::string> ids) {
|
|
453
|
+
ShadowView view = makeView(tag, 0, 0, 100, 50);
|
|
454
|
+
auto props = std::make_shared<ViewProps>();
|
|
455
|
+
props->accessibilityLabelledBy.value = std::move(ids);
|
|
456
|
+
view.props = props;
|
|
457
|
+
return view;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
} // namespace
|
|
461
|
+
|
|
462
|
+
TEST(appkit_mounting_labelled_by_resolves_a_native_id) {
|
|
463
|
+
@autoreleasepool {
|
|
464
|
+
basalt::AppKitMountingManager manager;
|
|
465
|
+
manager.createSurfaceRoot(kSurfaceId);
|
|
466
|
+
|
|
467
|
+
ShadowViewMutationList mutations;
|
|
468
|
+
mutations.push_back(ShadowViewMutation::CreateMutation(makeLabelledView(10, {"name-label"})));
|
|
469
|
+
mutations.push_back(
|
|
470
|
+
ShadowViewMutation::InsertMutation(kSurfaceId, makeLabelledView(10, {"name-label"}), 0));
|
|
471
|
+
mutations.push_back(ShadowViewMutation::CreateMutation(makeNamedView(20, "name-label")));
|
|
472
|
+
mutations.push_back(
|
|
473
|
+
ShadowViewMutation::InsertMutation(kSurfaceId, makeNamedView(20, "name-label"), 1));
|
|
474
|
+
apply(manager, std::move(mutations));
|
|
475
|
+
|
|
476
|
+
RnAppKitView *field = manager.viewForTag(10);
|
|
477
|
+
EXPECT(field != nil);
|
|
478
|
+
EXPECT(field.accessibilityTitleUIElement == manager.viewForTag(20));
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// The label in a later transaction, which is what a resolution done as the props
|
|
483
|
+
// arrived would miss.
|
|
484
|
+
TEST(appkit_mounting_labelled_by_waits_for_a_label_that_mounts_later) {
|
|
485
|
+
@autoreleasepool {
|
|
486
|
+
basalt::AppKitMountingManager manager;
|
|
487
|
+
manager.createSurfaceRoot(kSurfaceId);
|
|
488
|
+
|
|
489
|
+
ShadowViewMutationList first;
|
|
490
|
+
first.push_back(ShadowViewMutation::CreateMutation(makeLabelledView(10, {"name-label"})));
|
|
491
|
+
first.push_back(
|
|
492
|
+
ShadowViewMutation::InsertMutation(kSurfaceId, makeLabelledView(10, {"name-label"}), 0));
|
|
493
|
+
apply(manager, std::move(first));
|
|
494
|
+
|
|
495
|
+
RnAppKitView *field = manager.viewForTag(10);
|
|
496
|
+
EXPECT(field.accessibilityTitleUIElement == nil);
|
|
497
|
+
|
|
498
|
+
ShadowViewMutationList second;
|
|
499
|
+
second.push_back(ShadowViewMutation::CreateMutation(makeNamedView(20, "name-label")));
|
|
500
|
+
second.push_back(
|
|
501
|
+
ShadowViewMutation::InsertMutation(kSurfaceId, makeNamedView(20, "name-label"), 1));
|
|
502
|
+
manager.applyTransaction(
|
|
503
|
+
kSurfaceId, MountingTransaction(kSurfaceId, 2, std::move(second), TransactionTelemetry{}));
|
|
504
|
+
|
|
505
|
+
EXPECT(field.accessibilityTitleUIElement == manager.viewForTag(20));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// `accessibilityLiveRegion`: a status message read out when it changes.
|
|
510
|
+
//
|
|
511
|
+
// Nothing in an automated run is running VoiceOver, so what is asserted is the
|
|
512
|
+
// decision rather than the speech: `-rnAnnounce:assertive:` records the text it
|
|
513
|
+
// posted and these read it back. The rules -- what counts as a change -- are
|
|
514
|
+
// core/LiveRegions.h's and are tested there; this is the wiring, and it is the
|
|
515
|
+
// same four cases the GTK suite asserts.
|
|
516
|
+
namespace {
|
|
517
|
+
|
|
518
|
+
ShadowView makeStatusView(Tag tag,
|
|
519
|
+
facebook::react::AccessibilityLiveRegion politeness,
|
|
520
|
+
const std::string &label) {
|
|
521
|
+
ShadowView view = makeView(tag, 0, 0, 200, 40);
|
|
522
|
+
auto props = std::make_shared<ViewProps>();
|
|
523
|
+
props->accessibilityLiveRegion = politeness;
|
|
524
|
+
props->accessibilityLabel = label;
|
|
525
|
+
view.props = props;
|
|
526
|
+
return view;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
void mountStatus(basalt::AppKitMountingManager &manager, const ShadowView &view, int transaction) {
|
|
530
|
+
ShadowViewMutationList mutations;
|
|
531
|
+
if (transaction == 1) {
|
|
532
|
+
mutations.push_back(ShadowViewMutation::CreateMutation(view));
|
|
533
|
+
mutations.push_back(ShadowViewMutation::InsertMutation(kSurfaceId, view, 0));
|
|
534
|
+
} else {
|
|
535
|
+
mutations.push_back(ShadowViewMutation::UpdateMutation(view, view, kSurfaceId));
|
|
536
|
+
}
|
|
537
|
+
manager.applyTransaction(
|
|
538
|
+
kSurfaceId,
|
|
539
|
+
MountingTransaction(kSurfaceId, transaction, std::move(mutations), TransactionTelemetry{}));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
} // namespace
|
|
543
|
+
|
|
544
|
+
TEST(appkit_mounting_a_live_region_says_nothing_when_it_appears) {
|
|
545
|
+
@autoreleasepool {
|
|
546
|
+
basalt::AppKitMountingManager manager;
|
|
547
|
+
manager.createSurfaceRoot(kSurfaceId);
|
|
548
|
+
|
|
549
|
+
using facebook::react::AccessibilityLiveRegion;
|
|
550
|
+
mountStatus(manager, makeStatusView(10, AccessibilityLiveRegion::Polite, "Saving"), 1);
|
|
551
|
+
// Mounting a status line is not news: the screen appeared, nothing changed.
|
|
552
|
+
EXPECT(manager.viewForTag(10).rnLastAnnouncement == nil);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
TEST(appkit_mounting_a_live_region_announces_a_change) {
|
|
557
|
+
@autoreleasepool {
|
|
558
|
+
basalt::AppKitMountingManager manager;
|
|
559
|
+
manager.createSurfaceRoot(kSurfaceId);
|
|
560
|
+
|
|
561
|
+
using facebook::react::AccessibilityLiveRegion;
|
|
562
|
+
mountStatus(manager, makeStatusView(10, AccessibilityLiveRegion::Polite, "Saving"), 1);
|
|
563
|
+
mountStatus(manager, makeStatusView(10, AccessibilityLiveRegion::Polite, "Saved"), 2);
|
|
564
|
+
|
|
565
|
+
RnAppKitView *status = manager.viewForTag(10);
|
|
566
|
+
EXPECT(status.rnLastAnnouncement != nil);
|
|
567
|
+
EXPECT([status.rnLastAnnouncement isEqualToString:@"Saved"]);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
TEST(appkit_mounting_a_view_that_is_not_a_live_region_stays_quiet) {
|
|
572
|
+
@autoreleasepool {
|
|
573
|
+
basalt::AppKitMountingManager manager;
|
|
574
|
+
manager.createSurfaceRoot(kSurfaceId);
|
|
575
|
+
|
|
576
|
+
using facebook::react::AccessibilityLiveRegion;
|
|
577
|
+
mountStatus(manager, makeStatusView(10, AccessibilityLiveRegion::None, "Saving"), 1);
|
|
578
|
+
mountStatus(manager, makeStatusView(10, AccessibilityLiveRegion::None, "Saved"), 2);
|
|
579
|
+
EXPECT(manager.viewForTag(10).rnLastAnnouncement == nil);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
TEST(appkit_mounting_a_live_region_that_stops_being_one_goes_quiet) {
|
|
584
|
+
@autoreleasepool {
|
|
585
|
+
basalt::AppKitMountingManager manager;
|
|
586
|
+
manager.createSurfaceRoot(kSurfaceId);
|
|
587
|
+
|
|
588
|
+
using facebook::react::AccessibilityLiveRegion;
|
|
589
|
+
mountStatus(manager, makeStatusView(10, AccessibilityLiveRegion::Assertive, "Saving"), 1);
|
|
590
|
+
mountStatus(manager, makeStatusView(10, AccessibilityLiveRegion::Assertive, "Saved"), 2);
|
|
591
|
+
RnAppKitView *status = manager.viewForTag(10);
|
|
592
|
+
EXPECT([status.rnLastAnnouncement isEqualToString:@"Saved"]);
|
|
593
|
+
|
|
594
|
+
mountStatus(manager, makeStatusView(10, AccessibilityLiveRegion::None, "Saved again"), 3);
|
|
595
|
+
// Still the old announcement: nothing new was said.
|
|
596
|
+
EXPECT([status.rnLastAnnouncement isEqualToString:@"Saved"]);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// The other source of a region's text: a <Text> inside it rather than a label on
|
|
601
|
+
// it. Asserted against the collector directly, a Paragraph with real state being
|
|
602
|
+
// more machinery than the question needs.
|
|
603
|
+
TEST(appkit_collected_text_is_every_paragraph_in_the_subtree) {
|
|
604
|
+
@autoreleasepool {
|
|
605
|
+
RnAppKitView *region = [RnAppKitView viewWithTag:1];
|
|
606
|
+
[region setRnFrameX:0 y:0 width:200 height:100];
|
|
607
|
+
EXPECT_EQ(region.rnCollectedText.length, 0UL);
|
|
608
|
+
|
|
609
|
+
RnAppKitView *first = [RnAppKitView viewWithTag:2];
|
|
610
|
+
[first setRnFrameX:0 y:0 width:200 height:20];
|
|
611
|
+
[region insertRnChild:first atIndex:0];
|
|
612
|
+
RnAppKitView *second = [RnAppKitView viewWithTag:3];
|
|
613
|
+
[second setRnFrameX:0 y:20 width:200 height:20];
|
|
614
|
+
[region insertRnChild:second atIndex:1];
|
|
615
|
+
// No text layouts, so still nothing: a tree of plain views collects nothing
|
|
616
|
+
// rather than crashing on the way down.
|
|
617
|
+
EXPECT_EQ(region.rnCollectedText.length, 0UL);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
// What the loader reads out of an animated file.
|
|
623
|
+
//
|
|
624
|
+
// Here rather than in tests/test_appkit_image.mm because it needs the loader,
|
|
625
|
+
// which needs React Native's IImageLoader, and that file is compiled into the
|
|
626
|
+
// toolkit-only build as well. The view's half of animation is there; this is
|
|
627
|
+
// the half that reads the GIF's own bytes.
|
|
628
|
+
// ---------------------------------------------------------------------------
|
|
629
|
+
namespace {
|
|
630
|
+
|
|
631
|
+
// The same four-pixel GIF the view tests use: red for 80ms, blue for 40ms,
|
|
632
|
+
// looping forever. See tests/test_appkit_image.mm for how it was written.
|
|
633
|
+
const char *const kAnimatedGifDataUri =
|
|
634
|
+
"data:image/gif;base64,"
|
|
635
|
+
"R0lGODlhBAAEAPAAAP8AAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQACAAAACwAAAAABAAEAAAC"
|
|
636
|
+
"BISPCQUAIfkEAAQAAAAsAAAAAAQABACAAAD/AAAAAgSEjwkFADs=";
|
|
637
|
+
|
|
638
|
+
// A 4x2 still PNG, which must come back with no animation at all.
|
|
639
|
+
const char *const kStillPngDataUri =
|
|
640
|
+
"data:image/png;base64,"
|
|
641
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAQAAAACCAIAAADwyuo0AAAAFUlEQVR4nGN44OAA"
|
|
642
|
+
"RA4JD4CIAZkDAJQaC4Enje7+AAAAAElFTkSuQmCC";
|
|
643
|
+
|
|
644
|
+
// Loads a URI and runs the main run loop until the answer arrives.
|
|
645
|
+
//
|
|
646
|
+
// The decode is on a worker thread and the delivery is a `dispatch_async` to
|
|
647
|
+
// the main queue, which nothing in this suite otherwise drains, so the pump is
|
|
648
|
+
// what makes a loader testable at all.
|
|
649
|
+
bool loadAndWait(basalt::AppKitImageLoader &loader, const std::string &uri) {
|
|
650
|
+
bool done = false;
|
|
651
|
+
bool decoded = false;
|
|
652
|
+
loader.load(uri, [&](CGImageRef image, const std::string &) {
|
|
653
|
+
decoded = image != nullptr;
|
|
654
|
+
done = true;
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:5.0];
|
|
658
|
+
while (!done && deadline.timeIntervalSinceNow > 0) {
|
|
659
|
+
[NSRunLoop.currentRunLoop runMode:NSDefaultRunLoopMode
|
|
660
|
+
beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.01]];
|
|
661
|
+
}
|
|
662
|
+
return done && decoded;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
} // namespace
|
|
666
|
+
|
|
667
|
+
TEST(appkit_the_loader_reads_an_animations_frames_and_delays) {
|
|
668
|
+
@autoreleasepool {
|
|
669
|
+
basalt::AppKitImageLoader loader;
|
|
670
|
+
const std::string uri = kAnimatedGifDataUri;
|
|
671
|
+
EXPECT(loadAndWait(loader, uri));
|
|
672
|
+
|
|
673
|
+
const basalt::AppKitImageLoader::Animation *animation = loader.animation(uri);
|
|
674
|
+
EXPECT(animation != nullptr);
|
|
675
|
+
if (animation == nullptr) {
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
EXPECT_EQ((int)animation->frames.count, 2);
|
|
679
|
+
EXPECT_EQ((int)animation->delaysMs.count, 2);
|
|
680
|
+
// The file's own delays, per frame and unclamped. Equal numbers here would
|
|
681
|
+
// pass with a loader that read the first frame's delay and reused it.
|
|
682
|
+
EXPECT_EQ(animation->delaysMs[0].intValue, 80);
|
|
683
|
+
EXPECT_EQ(animation->delaysMs[1].intValue, 40);
|
|
684
|
+
// Zero is forever, which is what this GIF's NETSCAPE2.0 extension says.
|
|
685
|
+
EXPECT_EQ((int)animation->loopCount, 0);
|
|
686
|
+
|
|
687
|
+
// The same arrays on a second load, which is what lets a view tell a
|
|
688
|
+
// re-mount from a new animation.
|
|
689
|
+
EXPECT(loadAndWait(loader, uri));
|
|
690
|
+
EXPECT(loader.animation(uri)->frames == animation->frames);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// The same picture with no delays at all, which a great many GIFs carry:
|
|
695
|
+
//
|
|
696
|
+
// magick -loop 0 -size 4x4 xc:'#ff0000' -size 4x4 xc:'#0000ff' anim.gif
|
|
697
|
+
TEST(appkit_the_loader_leaves_a_zero_delay_alone) {
|
|
698
|
+
@autoreleasepool {
|
|
699
|
+
basalt::AppKitImageLoader loader;
|
|
700
|
+
const std::string uri =
|
|
701
|
+
"data:image/gif;base64,"
|
|
702
|
+
"R0lGODlhBAAEAPAAAP8AAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQAAAAAACwAAAAABAAEAAAC"
|
|
703
|
+
"BISPCQUAIfkEAAAAAAAsAAAAAAQABACAAAD/AAAAAgSEjwkFADs=";
|
|
704
|
+
EXPECT(loadAndWait(loader, uri));
|
|
705
|
+
|
|
706
|
+
const basalt::AppKitImageLoader::Animation *animation = loader.animation(uri);
|
|
707
|
+
EXPECT(animation != nullptr);
|
|
708
|
+
if (animation == nullptr) {
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
// Unclamped, deliberately: ImageIO reports what the file says and
|
|
712
|
+
// core/ImageAnimation.h owns the rule, so both hosts and the next one apply
|
|
713
|
+
// the same one in the same place. The GTK host's gdk-pixbuf answers 100 for
|
|
714
|
+
// this file itself, which is why the clamp has to live where both can see
|
|
715
|
+
// it rather than in whichever decoder happens to need it.
|
|
716
|
+
EXPECT_EQ((int)animation->frames.count, 2);
|
|
717
|
+
EXPECT_EQ(animation->delaysMs[0].intValue, 0);
|
|
718
|
+
EXPECT_EQ(animation->delaysMs[1].intValue, 0);
|
|
719
|
+
|
|
720
|
+
// And the view paces it at a tenth of a second a frame, which is the clamp
|
|
721
|
+
// doing the work: without it the cycle is zero long and the first frame is
|
|
722
|
+
// the only one ever shown.
|
|
723
|
+
RnAppKitView *view = [RnAppKitView viewWithTag:1];
|
|
724
|
+
[view setRnFrameX:0 y:0 width:20 height:20];
|
|
725
|
+
[view setRnImageFrames:animation->frames
|
|
726
|
+
delaysMs:animation->delaysMs
|
|
727
|
+
loopCount:animation->loopCount];
|
|
728
|
+
EXPECT_NEAR([view rnAdvanceImageAnimationBy:0], 100.0, 0.01);
|
|
729
|
+
[view rnAdvanceImageAnimationBy:99];
|
|
730
|
+
EXPECT_EQ((int)view.rnImageFrameIndex, 0);
|
|
731
|
+
[view rnAdvanceImageAnimationBy:1];
|
|
732
|
+
EXPECT_EQ((int)view.rnImageFrameIndex, 1);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
TEST(appkit_the_loader_reports_no_animation_for_a_still_image) {
|
|
737
|
+
@autoreleasepool {
|
|
738
|
+
basalt::AppKitImageLoader loader;
|
|
739
|
+
const std::string uri = kStillPngDataUri;
|
|
740
|
+
EXPECT(loadAndWait(loader, uri));
|
|
741
|
+
EXPECT(loader.animation(uri) == nullptr);
|
|
742
|
+
EXPECT(loader.animation("data:image/png;base64,never-loaded") == nullptr);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
// A GIF with no NETSCAPE2.0 extension at all, which is what "play once" looks
|
|
748
|
+
// like in the format:
|
|
749
|
+
//
|
|
750
|
+
// magick -loop 1 -delay 8 -size 4x4 xc:'#ff0000' \
|
|
751
|
+
// -delay 4 -size 4x4 xc:'#0000ff' once.gif
|
|
752
|
+
//
|
|
753
|
+
// writes no application extension rather than a count of one.
|
|
754
|
+
//
|
|
755
|
+
// **Both hosts stop after one pass, which was measured rather than assumed.**
|
|
756
|
+
// ImageIO answers `kCGImagePropertyGIFLoopCount` with 1 for this file, not with
|
|
757
|
+
// nothing, so the loop count needs no special case for an absent extension;
|
|
758
|
+
// gdk-pixbuf's iterator reports a delay of -1 at the end of the same file,
|
|
759
|
+
// which this host's `rn_view_animation_delay_ms` turns into "nothing more is
|
|
760
|
+
// due". So a non-looping GIF plays once on both, and neither of them had to be
|
|
761
|
+
// taught it.
|
|
762
|
+
TEST(appkit_a_gif_that_does_not_loop_plays_once) {
|
|
763
|
+
@autoreleasepool {
|
|
764
|
+
basalt::AppKitImageLoader loader;
|
|
765
|
+
const std::string uri = std::string("data:image/gif;base64,")
|
|
766
|
+
+ "R0lGODlhBAAEAPAAAP8AAAAAACH5BAAIAAAALAAAAAAEAAQAAAIEhI8JBQAh+QQABAAAACwAAAAABAAE"
|
|
767
|
+
"AIAAAP8AAAACBISPCQUAOw==";
|
|
768
|
+
EXPECT(loadAndWait(loader, uri));
|
|
769
|
+
|
|
770
|
+
const basalt::AppKitImageLoader::Animation *animation = loader.animation(uri);
|
|
771
|
+
EXPECT(animation != nullptr);
|
|
772
|
+
if (animation == nullptr) {
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
EXPECT_EQ((int)animation->loopCount, 1);
|
|
776
|
+
|
|
777
|
+
RnAppKitView *view = [RnAppKitView viewWithTag:1];
|
|
778
|
+
[view setRnFrameX:0 y:0 width:20 height:20];
|
|
779
|
+
[view setRnImageFrames:animation->frames
|
|
780
|
+
delaysMs:animation->delaysMs
|
|
781
|
+
loopCount:animation->loopCount];
|
|
782
|
+
|
|
783
|
+
// Halfway through, still running.
|
|
784
|
+
EXPECT([view rnAdvanceImageAnimationBy:60] > 0.0);
|
|
785
|
+
// One pass is 120ms, after which nothing more is due and the last frame
|
|
786
|
+
// stays.
|
|
787
|
+
EXPECT_NEAR([view rnAdvanceImageAnimationBy:60], 0.0, 0.01);
|
|
788
|
+
EXPECT_EQ((int)view.rnImageFrameIndex, 1);
|
|
789
|
+
}
|
|
790
|
+
}
|