basalt-core 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 (149) hide show
  1. package/dist/cli/basalt.d.ts.map +1 -1
  2. package/dist/cli/basalt.js +18 -1
  3. package/dist/cli/basalt.js.map +1 -1
  4. package/dist/cli/bundle-cli.js +0 -0
  5. package/dist/cli/desktop.d.ts.map +1 -1
  6. package/dist/cli/desktop.js +53 -4
  7. package/dist/cli/desktop.js.map +1 -1
  8. package/dist/cli/packageApp.d.ts.map +1 -1
  9. package/dist/cli/packageApp.js +7 -1
  10. package/dist/cli/packageApp.js.map +1 -1
  11. package/dist/metro-config.d.ts.map +1 -1
  12. package/dist/metro-config.js +53 -5
  13. package/dist/metro-config.js.map +1 -1
  14. package/dist/src/KeyHandler.d.ts +1 -1
  15. package/dist/src/KeyHandler.d.ts.map +1 -1
  16. package/dist/src/KeyHandler.js.map +1 -1
  17. package/dist/src/index.d.ts +5 -0
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/overrides/Alert.d.ts +1 -1
  20. package/dist/src/overrides/Alert.js +1 -1
  21. package/dist/src/overrides/BaseViewConfig.d.ts +10 -3
  22. package/dist/src/overrides/BaseViewConfig.d.ts.map +1 -1
  23. package/dist/src/overrides/BaseViewConfig.js +21 -3
  24. package/dist/src/overrides/BaseViewConfig.js.map +1 -1
  25. package/dist/src/overrides/Button.d.ts +81 -0
  26. package/dist/src/overrides/Button.d.ts.map +1 -0
  27. package/dist/src/overrides/Button.js +109 -0
  28. package/dist/src/overrides/Button.js.map +1 -0
  29. package/dist/src/overrides/ScrollView.d.ts +25 -2
  30. package/dist/src/overrides/ScrollView.d.ts.map +1 -1
  31. package/dist/src/overrides/ScrollView.js +64 -7
  32. package/dist/src/overrides/ScrollView.js.map +1 -1
  33. package/dist/src/overrides/Settings.d.ts +81 -0
  34. package/dist/src/overrides/Settings.d.ts.map +1 -0
  35. package/dist/src/overrides/Settings.js +120 -0
  36. package/dist/src/overrides/Settings.js.map +1 -0
  37. package/dist/src/overrides/processDecelerationRate.d.ts +20 -0
  38. package/dist/src/overrides/processDecelerationRate.d.ts.map +1 -0
  39. package/dist/src/overrides/processDecelerationRate.js +31 -0
  40. package/dist/src/overrides/processDecelerationRate.js.map +1 -0
  41. package/dist/src/useContextMenu.d.ts +57 -6
  42. package/dist/src/useContextMenu.d.ts.map +1 -1
  43. package/dist/src/useContextMenu.js +34 -1
  44. package/dist/src/useContextMenu.js.map +1 -1
  45. package/dist/tsconfig.tsbuildinfo +1 -1
  46. package/native/CMakeLists.txt +85 -1
  47. package/native/cmake/Skia.cmake +29 -6
  48. package/native/core/ActionSheet.cpp +75 -0
  49. package/native/core/ActionSheet.h +90 -0
  50. package/native/core/AppStateHost.cpp +76 -0
  51. package/native/core/AppStateHost.h +68 -0
  52. package/native/core/BackgroundLayers.h +282 -0
  53. package/native/core/BlendModes.h +67 -0
  54. package/native/core/CoreModules.cpp +380 -11
  55. package/native/core/CoreModules.h +140 -4
  56. package/native/core/CrashHandler.cpp +246 -0
  57. package/native/core/CrashHandler.h +65 -0
  58. package/native/core/CursorNames.h +110 -0
  59. package/native/core/DevMenu.cpp +6 -5
  60. package/native/core/DeviceMetrics.cpp +87 -0
  61. package/native/core/DeviceMetrics.h +80 -0
  62. package/native/core/Filters.h +336 -0
  63. package/native/core/FontFitting.h +183 -0
  64. package/native/core/FontScaling.h +153 -0
  65. package/native/core/FontVariants.h +148 -0
  66. package/native/core/Gradients.h +523 -0
  67. package/native/core/ImageAnimation.h +109 -0
  68. package/native/core/ImageBytes.cpp +28 -1
  69. package/native/core/ImageBytes.h +17 -1
  70. package/native/core/ImageProgress.h +85 -0
  71. package/native/core/LabelRegistry.h +189 -0
  72. package/native/core/LegacyShadow.h +90 -0
  73. package/native/core/LiveRegions.h +114 -0
  74. package/native/core/MenuModule.cpp +35 -4
  75. package/native/core/MountingWalk.h +125 -2
  76. package/native/core/PlatformServices.h +187 -3
  77. package/native/core/SafeAreaComponent.cpp +115 -0
  78. package/native/core/SafeAreaComponent.h +105 -0
  79. package/native/core/ScreensComponent.cpp +201 -0
  80. package/native/core/ScreensComponent.h +216 -0
  81. package/native/core/ScrollBounds.cpp +8 -0
  82. package/native/core/ScrollBounds.h +14 -0
  83. package/native/core/ScrollIndicator.h +67 -0
  84. package/native/core/ScrollMomentum.cpp +24 -0
  85. package/native/core/ScrollMomentum.h +14 -0
  86. package/native/core/ScrollSnap.cpp +121 -12
  87. package/native/core/ScrollSnap.h +45 -0
  88. package/native/core/ScrollVisiblePosition.cpp +68 -0
  89. package/native/core/ScrollVisiblePosition.h +101 -0
  90. package/native/core/SettingsStore.cpp +199 -0
  91. package/native/core/SettingsStore.h +127 -0
  92. package/native/core/ShadowShape.h +38 -0
  93. package/native/core/SkiaViewComponent.cpp +11 -0
  94. package/native/core/SkiaViewComponent.h +50 -0
  95. package/native/core/SystemLocale.cpp +103 -0
  96. package/native/core/SystemLocale.h +86 -0
  97. package/native/core/TestDialog.cpp +45 -6
  98. package/native/core/TestDialog.h +15 -0
  99. package/native/core/TextAlignments.h +240 -0
  100. package/native/core/TextChecking.h +133 -0
  101. package/native/core/TextColors.h +83 -0
  102. package/native/core/TextDecorations.h +101 -0
  103. package/native/core/TextDirection.cpp +242 -0
  104. package/native/core/TextDirection.h +88 -0
  105. package/native/core/TextHighlight.h +30 -0
  106. package/native/core/TextSelection.cpp +97 -0
  107. package/native/core/TextSelection.h +161 -0
  108. package/native/core/TextShadows.h +96 -0
  109. package/native/core/TextVerticalAlign.h +33 -0
  110. package/native/core/UIManagerAccess.cpp +36 -0
  111. package/native/core/UIManagerAccess.h +28 -0
  112. package/native/core/WindowBoundsCache.cpp +58 -1
  113. package/native/core/WindowControl.h +24 -0
  114. package/native/core/WritingDirections.h +52 -0
  115. package/native/core/portability_probe.cpp +7 -0
  116. package/native/tests/RenamedFontFile.h +161 -0
  117. package/native/tests/TestHarness.cpp +18 -0
  118. package/native/tests/test_action_sheet.cpp +157 -0
  119. package/native/tests/test_app_state.cpp +60 -0
  120. package/native/tests/test_background_layers.cpp +281 -0
  121. package/native/tests/test_blend_modes.cpp +79 -0
  122. package/native/tests/test_blobs.cpp +1 -1
  123. package/native/tests/test_device_metrics.cpp +126 -0
  124. package/native/tests/test_filters.cpp +271 -0
  125. package/native/tests/test_font_fitting.cpp +172 -0
  126. package/native/tests/test_font_scaling.cpp +127 -0
  127. package/native/tests/test_font_variants.cpp +84 -0
  128. package/native/tests/test_gradients.cpp +390 -0
  129. package/native/tests/test_image_animation.cpp +105 -0
  130. package/native/tests/test_image_progress.cpp +94 -0
  131. package/native/tests/test_label_registry.cpp +262 -0
  132. package/native/tests/test_legacy_shadow.cpp +165 -0
  133. package/native/tests/test_live_regions.cpp +108 -0
  134. package/native/tests/test_menu_entries.cpp +192 -0
  135. package/native/tests/test_momentum.cpp +45 -0
  136. package/native/tests/test_scroll_bounds.cpp +42 -0
  137. package/native/tests/test_scroll_indicator.cpp +61 -0
  138. package/native/tests/test_scroll_snap.cpp +129 -0
  139. package/native/tests/test_scroll_visible_position.cpp +121 -0
  140. package/native/tests/test_settings_store.cpp +257 -0
  141. package/native/tests/test_shadow_shape.cpp +47 -0
  142. package/native/tests/test_system_locale.cpp +36 -0
  143. package/native/tests/test_text_alignment.cpp +299 -0
  144. package/native/tests/test_text_checking.cpp +31 -0
  145. package/native/tests/test_text_colors.cpp +92 -0
  146. package/native/tests/test_text_decorations.cpp +100 -0
  147. package/native/tests/test_text_selection.cpp +168 -0
  148. package/native/tests/test_text_shadows.cpp +132 -0
  149. package/package.json +4 -2
@@ -0,0 +1,108 @@
1
+ // Noticing that a status message changed, which is what
2
+ // `accessibilityLiveRegion` asks for.
3
+ //
4
+ // The prop is not a property of a view on either desktop -- GTK announces at a
5
+ // moment, AppKit posts a notification -- so honouring it is change detection,
6
+ // and the two rules that make it bearable rather than deafening are asserted
7
+ // here. See core/LiveRegions.h.
8
+
9
+ #include "TestHarness.h"
10
+
11
+ #include "LiveRegions.h"
12
+
13
+ #include <sstream>
14
+
15
+ using basalt::LiveRegionPoliteness;
16
+ using basalt::LiveRegionRegistry;
17
+
18
+ // Mounting a status line is not news: nothing changed, the screen appeared. A
19
+ // host that announced here would have every screen with a status message read
20
+ // itself out on arrival.
21
+ TEST(live_regions_the_first_text_is_not_announced) {
22
+ LiveRegionRegistry registry;
23
+ registry.setPoliteness(10, LiveRegionPoliteness::Polite);
24
+
25
+ EXPECT(!registry.noticed(10, "Saved").has_value());
26
+ }
27
+
28
+ TEST(live_regions_a_change_is_announced_with_the_politeness_asked_for) {
29
+ LiveRegionRegistry registry;
30
+ registry.setPoliteness(10, LiveRegionPoliteness::Polite);
31
+ registry.noticed(10, "Saving");
32
+
33
+ const auto polite = registry.noticed(10, "Saved");
34
+ EXPECT(polite.has_value());
35
+ EXPECT(polite.value() == LiveRegionPoliteness::Polite);
36
+
37
+ registry.setPoliteness(11, LiveRegionPoliteness::Assertive);
38
+ registry.noticed(11, "");
39
+ const auto assertive = registry.noticed(11, "Offline");
40
+ EXPECT(assertive.has_value());
41
+ EXPECT(assertive.value() == LiveRegionPoliteness::Assertive);
42
+ }
43
+
44
+ // A view re-renders for every reason under the sun, and React Native re-sends
45
+ // identical props on each mutation. A region that announced every time would
46
+ // talk over the rest of the application.
47
+ TEST(live_regions_the_same_text_twice_is_not_announced) {
48
+ LiveRegionRegistry registry;
49
+ registry.setPoliteness(10, LiveRegionPoliteness::Polite);
50
+ registry.noticed(10, "Saving");
51
+ EXPECT(registry.noticed(10, "Saved").has_value());
52
+
53
+ EXPECT(!registry.noticed(10, "Saved").has_value());
54
+ EXPECT(!registry.noticed(10, "Saved").has_value());
55
+ // And a change after that is news again.
56
+ EXPECT(registry.noticed(10, "Saved just now").has_value());
57
+ }
58
+
59
+ // A region that cleared itself has nothing to read out. Announcing an empty
60
+ // string is silence with a beat of interruption in front of it.
61
+ TEST(live_regions_clearing_the_text_announces_nothing) {
62
+ LiveRegionRegistry registry;
63
+ registry.setPoliteness(10, LiveRegionPoliteness::Polite);
64
+ registry.noticed(10, "Saved");
65
+ EXPECT(!registry.noticed(10, "").has_value());
66
+ // But the clearing was remembered, so the text coming back is a change.
67
+ EXPECT(registry.noticed(10, "Saved").has_value());
68
+ }
69
+
70
+ // A view that is not a live region never announces, whatever its text does.
71
+ TEST(live_regions_a_view_without_the_prop_is_silent) {
72
+ LiveRegionRegistry registry;
73
+ EXPECT(!registry.noticed(10, "Saving").has_value());
74
+ EXPECT(!registry.noticed(10, "Saved").has_value());
75
+
76
+ registry.setPoliteness(10, LiveRegionPoliteness::Polite);
77
+ registry.setPoliteness(10, LiveRegionPoliteness::None);
78
+ EXPECT(!registry.noticed(10, "Saved again").has_value());
79
+ }
80
+
81
+ // A region that is unmounted and comes back is new again, rather than compared
82
+ // against the text of a screen that is no longer there.
83
+ TEST(live_regions_a_region_that_goes_away_forgets_what_it_said) {
84
+ LiveRegionRegistry registry;
85
+ registry.setPoliteness(10, LiveRegionPoliteness::Polite);
86
+ registry.noticed(10, "Saved");
87
+
88
+ registry.forget(10);
89
+ registry.setPoliteness(10, LiveRegionPoliteness::Polite);
90
+ EXPECT(!registry.noticed(10, "Offline").has_value());
91
+ EXPECT(registry.noticed(10, "Saved").has_value());
92
+ }
93
+
94
+ // The hosts ask `empty()` before doing any work per transaction, collecting a
95
+ // region's text being a walk of its subtree.
96
+ TEST(live_regions_a_registry_with_no_regions_is_empty) {
97
+ LiveRegionRegistry registry;
98
+ EXPECT(registry.empty());
99
+ EXPECT_EQ((long)registry.tags().size(), 0L);
100
+
101
+ registry.setPoliteness(10, LiveRegionPoliteness::Assertive);
102
+ EXPECT(!registry.empty());
103
+ EXPECT_EQ((long)registry.tags().size(), 1L);
104
+ EXPECT_EQ((long)registry.tags()[0], 10L);
105
+
106
+ registry.setPoliteness(10, LiveRegionPoliteness::None);
107
+ EXPECT(registry.empty());
108
+ }
@@ -0,0 +1,192 @@
1
+ // The portable half of a popup menu: how its entries are numbered, which of them
2
+ // are drawn, and where a radio group begins and ends.
3
+ //
4
+ // This is where a popup menu's real bugs have been, and none of them were in the
5
+ // drawing. An index is an entry's position in a pre-order walk of the whole menu,
6
+ // and both sides of a JSI boundary have to agree on it: the hosts tag their items
7
+ // with it and `itemAt` in useContextMenu.ts walks the tree the app passed. The
8
+ // first attempt at roles filtered unsupported ones out of the vector, which would
9
+ // have had the two sides numbering different lists, and on Linux every item after
10
+ // an `about` would have run the handler of the one before it.
11
+ //
12
+ // The drawing is not here and is not asserted anywhere: `presentMenu` answers
13
+ // BASALT_TEST_MENU before `showMenu` is reached, so an automated run never builds
14
+ // a native menu at all. See docs/backlog/desktop-capabilities.md.
15
+
16
+ #include "TestHarness.h"
17
+
18
+ #include "PlatformServices.h"
19
+ #include "TestDialog.h"
20
+
21
+ #include <sstream>
22
+ #include <string>
23
+ #include <vector>
24
+
25
+ namespace {
26
+
27
+ using basalt::MenuEntry;
28
+
29
+ MenuEntry item(std::string label) {
30
+ MenuEntry entry;
31
+ entry.label = std::move(label);
32
+ return entry;
33
+ }
34
+
35
+ MenuEntry radio(std::string label, bool checked) {
36
+ MenuEntry entry = item(std::move(label));
37
+ entry.mark = MenuEntry::Mark::Radio;
38
+ entry.checked = checked;
39
+ return entry;
40
+ }
41
+
42
+ MenuEntry check(std::string label, bool checked) {
43
+ MenuEntry entry = item(std::move(label));
44
+ entry.mark = MenuEntry::Mark::Check;
45
+ entry.checked = checked;
46
+ return entry;
47
+ }
48
+
49
+ MenuEntry parent(std::string label, std::vector<MenuEntry> children) {
50
+ MenuEntry entry = item(std::move(label));
51
+ entry.submenu = std::move(children);
52
+ return entry;
53
+ }
54
+
55
+ } // namespace
56
+
57
+ TEST(menu_indexes_are_preorder_and_count_separators_and_parents) {
58
+ // A flat list is numbered by position, which is what an index meant before a
59
+ // popup could nest, so no caller's meaning changed when nesting arrived.
60
+ const std::vector<MenuEntry> flat = {item("a"), MenuEntry::separator(), item("b")};
61
+ EXPECT_EQ(basalt::menuEntryCount(flat), 3);
62
+ EXPECT_EQ(basalt::menuEntryAt(flat, 0)->label, std::string("a"));
63
+ EXPECT(basalt::menuEntryAt(flat, 1)->isSeparator());
64
+ EXPECT_EQ(basalt::menuEntryAt(flat, 2)->label, std::string("b"));
65
+
66
+ // Nested: the parent, then its children, then what follows it.
67
+ const std::vector<MenuEntry> nested = {
68
+ item("first"),
69
+ parent("branch", {item("leaf one"), item("leaf two")}),
70
+ item("last"),
71
+ };
72
+ EXPECT_EQ(basalt::menuEntryCount(nested), 5);
73
+ EXPECT_EQ(basalt::menuEntryAt(nested, 0)->label, std::string("first"));
74
+ EXPECT_EQ(basalt::menuEntryAt(nested, 1)->label, std::string("branch"));
75
+ EXPECT_EQ(basalt::menuEntryAt(nested, 2)->label, std::string("leaf one"));
76
+ EXPECT_EQ(basalt::menuEntryAt(nested, 3)->label, std::string("leaf two"));
77
+ // The one that matters: a parent's children do not push what follows it off by
78
+ // their own count, they are counted where they are.
79
+ EXPECT_EQ(basalt::menuEntryAt(nested, 4)->label, std::string("last"));
80
+ EXPECT(basalt::menuEntryAt(nested, 5) == nullptr);
81
+ }
82
+
83
+ TEST(menu_entries_the_platform_drops_still_count) {
84
+ // A role this desktop cannot perform is not drawn. It keeps its index anyway,
85
+ // because the index is into the list JavaScript passed: numbering only what was
86
+ // drawn would move every entry after it onto the wrong handler.
87
+ MenuEntry unsupported = item("about");
88
+ unsupported.role = "not-a-role-any-platform-has";
89
+ const std::vector<MenuEntry> entries = {item("before"), unsupported, item("after")};
90
+
91
+ EXPECT(!basalt::menuEntryShown(entries[1]));
92
+ EXPECT(basalt::menuEntryShown(entries[0]));
93
+ EXPECT_EQ(basalt::menuEntryCount(entries), 3);
94
+ EXPECT_EQ(basalt::menuEntryAt(entries, 2)->label, std::string("after"));
95
+ }
96
+
97
+ TEST(a_parent_with_nothing_drawable_under_it_is_not_drawn) {
98
+ MenuEntry unsupported = item("about");
99
+ unsupported.role = "not-a-role-any-platform-has";
100
+ const MenuEntry empty = parent("branch", {unsupported});
101
+ EXPECT(!basalt::menuEntryShown(empty));
102
+
103
+ // One drawable child is enough to keep the parent.
104
+ MenuEntry another = item("about");
105
+ another.role = "not-a-role-any-platform-has";
106
+ const MenuEntry mixed = parent("branch", {another, item("real")});
107
+ EXPECT(basalt::menuEntryShown(mixed));
108
+ }
109
+
110
+ TEST(a_radio_run_is_the_adjacent_radio_siblings) {
111
+ // Ended by a non-radio entry, by a separator, and by the end of the level.
112
+ const std::vector<MenuEntry> entries = {
113
+ item("plain"), // 0
114
+ radio("one", true), // 1
115
+ radio("two", false), // 2
116
+ MenuEntry::separator(), // 3
117
+ radio("three", false), // 4
118
+ radio("four", false), // 5
119
+ };
120
+
121
+ // The first run is 1..3, stopping at the separator rather than running on.
122
+ EXPECT_EQ(basalt::menuRadioRun(entries, 0, 1).first, 1);
123
+ EXPECT_EQ(basalt::menuRadioRun(entries, 0, 1).second, 3);
124
+ // Asking about any member gives the same run, which is what lets every member
125
+ // of a group share one action named after where the run starts.
126
+ EXPECT_EQ(basalt::menuRadioRun(entries, 0, 2).first, 1);
127
+ EXPECT_EQ(basalt::menuRadioRun(entries, 0, 2).second, 3);
128
+ // The second run is 4..6, ended by the end of the level.
129
+ EXPECT_EQ(basalt::menuRadioRun(entries, 0, 4).first, 4);
130
+ EXPECT_EQ(basalt::menuRadioRun(entries, 0, 4).second, 6);
131
+ // A non-radio entry is its own degenerate run, so a caller need not special-case.
132
+ EXPECT_EQ(basalt::menuRadioRun(entries, 0, 0).first, 0);
133
+ EXPECT_EQ(basalt::menuRadioRun(entries, 0, 0).second, 0);
134
+ }
135
+
136
+ TEST(a_radio_run_is_offset_by_the_level_it_is_on) {
137
+ // Inside a submenu the indexes are the pre-order ones, not positions in the
138
+ // sibling vector, which is what `firstIndexOfLevel` carries. A run named after
139
+ // a sibling position would collide between levels.
140
+ const std::vector<MenuEntry> children = {radio("a", false), radio("b", true)};
141
+ EXPECT_EQ(basalt::menuRadioRun(children, 7, 0).first, 7);
142
+ EXPECT_EQ(basalt::menuRadioRun(children, 7, 0).second, 9);
143
+ EXPECT_EQ(basalt::menuRadioRun(children, 7, 1).first, 7);
144
+ }
145
+
146
+ TEST(a_mark_does_not_change_whether_an_entry_is_drawn_or_counted) {
147
+ // A checked item is an ordinary choosable leaf. All three desktops can draw a
148
+ // mark, so nothing here is dropped for carrying one, and the index contract is
149
+ // untouched by it.
150
+ const std::vector<MenuEntry> entries = {check("ticked", true), radio("chosen", true),
151
+ check("unticked", false)};
152
+ for (const MenuEntry &entry : entries) {
153
+ EXPECT(basalt::menuEntryShown(entry));
154
+ EXPECT(!entry.isSeparator());
155
+ EXPECT(!entry.isParent());
156
+ }
157
+ EXPECT_EQ(basalt::menuEntryCount(entries), 3);
158
+ }
159
+
160
+ // --- What a script may answer ------------------------------------------------
161
+ //
162
+ // `BASALT_TEST_MENU` skips the presentation and nothing else, so the index it
163
+ // names has to be one a person could have clicked. Four things are not: an
164
+ // index past the end of the tree, a separator, a submenu's parent, and an entry
165
+ // that is drawn greyed. The last was missing until an action sheet put its title
166
+ // into the menu as a disabled entry; see core/ActionSheet.h.
167
+
168
+ TEST(a_script_may_only_answer_what_a_person_could_have_clicked) {
169
+ const std::vector<MenuEntry> entries = {item("first"), MenuEntry::separator(),
170
+ parent("more", {item("nested")}), item("last")};
171
+
172
+ EXPECT_EQ(basalt::scriptedMenuIndex(entries, 0), 0);
173
+ // The separator, which is a line.
174
+ EXPECT_EQ(basalt::scriptedMenuIndex(entries, 1), -1);
175
+ // The parent, which opens rather than chooses -- and its child, which is a
176
+ // real choice at index 3 of the pre-order walk.
177
+ EXPECT_EQ(basalt::scriptedMenuIndex(entries, 2), -1);
178
+ EXPECT_EQ(basalt::scriptedMenuIndex(entries, 3), 3);
179
+ EXPECT_EQ(basalt::scriptedMenuIndex(entries, 4), 4);
180
+ // Past the end, and a dismissal, which is already one.
181
+ EXPECT_EQ(basalt::scriptedMenuIndex(entries, 5), -1);
182
+ EXPECT_EQ(basalt::scriptedMenuIndex(entries, -1), -1);
183
+ }
184
+
185
+ TEST(a_script_cannot_choose_a_disabled_entry) {
186
+ MenuEntry greyed = item("not now");
187
+ greyed.enabled = false;
188
+ const std::vector<MenuEntry> entries = {greyed, item("go")};
189
+
190
+ EXPECT_EQ(basalt::scriptedMenuIndex(entries, 0), -1);
191
+ EXPECT_EQ(basalt::scriptedMenuIndex(entries, 1), 1);
192
+ }
@@ -136,3 +136,48 @@ TEST(momentum_a_deceleration_rate_of_one_would_never_stop) {
136
136
  EXPECT(flight.frames < 6000);
137
137
  EXPECT(!momentum.isRunning());
138
138
  }
139
+
140
+ // The projection, which is the same model asked where it would end up rather
141
+ // than stepped there.
142
+ //
143
+ // It exists for `core/ScrollSnap.h`: a snapping list has to know where momentum
144
+ // would have carried it before it can choose a point. So the test that matters
145
+ // is that the closed form and the animation agree -- an integral that drifted
146
+ // from the frames would make a list settle somewhere it was never heading.
147
+ TEST(momentum_the_projection_agrees_with_the_frames) {
148
+ ScrollMomentum momentum;
149
+ EXPECT(momentum.start(0, 1200, ScrollMomentum::kNormalDeceleration));
150
+ const Flight flight = flyY(momentum);
151
+
152
+ const double projected =
153
+ basalt::scrollMomentumDistance(1200, ScrollMomentum::kNormalDeceleration);
154
+ EXPECT(projected > 0);
155
+ // Within a few per cent. The stepped version is deliberately the cruder of
156
+ // the two -- it moves at the velocity each frame *started* with, which
157
+ // overshoots slightly -- and ScrollMomentum.cpp says so where it does it.
158
+ EXPECT(projected > flight.distanceY * 0.9);
159
+ EXPECT(projected < flight.distanceY * 1.1);
160
+ }
161
+
162
+ TEST(momentum_the_projection_keeps_the_sign_and_scales_with_the_throw) {
163
+ const double gentle = basalt::scrollMomentumDistance(400, ScrollMomentum::kNormalDeceleration);
164
+ const double hard = basalt::scrollMomentumDistance(2000, ScrollMomentum::kNormalDeceleration);
165
+ EXPECT(gentle > 0);
166
+ EXPECT(hard > gentle * 2);
167
+
168
+ // Backwards travels backwards, which is the sign convention everything else
169
+ // here uses: positive velocity grows the content offset.
170
+ EXPECT_NEAR(basalt::scrollMomentumDistance(-2000, ScrollMomentum::kNormalDeceleration),
171
+ -hard,
172
+ 0.0001);
173
+
174
+ // A faster rate stops sooner, the same way the stepped model does.
175
+ EXPECT(basalt::scrollMomentumDistance(2000, ScrollMomentum::kFastDeceleration) < hard);
176
+ }
177
+
178
+ TEST(momentum_a_release_is_projected_nowhere) {
179
+ // The same threshold `start` refuses at: below it there is no fling, so there
180
+ // is nothing to project and a snapping list should settle where it is.
181
+ EXPECT_NEAR(basalt::scrollMomentumDistance(10, ScrollMomentum::kNormalDeceleration), 0.0, 0.0001);
182
+ EXPECT_NEAR(basalt::scrollMomentumDistance(0, ScrollMomentum::kNormalDeceleration), 0.0, 0.0001);
183
+ }
@@ -64,3 +64,45 @@ TEST(bounds_both_insets_extend_both_ends) {
64
64
  EXPECT_NEAR(range.maximum, 330.0, 0.001);
65
65
  EXPECT_NEAR(range.length(), 350.0, 0.001);
66
66
  }
67
+
68
+ // --- centerContent -----------------------------------------------------------
69
+ //
70
+ // An inset rather than a position, because the range above is already the thing
71
+ // that decides where content rests: half the slack at each end leaves exactly
72
+ // one offset to be at, and the clamp every host runs on every mutation puts the
73
+ // content there. iOS reaches it the same way, assigning a `contentInset`.
74
+
75
+ TEST(bounds_centering_insets_are_half_the_slack_at_each_end) {
76
+ const basalt::ScrollAxisInsets insets = basalt::centeringInsets(300, 200);
77
+ EXPECT_NEAR(insets.leading, 50.0, 0.0001);
78
+ EXPECT_NEAR(insets.trailing, 50.0, 0.0001);
79
+ }
80
+
81
+ TEST(bounds_centering_leaves_one_offset_to_rest_at) {
82
+ // The whole point: a range of no length, in the middle. So a view that was at
83
+ // zero is pulled to -50, which draws the content half the slack further down.
84
+ const basalt::ScrollRange range =
85
+ basalt::scrollRangeFor(300, 200, basalt::centeringInsets(300, 200));
86
+ EXPECT_NEAR(range.minimum, -50.0, 0.0001);
87
+ EXPECT_NEAR(range.maximum, -50.0, 0.0001);
88
+ EXPECT_NEAR(basalt::clampScrollOffset(0, 300, 200, basalt::centeringInsets(300, 200)),
89
+ -50.0,
90
+ 0.0001);
91
+ }
92
+
93
+ TEST(bounds_centering_does_nothing_to_content_that_can_scroll) {
94
+ // Content longer than its container has no slack to share out, and an inset
95
+ // here would be a list that could be pulled past its own first row.
96
+ const basalt::ScrollAxisInsets insets = basalt::centeringInsets(300, 4000);
97
+ EXPECT_NEAR(insets.leading, 0.0, 0.0001);
98
+ EXPECT_NEAR(insets.trailing, 0.0, 0.0001);
99
+ EXPECT_NEAR(basalt::clampScrollOffset(0, 300, 4000, insets), 0.0, 0.0001);
100
+ }
101
+
102
+ TEST(bounds_centering_content_exactly_the_container_is_not_centred) {
103
+ // The boundary, and the one that would divide by nothing useful: there is no
104
+ // slack, so there is no inset and the view rests at zero.
105
+ const basalt::ScrollAxisInsets insets = basalt::centeringInsets(300, 300);
106
+ EXPECT_NEAR(insets.leading, 0.0, 0.0001);
107
+ EXPECT_NEAR(insets.trailing, 0.0, 0.0001);
108
+ }
@@ -147,3 +147,64 @@ TEST(indicator_content_that_fits_but_can_be_pulled_still_has_a_bar) {
147
147
  basalt::scrollIndicatorFor(100.0, 100.0, 0.0, basalt::ScrollAxisInsets{50.0, 0.0});
148
148
  EXPECT(indicator.visible);
149
149
  }
150
+
151
+ // --- indicatorStyle ----------------------------------------------------------
152
+ //
153
+ // The colour the thumb is drawn in, decided here so that the three hosts cannot
154
+ // answer the same prop differently -- each one used to spell its own black.
155
+
156
+ TEST(indicator_white_is_white) {
157
+ const basalt::ScrollIndicatorColour white =
158
+ basalt::scrollIndicatorColourFor(basalt::ScrollIndicatorStyle::White);
159
+ EXPECT_NEAR(white.red, 1.0, 0.0001);
160
+ EXPECT_NEAR(white.green, 1.0, 0.0001);
161
+ EXPECT_NEAR(white.blue, 1.0, 0.0001);
162
+ }
163
+
164
+ TEST(indicator_the_default_is_black) {
165
+ const basalt::ScrollIndicatorColour black =
166
+ basalt::scrollIndicatorColourFor(basalt::ScrollIndicatorStyle::Default);
167
+ EXPECT_NEAR(black.red, 0.0, 0.0001);
168
+ EXPECT_NEAR(black.green, 0.0, 0.0001);
169
+ EXPECT_NEAR(black.blue, 0.0, 0.0001);
170
+ }
171
+
172
+ TEST(indicator_default_and_black_share_a_fill_and_differ_by_a_border) {
173
+ // Which is exactly how iOS's two differ:
174
+ // `UIScrollViewIndicatorStyleDefault` is "black with a white border" and
175
+ // `.black` is black alone. The fill being the same is half the claim and
176
+ // used to be the whole of it, back when neither drew a border.
177
+ const basalt::ScrollIndicatorColour fallback =
178
+ basalt::scrollIndicatorColourFor(basalt::ScrollIndicatorStyle::Default);
179
+ const basalt::ScrollIndicatorColour black =
180
+ basalt::scrollIndicatorColourFor(basalt::ScrollIndicatorStyle::Black);
181
+ EXPECT_NEAR(fallback.red, black.red, 0.0001);
182
+ EXPECT_NEAR(fallback.alpha, black.alpha, 0.0001);
183
+
184
+ // And the border is the other half: white, and on `default` alone.
185
+ const basalt::ScrollIndicatorColour border =
186
+ basalt::scrollIndicatorBorderColourFor(basalt::ScrollIndicatorStyle::Default);
187
+ EXPECT_NEAR(border.red, 1.0, 0.0001);
188
+ EXPECT_NEAR(border.green, 1.0, 0.0001);
189
+ EXPECT_NEAR(border.blue, 1.0, 0.0001);
190
+ EXPECT(border.alpha > basalt::kScrollIndicatorAlpha);
191
+
192
+ // The two styles an app picks because it knows what is behind the thumb have
193
+ // none: iOS draws neither with an outline, and adding one would be this
194
+ // platform inventing a look.
195
+ EXPECT_NEAR(basalt::scrollIndicatorBorderColourFor(basalt::ScrollIndicatorStyle::Black).alpha,
196
+ 0.0, 0.0001);
197
+ EXPECT_NEAR(basalt::scrollIndicatorBorderColourFor(basalt::ScrollIndicatorStyle::White).alpha,
198
+ 0.0, 0.0001);
199
+ }
200
+
201
+ TEST(indicator_both_colours_are_equally_translucent) {
202
+ // A white thumb more opaque than the black one would read as a different
203
+ // control rather than the same one in another colour.
204
+ EXPECT_NEAR(basalt::scrollIndicatorColourFor(basalt::ScrollIndicatorStyle::White).alpha,
205
+ basalt::kScrollIndicatorAlpha,
206
+ 0.0001);
207
+ EXPECT_NEAR(basalt::scrollIndicatorColourFor(basalt::ScrollIndicatorStyle::Black).alpha,
208
+ basalt::kScrollIndicatorAlpha,
209
+ 0.0001);
210
+ }
@@ -101,6 +101,100 @@ TEST(snap_centre_alignment_lines_up_the_middle) {
101
101
  EXPECT_EQ(snapped(config, 210, 0), 200.0);
102
102
  }
103
103
 
104
+ // --- disableIntervalMomentum, and the momentum it disables -----------------
105
+ //
106
+ // A spacing is not a page: upstream lets the fling carry across several points
107
+ // and settles at the one it arrives near, which is what `snapToInterval` means
108
+ // on iOS. The prop asks for the paged behaviour instead.
109
+ //
110
+ // The projection is `core/ScrollMomentum.h`'s, so these numbers are the
111
+ // friction model's rather than a target: 2000px/s at the normal rate is about
112
+ // 984 pixels, which puts the next 250-point boundary at 1000.
113
+
114
+ TEST(snap_a_flick_carries_as_far_as_it_was_thrown) {
115
+ ScrollSnapConfig config;
116
+ config.interval = 250;
117
+ EXPECT_EQ(snapped(config, 0, 2000), 1000.0);
118
+ // And a gentler one carries less, which is the rule the header states and
119
+ // the reason the projection is velocity-dependent rather than a page.
120
+ EXPECT_EQ(snapped(config, 0, 400), 250.0);
121
+ }
122
+
123
+ TEST(snap_momentum_across_an_interval_can_be_disabled) {
124
+ ScrollSnapConfig config;
125
+ config.interval = 250;
126
+ config.disableIntervalMomentum = true;
127
+ // The next point from where the finger left, however hard the throw.
128
+ EXPECT_EQ(snapped(config, 0, 2000), 250.0);
129
+ EXPECT_EQ(snapped(config, 0, 400), 250.0);
130
+ }
131
+
132
+ TEST(snap_paging_turns_one_page_however_hard_it_is_thrown) {
133
+ // Not a regression test for the projection so much as a statement that it
134
+ // does not apply here: a paged view turns one page per flick on iOS, and a
135
+ // throw worth two and a half pages still turns one.
136
+ ScrollSnapConfig config;
137
+ config.paging = true;
138
+ EXPECT_EQ(snapped(config, 0, 4000), kContainer);
139
+ }
140
+
141
+ // --- snapToStart and snapToEnd ---------------------------------------------
142
+ //
143
+ // Both default to true, and what they are the default *of* is that the
144
+ // content's own edges count as snap points beside the listed ones. Which is
145
+ // easy to miss: a list of offsets that does not include 0 could not be scrolled
146
+ // back to the top and held there.
147
+
148
+ TEST(snap_the_content_edges_are_snap_points_by_default) {
149
+ ScrollSnapConfig config;
150
+ config.offsets = {300, 600};
151
+
152
+ // Nearer the top than the first listed point, released: the top.
153
+ EXPECT_EQ(snapped(config, 100, 0), 0.0);
154
+ // And nearer the end than the last listed point: the end, which is the
155
+ // content minus the container.
156
+ EXPECT_EQ(snapped(config, 1500, 0), kContent - kContainer);
157
+ }
158
+
159
+ TEST(snap_to_start_false_scrolls_freely_before_the_first_offset) {
160
+ ScrollSnapConfig config;
161
+ config.offsets = {300, 600};
162
+ config.snapToStart = false;
163
+ // No answer at all, which is what lets the list coast: each host treats a
164
+ // target of nothing as "not a snapping gesture" and runs the fling.
165
+ EXPECT(!scrollSnapTarget(config, 100, 0, kContainer, kContent).has_value());
166
+ // The listed points still snap, so this is the gap being freed rather than
167
+ // snapping being switched off.
168
+ EXPECT_EQ(snapped(config, 320, 0), 300.0);
169
+ }
170
+
171
+ TEST(snap_to_end_false_scrolls_freely_past_the_last_offset) {
172
+ ScrollSnapConfig config;
173
+ config.offsets = {300, 600};
174
+ config.snapToEnd = false;
175
+ EXPECT(!scrollSnapTarget(config, 1500, 0, kContainer, kContent).has_value());
176
+ // The listed points still snap: it is the gap past the last of them that was
177
+ // freed, not snapping itself. A list at 620 is in that gap and coasts, which
178
+ // is what the assertion above says; one at 320 is not.
179
+ EXPECT_EQ(snapped(config, 320, 0), 300.0);
180
+ }
181
+
182
+ TEST(snap_a_freed_edge_is_still_reached_from_before_it) {
183
+ // The half of upstream's rule that is easy to drop: a list thrown from
184
+ // *inside* the snapping region towards a freed edge settles on the last
185
+ // listed point rather than sailing into the gap. Only a list already in the
186
+ // gap coasts.
187
+ ScrollSnapConfig config;
188
+ config.offsets = {300, 600};
189
+ config.snapToEnd = false;
190
+ EXPECT_EQ(snapped(config, 500, 2000), 600.0);
191
+
192
+ ScrollSnapConfig fromTheOtherEnd;
193
+ fromTheOtherEnd.offsets = {300, 600};
194
+ fromTheOtherEnd.snapToStart = false;
195
+ EXPECT_EQ(snapped(fromTheOtherEnd, 500, -2000), 300.0);
196
+ }
197
+
104
198
  TEST(snap_content_shorter_than_the_container_stays_at_zero) {
105
199
  ScrollSnapConfig config;
106
200
  config.paging = true;
@@ -110,3 +204,38 @@ TEST(snap_content_shorter_than_the_container_stays_at_zero) {
110
204
  }
111
205
 
112
206
  } // namespace
207
+
208
+ // What a tree dump says a scroll view's snapping is, which is the only way an
209
+ // end-to-end run can see any of these props: a snap point changes where a fling
210
+ // *ends*, and a dump is taken when nothing is moving.
211
+ TEST(scroll_snap_describes_itself_for_a_dump) {
212
+ ScrollSnapConfig plain;
213
+ EXPECT(basalt::describeScrollSnap(plain).empty());
214
+
215
+ ScrollSnapConfig paged;
216
+ paged.paging = true;
217
+ EXPECT(basalt::describeScrollSnap(paged) == " snap=paging");
218
+
219
+ ScrollSnapConfig interval;
220
+ interval.interval = 420.0;
221
+ interval.disableIntervalMomentum = true;
222
+ EXPECT(basalt::describeScrollSnap(interval) == " snap=interval:420,momentum=off");
223
+
224
+ ScrollSnapConfig offsets;
225
+ offsets.offsets = {0.0, 100.0, 250.0};
226
+ offsets.alignment = ScrollSnapAlignment::Center;
227
+ offsets.snapToEnd = false;
228
+ EXPECT(basalt::describeScrollSnap(offsets) == " snap=offsets:3,align=center,end=off");
229
+
230
+ // The rate rides along, and only when it is not React Native's `'normal'`:
231
+ // it is what the landing point of a fling is projected with, which is the
232
+ // point a snap is chosen from.
233
+ ScrollSnapConfig fast;
234
+ fast.interval = 100.0;
235
+ fast.decelerationRate = 0.99;
236
+ EXPECT(basalt::describeScrollSnap(fast) == " snap=interval:100 decel=0.99");
237
+
238
+ ScrollSnapConfig rateOnly;
239
+ rateOnly.decelerationRate = 0.9;
240
+ EXPECT(basalt::describeScrollSnap(rateOnly) == " decel=0.9");
241
+ }