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,126 @@
1
+ // What `Dimensions` and `PixelRatio` are told, which until 2026-10-10 was a
2
+ // hardcoded 1280x720 with a zero scale and an uninitialised font scale. See
3
+ // core/DeviceMetrics.h.
4
+
5
+ #include "TestHarness.h"
6
+
7
+ #include "DeviceMetrics.h"
8
+
9
+ #include <sstream>
10
+ #include <vector>
11
+
12
+ using basalt::deviceMetricsFrom;
13
+ using basalt::DisplayInfo;
14
+ using basalt::WindowBounds;
15
+
16
+ namespace {
17
+
18
+ DisplayInfo display(double x, double width, double scale, bool primary) {
19
+ DisplayInfo info;
20
+ info.x = x;
21
+ info.y = 0.0;
22
+ info.width = width;
23
+ info.height = 900.0;
24
+ info.workX = x;
25
+ info.workWidth = width;
26
+ info.workHeight = 900.0;
27
+ info.scaleFactor = scale;
28
+ info.primary = primary;
29
+ return info;
30
+ }
31
+
32
+ WindowBounds window(double x, double width, double height) {
33
+ WindowBounds bounds;
34
+ bounds.x = x;
35
+ bounds.y = 0.0;
36
+ bounds.width = width;
37
+ bounds.height = height;
38
+ return bounds;
39
+ }
40
+
41
+ } // namespace
42
+
43
+ TEST(device_metrics_report_the_window_and_the_screen_it_is_on) {
44
+ const std::vector<DisplayInfo> displays{display(0.0, 1440.0, 2.0, true)};
45
+ const auto metrics = deviceMetricsFrom(window(100.0, 900.0, 732.0), 900.0, 700.0, displays, 1.0);
46
+
47
+ EXPECT_EQ((long)metrics.window.width, 900L);
48
+ // The surface, not the frame: the window is 732 tall and 32 of that is a
49
+ // title bar the app cannot lay anything out in.
50
+ EXPECT_EQ((long)metrics.window.height, 700L);
51
+ // The screen is the display, not the window: the distinction React Native
52
+ // draws, and one that matters on a desktop where a window is a fraction of a
53
+ // screen.
54
+ EXPECT_EQ((long)metrics.screen.width, 1440L);
55
+ EXPECT_EQ((long)metrics.screen.height, 900L);
56
+ EXPECT_EQ((long)metrics.window.scale, 2L);
57
+ EXPECT_EQ((long)metrics.screen.scale, 2L);
58
+ }
59
+
60
+ // Which display a window is on is its centre's, so a window straddling two
61
+ // reports the one most of it is on.
62
+ TEST(device_metrics_follow_the_window_to_another_display) {
63
+ const std::vector<DisplayInfo> displays{display(0.0, 1440.0, 2.0, true),
64
+ display(1440.0, 1920.0, 1.0, false)};
65
+
66
+ const auto onFirst = deviceMetricsFrom(window(1000.0, 800.0, 600.0), 0.0, 0.0, displays, 1.0);
67
+ EXPECT_EQ((long)onFirst.window.scale, 2L);
68
+ EXPECT_EQ((long)onFirst.screen.width, 1440L);
69
+
70
+ const auto onSecond = deviceMetricsFrom(window(2000.0, 800.0, 600.0), 0.0, 0.0, displays, 1.0);
71
+ EXPECT_EQ((long)onSecond.window.scale, 1L);
72
+ EXPECT_EQ((long)onSecond.screen.width, 1920L);
73
+ }
74
+
75
+ // `PixelRatio.get()` is this number and `roundToNearestPixel` divides by it, so
76
+ // a zero is a NaN in an app's layout arithmetic -- which is what upstream's
77
+ // hardcoded constants produced.
78
+ TEST(device_metrics_never_report_a_zero_scale) {
79
+ const std::vector<DisplayInfo> displays{display(0.0, 1440.0, 0.0, true)};
80
+ const auto metrics = deviceMetricsFrom(window(0.0, 900.0, 700.0), 0.0, 0.0, displays, 0.0);
81
+
82
+ EXPECT_EQ((long)metrics.window.scale, 1L);
83
+ EXPECT_EQ((long)metrics.window.fontScale, 1L);
84
+ EXPECT_EQ((long)metrics.screen.scale, 1L);
85
+ EXPECT_EQ((long)metrics.screen.fontScale, 1L);
86
+ }
87
+
88
+ // The text scale rides along, because `Dimensions` is where React Native puts
89
+ // it and `PixelRatio.getFontScale()` is what reads it.
90
+ TEST(device_metrics_carry_the_text_scale) {
91
+ const std::vector<DisplayInfo> displays{display(0.0, 1440.0, 1.0, true)};
92
+ const auto metrics = deviceMetricsFrom(window(0.0, 900.0, 700.0), 0.0, 0.0, displays, 1.5);
93
+
94
+ EXPECT_EQ((long)(metrics.window.fontScale * 10), 15L);
95
+ EXPECT_EQ((long)(metrics.screen.fontScale * 10), 15L);
96
+ }
97
+
98
+ // Before the host has reported a window at all, which is where an app's first
99
+ // render is. Zero would be divided by.
100
+ TEST(device_metrics_borrow_the_screen_before_the_first_frame) {
101
+ const std::vector<DisplayInfo> displays{display(0.0, 1440.0, 2.0, true)};
102
+ const auto metrics = deviceMetricsFrom(WindowBounds{}, 0.0, 0.0, displays, 1.0);
103
+
104
+ EXPECT_EQ((long)metrics.window.width, 1440L);
105
+ EXPECT_EQ((long)metrics.window.height, 900L);
106
+ }
107
+
108
+ // And with no displays either, which is a host that has reported nothing: the
109
+ // numbers are zero rather than invented, but the two scales are still one.
110
+ TEST(device_metrics_with_nothing_reported_are_zero_but_divisible) {
111
+ const auto metrics = deviceMetricsFrom(WindowBounds{}, 0.0, 0.0, {}, 1.0);
112
+
113
+ EXPECT_EQ((long)metrics.window.width, 0L);
114
+ EXPECT_EQ((long)metrics.window.scale, 1L);
115
+ EXPECT_EQ((long)metrics.window.fontScale, 1L);
116
+ }
117
+
118
+ // A window whose surface has not started yet reports the window itself, which
119
+ // is the closest thing there is to an answer before the first layout.
120
+ TEST(device_metrics_fall_back_to_the_window_before_a_surface_starts) {
121
+ const std::vector<DisplayInfo> displays{display(0.0, 1440.0, 1.0, true)};
122
+ const auto metrics = deviceMetricsFrom(window(0.0, 820.0, 640.0), 0.0, 0.0, displays, 1.0);
123
+
124
+ EXPECT_EQ((long)metrics.window.width, 820L);
125
+ EXPECT_EQ((long)metrics.window.height, 640L);
126
+ }
@@ -0,0 +1,271 @@
1
+ // The arithmetic behind `filter`, against the Filter Effects spec's own numbers.
2
+ //
3
+ // Seven of the nine functions are affine maps of colour, so the test for each is
4
+ // a colour in and a colour out rather than a matrix compared against itself: the
5
+ // spec says what grayscale(1) does to red, and that is checkable without
6
+ // agreeing on how a matrix is stored. See core/Filters.h.
7
+
8
+ #include "TestHarness.h"
9
+
10
+ #include "Filters.h"
11
+
12
+ #include <cmath>
13
+ #include <sstream>
14
+
15
+ using basalt::ColorMatrix;
16
+ using basalt::ResolvedFilters;
17
+ using basalt::resolveFilters;
18
+ using facebook::react::FilterFunction;
19
+ using facebook::react::FilterType;
20
+
21
+ namespace {
22
+
23
+ FilterFunction filter(FilterType type, double amount) {
24
+ FilterFunction function;
25
+ function.type = type;
26
+ function.parameters = static_cast<facebook::react::Float>(amount);
27
+ return function;
28
+ }
29
+
30
+ // The colour a list of filters makes of one colour, straight (not
31
+ // premultiplied), which is the space both hosts apply the matrix in.
32
+ struct Colour {
33
+ float red;
34
+ float green;
35
+ float blue;
36
+ float alpha;
37
+ };
38
+
39
+ Colour through(const std::vector<FilterFunction> &filters, Colour in) {
40
+ const ResolvedFilters resolved = resolveFilters(filters);
41
+ const float input[4] = {in.red, in.green, in.blue, in.alpha};
42
+ float output[4] = {0, 0, 0, 0};
43
+ resolved.matrix.apply(input, output);
44
+ return Colour{output[0], output[1], output[2], output[3]};
45
+ }
46
+
47
+ bool near(float a, float b, float slack = 0.002F) { return std::fabs(a - b) <= slack; }
48
+
49
+ constexpr Colour kRed{1, 0, 0, 1};
50
+ constexpr Colour kWhite{1, 1, 1, 1};
51
+ constexpr Colour kBlack{0, 0, 0, 1};
52
+
53
+ } // namespace
54
+
55
+ // grayscale(1) replaces a colour with its luminance, and the weights are Rec.
56
+ // 709's: red is 0.2126 of white. A matrix built from the wrong weights -- the
57
+ // older 0.299/0.587/0.114 is the usual mistake -- gives 0.299 here.
58
+ TEST(filters_grayscale_uses_the_luminance_weights_the_spec_names) {
59
+ const Colour grey = through({filter(FilterType::Grayscale, 1.0)}, kRed);
60
+ EXPECT(near(grey.red, 0.2126F));
61
+ EXPECT(near(grey.green, 0.2126F));
62
+ EXPECT(near(grey.blue, 0.2126F));
63
+ // Alpha is untouched: a filter that greys a colour does not make it
64
+ // transparent.
65
+ EXPECT(near(grey.alpha, 1.0F));
66
+
67
+ // Half way is half way between the colour and its luminance.
68
+ const Colour half = through({filter(FilterType::Grayscale, 0.5)}, kRed);
69
+ EXPECT(near(half.red, (1.0F + 0.2126F) / 2.0F));
70
+ }
71
+
72
+ // saturate(0) is grayscale(1) from the other end, which is how the spec defines
73
+ // both, and a good check that one is not written twice with different numbers.
74
+ TEST(filters_saturate_at_zero_is_grayscale) {
75
+ const Colour saturated = through({filter(FilterType::Saturate, 0.0)}, kRed);
76
+ const Colour grey = through({filter(FilterType::Grayscale, 1.0)}, kRed);
77
+ EXPECT(near(saturated.red, grey.red));
78
+ EXPECT(near(saturated.green, grey.green));
79
+ EXPECT(near(saturated.blue, grey.blue));
80
+
81
+ // And over-saturating is allowed: the prop takes any non-negative number.
82
+ const Colour over = through({filter(FilterType::Saturate, 2.0)}, Colour{0.5F, 0.5F, 0.6F, 1});
83
+ EXPECT(over.blue > 0.6F);
84
+ }
85
+
86
+ TEST(filters_invert_turns_black_white_and_back) {
87
+ const Colour inverted = through({filter(FilterType::Invert, 1.0)}, kBlack);
88
+ EXPECT(near(inverted.red, 1.0F));
89
+ EXPECT(near(inverted.green, 1.0F));
90
+ EXPECT(near(inverted.blue, 1.0F));
91
+
92
+ // Half an inversion is mid grey, whatever it started as: that is the point of
93
+ // the interpolation rather than a plain one-minus.
94
+ const Colour half = through({filter(FilterType::Invert, 0.5)}, kBlack);
95
+ EXPECT(near(half.red, 0.5F));
96
+ const Colour halfWhite = through({filter(FilterType::Invert, 0.5)}, kWhite);
97
+ EXPECT(near(halfWhite.red, 0.5F));
98
+ }
99
+
100
+ TEST(filters_brightness_scales_and_contrast_pivots_on_grey) {
101
+ const Colour brighter = through({filter(FilterType::Brightness, 2.0)}, Colour{0.25F, 0.25F, 0.25F, 1});
102
+ EXPECT(near(brighter.red, 0.5F));
103
+
104
+ // contrast(0) collapses everything onto mid grey; contrast(1) changes nothing.
105
+ const Colour flat = through({filter(FilterType::Contrast, 0.0)}, kRed);
106
+ EXPECT(near(flat.red, 0.5F));
107
+ EXPECT(near(flat.blue, 0.5F));
108
+ const Colour same = through({filter(FilterType::Contrast, 1.0)}, kRed);
109
+ EXPECT(near(same.red, 1.0F));
110
+ EXPECT(near(same.blue, 0.0F));
111
+ // And doubling it pushes away from grey in both directions.
112
+ const Colour harder = through({filter(FilterType::Contrast, 2.0)}, Colour{0.75F, 0.25F, 0.5F, 1});
113
+ EXPECT(near(harder.red, 1.0F));
114
+ EXPECT(near(harder.green, 0.0F));
115
+ EXPECT(near(harder.blue, 0.5F));
116
+ }
117
+
118
+ TEST(filters_sepia_matches_the_matrix_the_spec_prints) {
119
+ const Colour sepia = through({filter(FilterType::Sepia, 1.0)}, kRed);
120
+ EXPECT(near(sepia.red, 0.393F));
121
+ EXPECT(near(sepia.green, 0.349F));
122
+ EXPECT(near(sepia.blue, 0.272F));
123
+ }
124
+
125
+ // A full turn is the identity, which is the cheapest check that the rotation is
126
+ // a rotation; and a quarter turn moves a hue without moving its luminance, which
127
+ // is the property the spec's matrix is built around.
128
+ TEST(filters_hue_rotate_comes_back_round) {
129
+ const Colour full = through({filter(FilterType::HueRotate, 360.0)}, kRed);
130
+ EXPECT(near(full.red, 1.0F, 0.01F));
131
+ EXPECT(near(full.green, 0.0F, 0.01F));
132
+ EXPECT(near(full.blue, 0.0F, 0.01F));
133
+
134
+ const Colour quarter = through({filter(FilterType::HueRotate, 120.0)}, kRed);
135
+ EXPECT(!near(quarter.red, 1.0F, 0.1F));
136
+ const float before = 0.2126F;
137
+ const float after = 0.2126F * quarter.red + 0.7152F * quarter.green + 0.0722F * quarter.blue;
138
+ EXPECT(near(after, before, 0.02F));
139
+ }
140
+
141
+ // Two filters are one matrix, applied in the order they were written. Brightness
142
+ // then grayscale is not the same as grayscale then brightness for a saturated
143
+ // colour... except that both are linear, which is why this asserts the composed
144
+ // answer rather than an order-dependence that does not exist.
145
+ TEST(filters_a_list_of_colour_filters_is_one_matrix) {
146
+ const ResolvedFilters resolved =
147
+ resolveFilters({filter(FilterType::Grayscale, 1.0), filter(FilterType::Brightness, 2.0)});
148
+ EXPECT(resolved.hasMatrix);
149
+ EXPECT(near(resolved.blurRadius, 0.0F));
150
+
151
+ const Colour both = through(
152
+ {filter(FilterType::Grayscale, 1.0), filter(FilterType::Brightness, 2.0)}, kRed);
153
+ EXPECT(near(both.red, 0.2126F * 2.0F));
154
+ EXPECT(near(both.green, 0.2126F * 2.0F));
155
+ }
156
+
157
+ // Opacity is alpha, not colour, and several of them multiply.
158
+ TEST(filters_opacity_multiplies) {
159
+ EXPECT(near(resolveFilters({filter(FilterType::Opacity, 0.5)}).opacity, 0.5F));
160
+ EXPECT(near(
161
+ resolveFilters({filter(FilterType::Opacity, 0.5), filter(FilterType::Opacity, 0.5)}).opacity,
162
+ 0.25F));
163
+ EXPECT(near(resolveFilters({filter(FilterType::Grayscale, 1.0)}).opacity, 1.0F));
164
+ }
165
+
166
+ // Two blurs are one blur of the combined variance, which is what convolving two
167
+ // gaussians gives: 3 and 4 make 5, not 7.
168
+ TEST(filters_two_blurs_compose_in_quadrature) {
169
+ EXPECT(near(resolveFilters({filter(FilterType::Blur, 8.0)}).blurRadius, 8.0F));
170
+ const float composed =
171
+ resolveFilters({filter(FilterType::Blur, 6.0), filter(FilterType::Blur, 8.0)}).blurRadius;
172
+ EXPECT(near(composed, 10.0F, 0.01F));
173
+ }
174
+
175
+ // A drop shadow is the one function that does not commute with the others, so it
176
+ // is kept as a shadow rather than folded in -- and the rest of the list still
177
+ // applies, which is what stops one function taking a whole style with it.
178
+ TEST(filters_a_drop_shadow_is_kept_and_the_rest_still_applies) {
179
+ FilterFunction shadow;
180
+ shadow.type = FilterType::DropShadow;
181
+ shadow.parameters = facebook::react::DropShadowParams{};
182
+
183
+ const ResolvedFilters resolved =
184
+ resolveFilters({shadow, filter(FilterType::Grayscale, 1.0)});
185
+ EXPECT(resolved.hasDropShadow);
186
+ EXPECT_EQ((int)resolved.dropShadows.size(), 1);
187
+ EXPECT(resolved.hasMatrix);
188
+ EXPECT(!resolved.empty());
189
+ }
190
+
191
+ // The numbers come through as React Native parsed them: an offset, a standard
192
+ // deviation -- not a CSS blur radius, which is what the field is named after --
193
+ // and a colour. Each host converts from the sigma to whatever its own shadow
194
+ // takes, so getting this wrong is a shadow twice as soft on one desktop.
195
+ TEST(filters_a_drop_shadow_carries_its_offset_sigma_and_colour) {
196
+ FilterFunction function;
197
+ function.type = FilterType::DropShadow;
198
+ facebook::react::DropShadowParams params;
199
+ params.offsetX = 4;
200
+ params.offsetY = -6;
201
+ params.standardDeviation = 3;
202
+ params.color = facebook::react::colorFromComponents(
203
+ facebook::react::ColorComponents{1.0F, 0.0F, 0.0F, 0.5F});
204
+ function.parameters = params;
205
+
206
+ const ResolvedFilters resolved = resolveFilters({function});
207
+ EXPECT_EQ((int)resolved.dropShadows.size(), 1);
208
+ if (resolved.dropShadows.empty()) {
209
+ return;
210
+ }
211
+ const basalt::FilterShadow &shadow = resolved.dropShadows[0];
212
+ EXPECT(near(shadow.dx, 4.0F, 0.001F));
213
+ EXPECT(near(shadow.dy, -6.0F, 0.001F));
214
+ EXPECT(near(shadow.standardDeviation, 3.0F, 0.001F));
215
+ EXPECT(near(shadow.red, 1.0F, 0.01F));
216
+ EXPECT(near(shadow.green, 0.0F, 0.01F));
217
+ EXPECT(near(shadow.alpha, 0.5F, 0.01F));
218
+ }
219
+
220
+ // No colour is legal CSS -- it means `currentColor` -- and nothing here has one,
221
+ // so it is opaque black. The alternative is dropping a declaration that parsed,
222
+ // which is worse than a shadow in the wrong colour.
223
+ TEST(filters_a_drop_shadow_with_no_colour_is_black) {
224
+ FilterFunction function;
225
+ function.type = FilterType::DropShadow;
226
+ function.parameters = facebook::react::DropShadowParams{};
227
+
228
+ const ResolvedFilters resolved = resolveFilters({function});
229
+ EXPECT_EQ((int)resolved.dropShadows.size(), 1);
230
+ if (resolved.dropShadows.empty()) {
231
+ return;
232
+ }
233
+ EXPECT(near(resolved.dropShadows[0].alpha, 1.0F, 0.01F));
234
+ EXPECT(near(resolved.dropShadows[0].red, 0.0F, 0.01F));
235
+ }
236
+
237
+ // Several of them, in the order they were written: CSS applies a filter list
238
+ // left to right, so the first drop shadow is the one nearest the content, and a
239
+ // host that reversed them would put the wrong one on top.
240
+ TEST(filters_drop_shadows_keep_their_order) {
241
+ FilterFunction first;
242
+ first.type = FilterType::DropShadow;
243
+ facebook::react::DropShadowParams nearParams;
244
+ nearParams.offsetX = 1;
245
+ first.parameters = nearParams;
246
+
247
+ FilterFunction second;
248
+ second.type = FilterType::DropShadow;
249
+ facebook::react::DropShadowParams farParams;
250
+ farParams.offsetX = 9;
251
+ second.parameters = farParams;
252
+
253
+ const ResolvedFilters resolved = resolveFilters({first, second});
254
+ EXPECT_EQ((int)resolved.dropShadows.size(), 2);
255
+ if (resolved.dropShadows.size() != 2) {
256
+ return;
257
+ }
258
+ EXPECT(near(resolved.dropShadows[0].dx, 1.0F, 0.001F));
259
+ EXPECT(near(resolved.dropShadows[1].dx, 9.0F, 0.001F));
260
+ }
261
+
262
+ TEST(filters_an_empty_list_is_nothing_to_do) {
263
+ const ResolvedFilters resolved = resolveFilters({});
264
+ EXPECT(resolved.empty());
265
+ EXPECT(resolved.matrix.isIdentity());
266
+ EXPECT(!resolved.hasDropShadow);
267
+
268
+ // And a filter that does nothing is still nothing to do, which keeps a host
269
+ // from pushing an identity matrix node for `brightness(1)`.
270
+ EXPECT(resolveFilters({filter(FilterType::Brightness, 1.0)}).matrix.isIdentity());
271
+ }
@@ -0,0 +1,172 @@
1
+ // `adjustsFontSizeToFit`: the search that finds how far to shrink a paragraph.
2
+ //
3
+ // The search is core's and the measuring is each host's, so this hands it a
4
+ // measurement that is arithmetic -- a paragraph whose size is proportional to
5
+ // its font size, which is what a single line of text actually is. That makes
6
+ // the ratio predictable and the assertions about the *rules* rather than about
7
+ // a font.
8
+
9
+ #include "TestHarness.h"
10
+
11
+ #include "FontFitting.h"
12
+
13
+ #include <limits>
14
+ #include <sstream>
15
+
16
+ using basalt::FontFit;
17
+ using basalt::FontFitBox;
18
+ using basalt::fontFitToBox;
19
+
20
+ namespace {
21
+
22
+ // A line of text `width` by `height` at its natural size, measured at a ratio.
23
+ //
24
+ // Linear in the ratio, which a single unwrapped line is: twice the font size is
25
+ // twice the width and twice the height.
26
+ auto line(double width, double height) {
27
+ return [width, height](double ratio) {
28
+ return FontFitBox{.width = width * ratio, .height = height * ratio};
29
+ };
30
+ }
31
+
32
+ } // namespace
33
+
34
+ TEST(fit_text_that_already_fits_is_not_touched) {
35
+ // Upstream's first move, and the reason the prop never grows anything: a
36
+ // paragraph that fits is done, whatever room is left over.
37
+ const FontFit fit = fontFitToBox(FontFit{}, line(50, 10), FontFitBox{.width = 200, .height = 100});
38
+ EXPECT(!fit.scales());
39
+ EXPECT_NEAR(fit.ratio, 1.0, 0.0001);
40
+ }
41
+
42
+ TEST(fit_text_twice_too_wide_is_halved) {
43
+ // 200 wide in a 100 box: the ratio that fits is a half, and the search should
44
+ // land within a hair of it rather than at some ratio that merely fits.
45
+ const FontFit fit = fontFitToBox(FontFit{}, line(200, 10), FontFitBox{.width = 100, .height = 50});
46
+ EXPECT(fit.scales());
47
+ EXPECT(fit.ratio > 0.45);
48
+ EXPECT(fit.ratio <= 0.5);
49
+ }
50
+
51
+ TEST(fit_the_height_can_be_what_binds) {
52
+ // A paragraph narrow enough and too tall: the search has to shrink for the
53
+ // dimension that does not fit, not only for the width.
54
+ const FontFit fit = fontFitToBox(FontFit{}, line(10, 200), FontFitBox{.width = 100, .height = 50});
55
+ EXPECT(fit.scales());
56
+ EXPECT(fit.ratio <= 0.25);
57
+ }
58
+
59
+ TEST(fit_a_box_with_no_size_is_not_fitted_to) {
60
+ // An infinite dimension is Yoga saying "as much as you need", and a
61
+ // paragraph measured unconstrained always fits.
62
+ const double infinity = std::numeric_limits<double>::infinity();
63
+ EXPECT(!fontFitToBox(FontFit{}, line(200, 10), FontFitBox{.width = infinity, .height = 50})
64
+ .scales());
65
+ EXPECT(!fontFitToBox(FontFit{}, line(200, 10), FontFitBox{.width = 100, .height = infinity})
66
+ .scales());
67
+ // And a box of nothing is not a box: a paragraph in a zero-width column would
68
+ // otherwise shrink to the floor.
69
+ EXPECT(!fontFitToBox(FontFit{}, line(200, 10), FontFitBox{.width = 0, .height = 0}).scales());
70
+ }
71
+
72
+ TEST(fit_the_minimum_size_is_a_floor_and_not_a_refusal) {
73
+ // The clamp belongs to the fitting rather than to the text: scaling by the
74
+ // ratio and then clamping is what keeps a paragraph of mixed sizes in
75
+ // proportion until the floor bites.
76
+ FontFit limits;
77
+ limits.minimum = 10.0;
78
+ const FontFit fit =
79
+ fontFitToBox(limits, line(400, 10), FontFitBox{.width = 100, .height = 50});
80
+ EXPECT(fit.scales());
81
+ // A 16pt fragment would want 4pt and gets the floor.
82
+ EXPECT_NEAR(fit.apply(16.0), 10.0, 0.0001);
83
+ // And a fragment already below the floor is raised to it, which is what a
84
+ // minimum means: the search is about the paragraph, not about one fragment.
85
+ EXPECT_NEAR(fit.apply(2.0), 10.0, 0.0001);
86
+ }
87
+
88
+ TEST(fit_the_maximum_size_bounds_the_scaled_result) {
89
+ FontFit limits;
90
+ limits.maximum = 20.0;
91
+ FontFit scaled = limits;
92
+ scaled.ratio = 0.5;
93
+ EXPECT_NEAR(scaled.apply(80.0), 20.0, 0.0001);
94
+ EXPECT_NEAR(scaled.apply(30.0), 15.0, 0.0001);
95
+ }
96
+
97
+ TEST(fit_an_unscaled_paragraph_is_not_clamped) {
98
+ // The bounds are the fitting's, not the text's: a 200pt heading in an app
99
+ // that named a maximum of 96 is still 200pt while it fits.
100
+ FontFit limits;
101
+ limits.maximum = 96.0;
102
+ EXPECT_NEAR(limits.apply(200.0), 200.0, 0.0001);
103
+ }
104
+
105
+ TEST(fit_a_contradictory_pair_of_bounds_lets_the_minimum_win) {
106
+ // An app that asked for a minimum above its maximum has said something
107
+ // impossible; readable text is the better answer than invisible text.
108
+ facebook::react::ParagraphAttributes attributes;
109
+ attributes.minimumFontSize = 30.0F;
110
+ attributes.maximumFontSize = 10.0F;
111
+ const FontFit fit = basalt::fontFitLimits(attributes);
112
+ EXPECT_NEAR(fit.minimum, 30.0, 0.0001);
113
+ EXPECT_NEAR(fit.maximum, 30.0, 0.0001);
114
+ }
115
+
116
+ TEST(fit_unset_bounds_are_the_ones_ios_uses) {
117
+ // NaN is React Native's "nothing here", and the defaults are
118
+ // RCTTextLayoutManager's own rather than numbers invented here.
119
+ const FontFit fit = basalt::fontFitLimits(facebook::react::ParagraphAttributes{});
120
+ EXPECT_NEAR(fit.minimum, basalt::kFontFitMinimumSize, 0.0001);
121
+ EXPECT_NEAR(fit.maximum, basalt::kFontFitMaximumSize, 0.0001);
122
+ }
123
+
124
+ TEST(fit_a_paragraph_that_cannot_fit_at_all_still_answers) {
125
+ // Text a hundred times too big, with the floor in the way: the search closes
126
+ // on the smallest ratio that fitted rather than running for ever, and the
127
+ // clamp is what keeps the result legible.
128
+ FontFit limits;
129
+ limits.minimum = 8.0;
130
+ const FontFit fit =
131
+ fontFitToBox(limits, line(10000, 10), FontFitBox{.width = 100, .height = 50});
132
+ EXPECT(fit.scales());
133
+ EXPECT(fit.ratio > 0.0);
134
+ EXPECT_NEAR(fit.apply(16.0), 8.0, 0.0001);
135
+ }
136
+
137
+ TEST(fit_a_minimum_scale_is_a_floor_on_the_ratio) {
138
+ // `minimumFontScale` is the same floor as `minimumFontSize` said as a ratio,
139
+ // which is Android's spelling of the prop. iOS ignores it; there is no reason
140
+ // to here, the search being a search over exactly this number.
141
+ FontFit limits;
142
+ limits.minimumRatio = 0.75;
143
+ const FontFit fit =
144
+ fontFitToBox(limits, line(400, 10), FontFitBox{.width = 100, .height = 50});
145
+ EXPECT(fit.scales());
146
+ EXPECT(fit.ratio >= 0.75);
147
+ // And the text does not fit at that ratio, which is the floor doing its job
148
+ // rather than the search failing: 400 * 0.75 is still past 100.
149
+ EXPECT_NEAR(fit.apply(16.0), 12.0, 0.0001);
150
+ }
151
+
152
+ TEST(fit_a_minimum_scale_of_one_refuses_to_shrink_at_all) {
153
+ // A legal thing to ask for, and worth not reading as "unset": the paragraph
154
+ // overflows rather than being made smaller.
155
+ FontFit limits;
156
+ limits.minimumRatio = 1.0;
157
+ const FontFit fit =
158
+ fontFitToBox(limits, line(400, 10), FontFitBox{.width = 100, .height = 50});
159
+ EXPECT_NEAR(fit.ratio, 1.0, 0.0001);
160
+ }
161
+
162
+ TEST(fit_an_unset_minimum_scale_is_not_a_floor_of_zero) {
163
+ facebook::react::ParagraphAttributes attributes;
164
+ EXPECT_NEAR(basalt::fontFitLimits(attributes).minimumRatio, 0.0, 0.0001);
165
+ // And a value outside React Native's own documented range is clamped into it
166
+ // rather than trusted: 0 would be a floor of nothing and 2 would be a floor
167
+ // above the ceiling.
168
+ attributes.minimumFontScale = 0.0F;
169
+ EXPECT_NEAR(basalt::fontFitLimits(attributes).minimumRatio, 0.01, 0.0001);
170
+ attributes.minimumFontScale = 2.0F;
171
+ EXPECT_NEAR(basalt::fontFitLimits(attributes).minimumRatio, 1.0, 0.0001);
172
+ }