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
@@ -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
+ }