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,336 @@
1
+ // CSS filter functions, resolved to one colour matrix, one blur and one opacity.
2
+ //
3
+ // `filter: 'grayscale(1) brightness(1.2)'` on a view reaches both hosts as a
4
+ // list of `FilterFunction`s and was dropped by both. Each toolkit has the
5
+ // primitives: GSK has a colour-matrix node, a blur node and an opacity node, and
6
+ // CALayer takes Core Image filters on macOS. What neither has is the arithmetic,
7
+ // which the Filter Effects spec gives exactly -- so it lives here, once, with the
8
+ // spec's own numbers.
9
+ //
10
+ // ## Why one matrix rather than a chain
11
+ //
12
+ // Seven of the nine functions are affine transformations of colour:
13
+ // `brightness`, `contrast`, `grayscale`, `hueRotate`, `invert`, `saturate` and
14
+ // `sepia`. Composing two of those is multiplying their matrices, so a list of
15
+ // them is one matrix and one node rather than a stack -- and since each is a
16
+ // per-pixel affine map, it does not matter whether the blur happens before or
17
+ // after: a blur is a weighted average whose weights sum to one, and an affine
18
+ // map commutes with that. The same goes for `opacity`, which scales alpha
19
+ // linearly.
20
+ //
21
+ // `dropShadow` is the exception and does not commute with anything: it depends on
22
+ // the alpha silhouette at its point in the chain. So it is kept as a list in the
23
+ // order it was written, and each host draws it with its own shadow mechanism.
24
+ //
25
+ // What that loses is the interleaving: `drop-shadow(...) grayscale(1)` greys the
26
+ // shadow too and `grayscale(1) drop-shadow(...)` does not, and with the matrix
27
+ // collapsed into one node there is one order rather than two. The shadows go on
28
+ // last, which is the second of those, and backlog/correctness.md records it.
29
+ //
30
+ // Two blurs in one list compose in quadrature -- the sigmas, not the radii --
31
+ // because convolving two gaussians gives a gaussian of the combined variance.
32
+ //
33
+ // Header-only, like core/Gradients.h, and included by the mounting managers,
34
+ // which already know React Native.
35
+
36
+ #pragma once
37
+
38
+ // For kPi, which is here rather than M_PI for the reason that header gives: MSVC
39
+ // defines the POSIX math constants only behind a flag this project does not set.
40
+ #include "Gradients.h"
41
+
42
+ #include <react/renderer/graphics/Filter.h>
43
+
44
+ #include <algorithm>
45
+ #include <cmath>
46
+ #include <vector>
47
+
48
+ namespace basalt {
49
+
50
+ // An affine map of colour: `out = matrix * in + offset`, both in R, G, B, A
51
+ // order, with the matrix stored row by row. That is GSK's own shape, and Core
52
+ // Image's CIColorMatrix wants the same numbers as four vectors and a bias.
53
+ struct ColorMatrix {
54
+ float m[16]{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
55
+ float offset[4]{0, 0, 0, 0};
56
+
57
+ // Row `row`, column `column`.
58
+ float at(int row, int column) const { return m[row * 4 + column]; }
59
+
60
+ bool isIdentity() const {
61
+ for (int row = 0; row < 4; row++) {
62
+ for (int column = 0; column < 4; column++) {
63
+ const float wanted = row == column ? 1.0F : 0.0F;
64
+ if (std::fabs(at(row, column) - wanted) > 1e-6F) {
65
+ return false;
66
+ }
67
+ }
68
+ if (std::fabs(offset[row]) > 1e-6F) {
69
+ return false;
70
+ }
71
+ }
72
+ return true;
73
+ }
74
+
75
+ // The colour this matrix makes of `in`, which is what the tests assert on and
76
+ // what a host can use to check itself against a rendered pixel.
77
+ void apply(const float in[4], float out[4]) const {
78
+ for (int row = 0; row < 4; row++) {
79
+ out[row] = offset[row];
80
+ for (int column = 0; column < 4; column++) {
81
+ out[row] += at(row, column) * in[column];
82
+ }
83
+ }
84
+ }
85
+ };
86
+
87
+ // `second` after `first`: out = M2 (M1 c + o1) + o2.
88
+ inline ColorMatrix composeColorMatrix(const ColorMatrix &first, const ColorMatrix &second) {
89
+ ColorMatrix result;
90
+ for (int row = 0; row < 4; row++) {
91
+ for (int column = 0; column < 4; column++) {
92
+ float sum = 0.0F;
93
+ for (int k = 0; k < 4; k++) {
94
+ sum += second.at(row, k) * first.at(k, column);
95
+ }
96
+ result.m[row * 4 + column] = sum;
97
+ }
98
+ float offset = second.offset[row];
99
+ for (int k = 0; k < 4; k++) {
100
+ offset += second.at(row, k) * first.offset[k];
101
+ }
102
+ result.offset[row] = offset;
103
+ }
104
+ return result;
105
+ }
106
+
107
+ namespace detail {
108
+
109
+ // The luminance weights every one of these matrices is built from, which are
110
+ // Rec. 709's and are what the spec names.
111
+ constexpr float kLumaRed = 0.2126F;
112
+ constexpr float kLumaGreen = 0.7152F;
113
+ constexpr float kLumaBlue = 0.0722F;
114
+
115
+ inline ColorMatrix scaleRgb(float factor) {
116
+ ColorMatrix matrix;
117
+ matrix.m[0] = factor;
118
+ matrix.m[5] = factor;
119
+ matrix.m[10] = factor;
120
+ return matrix;
121
+ }
122
+
123
+ // The spec's saturate matrix, which `grayscale` is the inverse end of: saturate
124
+ // at 0 and grayscale at 1 are the same transformation.
125
+ inline ColorMatrix saturate(float amount) {
126
+ // Clamped at zero only. Over-saturating past 1 is allowed and useful.
127
+ const float s = std::max(amount, 0.0F);
128
+ ColorMatrix matrix;
129
+ const float r = kLumaRed;
130
+ const float g = kLumaGreen;
131
+ const float b = kLumaBlue;
132
+ const float row0[4] = {r + (1 - r) * s, g - g * s, b - b * s, 0};
133
+ const float row1[4] = {r - r * s, g + (1 - g) * s, b - b * s, 0};
134
+ const float row2[4] = {r - r * s, g - g * s, b + (1 - b) * s, 0};
135
+ for (int column = 0; column < 4; column++) {
136
+ matrix.m[column] = row0[column];
137
+ matrix.m[4 + column] = row1[column];
138
+ matrix.m[8 + column] = row2[column];
139
+ }
140
+ return matrix;
141
+ }
142
+
143
+ inline ColorMatrix sepia(float amount) {
144
+ const float a = std::clamp(amount, 0.0F, 1.0F);
145
+ // The spec's sepia matrix, interpolated from the identity by `amount`.
146
+ const float full[9] = {0.393F, 0.769F, 0.189F, 0.349F, 0.686F, 0.168F, 0.272F, 0.534F, 0.131F};
147
+ ColorMatrix matrix;
148
+ for (int row = 0; row < 3; row++) {
149
+ for (int column = 0; column < 3; column++) {
150
+ const float identity = row == column ? 1.0F : 0.0F;
151
+ matrix.m[row * 4 + column] = identity + (full[row * 3 + column] - identity) * a;
152
+ }
153
+ }
154
+ return matrix;
155
+ }
156
+
157
+ inline ColorMatrix hueRotate(float degrees) {
158
+ const double radians = static_cast<double>(degrees) * kPi / 180.0;
159
+ const float cosine = static_cast<float>(std::cos(radians));
160
+ const float sine = static_cast<float>(std::sin(radians));
161
+ // The spec's hue-rotate matrix: the luminance-preserving rotation in the plane
162
+ // orthogonal to the grey axis.
163
+ const float rows[9] = {
164
+ 0.213F + cosine * 0.787F - sine * 0.213F,
165
+ 0.715F - cosine * 0.715F - sine * 0.715F,
166
+ 0.072F - cosine * 0.072F + sine * 0.928F,
167
+
168
+ 0.213F - cosine * 0.213F + sine * 0.143F,
169
+ 0.715F + cosine * 0.285F + sine * 0.140F,
170
+ 0.072F - cosine * 0.072F - sine * 0.283F,
171
+
172
+ 0.213F - cosine * 0.213F - sine * 0.787F,
173
+ 0.715F - cosine * 0.715F + sine * 0.715F,
174
+ 0.072F + cosine * 0.928F + sine * 0.072F,
175
+ };
176
+ ColorMatrix matrix;
177
+ for (int row = 0; row < 3; row++) {
178
+ for (int column = 0; column < 3; column++) {
179
+ matrix.m[row * 4 + column] = rows[row * 3 + column];
180
+ }
181
+ }
182
+ return matrix;
183
+ }
184
+
185
+ } // namespace detail
186
+
187
+ // One `dropShadow()`, resolved: the offset, the gaussian's standard deviation and
188
+ // the colour, in straight sRGB.
189
+ //
190
+ // A standard deviation rather than a CSS blur radius, because that is what React
191
+ // Native parses the third length into and hands over -- the field is named
192
+ // `standardDeviation` -- and what its own iOS half passes straight to SwiftUI's
193
+ // `.shadow(radius:)`. Each host converts to whatever its own shadow takes:
194
+ // `CALayer.shadowRadius` is a standard deviation and takes it unchanged, GSK's
195
+ // shadow radius is CSS's and takes twice it.
196
+ //
197
+ // The colour is opaque black when the stylesheet left it out, which CSS resolves
198
+ // to `currentColor` and nothing here has: black is what an unstyled view's text
199
+ // would be, and dropping the shadow instead would silently ignore a declaration
200
+ // that is perfectly legal.
201
+ struct FilterShadow {
202
+ float dx;
203
+ float dy;
204
+ float standardDeviation;
205
+ float red;
206
+ float green;
207
+ float blue;
208
+ float alpha;
209
+ };
210
+
211
+ // What a list of filters comes to, once the arithmetic is done.
212
+ struct ResolvedFilters {
213
+ // The seven colour functions, multiplied together. Identity when the list has
214
+ // none of them.
215
+ ColorMatrix matrix;
216
+ bool hasMatrix{false};
217
+ // The CSS blur radius of every `blur()` in the list, composed: sigmas add in
218
+ // quadrature, and the radius is twice the sigma on both hosts.
219
+ float blurRadius{0.0F};
220
+ // Every `opacity()` multiplied together; 1 when the list has none.
221
+ float opacity{1.0F};
222
+ // Every `dropShadow()` in the list, in the order it was written: the first is
223
+ // the one nearest the content.
224
+ std::vector<FilterShadow> dropShadows;
225
+ // Whether there was one at all, which is what the hosts asked before they
226
+ // could draw one.
227
+ bool hasDropShadow{false};
228
+
229
+ bool empty() const {
230
+ return !hasMatrix && blurRadius <= 0 && opacity >= 1.0F && dropShadows.empty();
231
+ }
232
+ };
233
+
234
+ inline ResolvedFilters resolveFilters(const std::vector<facebook::react::FilterFunction> &filters) {
235
+ using facebook::react::FilterType;
236
+
237
+ ResolvedFilters resolved;
238
+ double variance = 0.0;
239
+ for (const auto &filter : filters) {
240
+ if (filter.type == FilterType::DropShadow) {
241
+ resolved.hasDropShadow = true;
242
+ if (!std::holds_alternative<facebook::react::DropShadowParams>(filter.parameters)) {
243
+ continue;
244
+ }
245
+ const auto &params = std::get<facebook::react::DropShadowParams>(filter.parameters);
246
+ FilterShadow shadow{};
247
+ shadow.dx = static_cast<float>(params.offsetX);
248
+ shadow.dy = static_cast<float>(params.offsetY);
249
+ shadow.standardDeviation =
250
+ params.standardDeviation > 0 ? static_cast<float>(params.standardDeviation) : 0.0F;
251
+ if (params.color) {
252
+ const auto components = facebook::react::colorComponentsFromColor(params.color);
253
+ shadow.red = components.red;
254
+ shadow.green = components.green;
255
+ shadow.blue = components.blue;
256
+ shadow.alpha = components.alpha;
257
+ } else {
258
+ shadow.alpha = 1.0F;
259
+ }
260
+ resolved.dropShadows.push_back(shadow);
261
+ continue;
262
+ }
263
+ if (!std::holds_alternative<facebook::react::Float>(filter.parameters)) {
264
+ continue;
265
+ }
266
+ const auto amount = static_cast<float>(std::get<facebook::react::Float>(filter.parameters));
267
+
268
+ switch (filter.type) {
269
+ case FilterType::Blur: {
270
+ if (amount > 0) {
271
+ // Sigma is half the radius, as it is for an <Image>'s blurRadius and
272
+ // for a box shadow; two blurs compose by their variances.
273
+ const double sigma = static_cast<double>(amount) / 2.0;
274
+ variance += sigma * sigma;
275
+ }
276
+ break;
277
+ }
278
+ case FilterType::Opacity:
279
+ resolved.opacity *= std::clamp(amount, 0.0F, 1.0F);
280
+ break;
281
+ case FilterType::Brightness:
282
+ resolved.matrix = composeColorMatrix(resolved.matrix, detail::scaleRgb(std::max(amount, 0.0F)));
283
+ resolved.hasMatrix = true;
284
+ break;
285
+ case FilterType::Contrast: {
286
+ const float k = std::max(amount, 0.0F);
287
+ ColorMatrix contrast = detail::scaleRgb(k);
288
+ // The intercept that keeps grey in the middle: 0.5 - 0.5k.
289
+ const float intercept = 0.5F - 0.5F * k;
290
+ contrast.offset[0] = intercept;
291
+ contrast.offset[1] = intercept;
292
+ contrast.offset[2] = intercept;
293
+ resolved.matrix = composeColorMatrix(resolved.matrix, contrast);
294
+ resolved.hasMatrix = true;
295
+ break;
296
+ }
297
+ case FilterType::Grayscale:
298
+ // Grayscale is saturate from the other end: 1 is fully grey.
299
+ resolved.matrix = composeColorMatrix(
300
+ resolved.matrix, detail::saturate(1.0F - std::clamp(amount, 0.0F, 1.0F)));
301
+ resolved.hasMatrix = true;
302
+ break;
303
+ case FilterType::Saturate:
304
+ resolved.matrix = composeColorMatrix(resolved.matrix, detail::saturate(amount));
305
+ resolved.hasMatrix = true;
306
+ break;
307
+ case FilterType::HueRotate:
308
+ resolved.matrix = composeColorMatrix(resolved.matrix, detail::hueRotate(amount));
309
+ resolved.hasMatrix = true;
310
+ break;
311
+ case FilterType::Invert: {
312
+ const float a = std::clamp(amount, 0.0F, 1.0F);
313
+ // out = a(1 - c) + (1 - a)c = (1 - 2a)c + a.
314
+ ColorMatrix invert = detail::scaleRgb(1.0F - 2.0F * a);
315
+ invert.offset[0] = a;
316
+ invert.offset[1] = a;
317
+ invert.offset[2] = a;
318
+ resolved.matrix = composeColorMatrix(resolved.matrix, invert);
319
+ resolved.hasMatrix = true;
320
+ break;
321
+ }
322
+ case FilterType::Sepia:
323
+ resolved.matrix = composeColorMatrix(resolved.matrix, detail::sepia(amount));
324
+ resolved.hasMatrix = true;
325
+ break;
326
+ case FilterType::DropShadow:
327
+ break;
328
+ }
329
+ }
330
+ if (variance > 0) {
331
+ resolved.blurRadius = static_cast<float>(std::sqrt(variance) * 2.0);
332
+ }
333
+ return resolved;
334
+ }
335
+
336
+ } // namespace basalt
@@ -0,0 +1,183 @@
1
+ // `adjustsFontSizeToFit`, and the two props that bound it.
2
+ //
3
+ // A label that says "Delete all messages" in a button sized for "Delete" has
4
+ // two ways to go wrong: it can overflow, or it can be cut off. React Native's
5
+ // third way is to shrink the text until it fits, which is what this prop asks
6
+ // for, and the two beside it -- `minimumFontSize` and `maximumFontSize` -- say
7
+ // how far that is allowed to go.
8
+ //
9
+ // ## Why it is here rather than in three hosts
10
+ //
11
+ // Finding the ratio is a search, and the search is the same on every platform:
12
+ // measure, compare, halve the interval. What differs is the measuring, which is
13
+ // the one thing each toolkit knows and this does not -- so the search takes the
14
+ // measurement as a callable and nothing in this file knows what a glyph is.
15
+ //
16
+ // The alternative was three bisections, which is how the three scroll ranges
17
+ // and the three snapping rules started before `core/ScrollBounds.h` and
18
+ // `core/ScrollSnap.h`.
19
+ //
20
+ // ## The algorithm is upstream's, deliberately
21
+ //
22
+ // `NSTextStorage+FontScaling.m` is a bisection between a ratio of 1/128 and
23
+ // one of 128, with three rules worth naming because each one is a decision:
24
+ //
25
+ // - **Text that already fits is not touched.** Upstream returns before the
26
+ // search, which is why the prop never *grows* text although the interval
27
+ // reaches 128: a paragraph that fits is done.
28
+ // - **A fit within one per cent of the box's width is good enough**, and the
29
+ // search stops there rather than finding the largest ratio that fits.
30
+ // Without it the search runs its full depth on every paragraph.
31
+ // - **The last ratio that fitted wins** when the interval closes, which is
32
+ // not the same as the last ratio tried: the final probe is as likely to be
33
+ // one that did not fit.
34
+ //
35
+ // Each font size is multiplied by the ratio and then clamped to the two props,
36
+ // so a paragraph of mixed sizes keeps its proportions until the clamp bites.
37
+
38
+ #pragma once
39
+
40
+ #include <react/renderer/attributedstring/ParagraphAttributes.h>
41
+
42
+ #include <algorithm>
43
+ #include <cmath>
44
+
45
+ namespace basalt {
46
+
47
+ // A size, in the logical points everything here measures in.
48
+ struct FontFitBox {
49
+ double width{0.0};
50
+ double height{0.0};
51
+ };
52
+
53
+ // How to scale a paragraph's fonts, and how far.
54
+ //
55
+ // A ratio of exactly 1 means "leave every size alone", which is both the
56
+ // default and what a paragraph that already fits resolves to -- and it skips
57
+ // the clamp as well, because `minimumFontSize` and `maximumFontSize` bound the
58
+ // *fitting* rather than the text. A 200pt heading in an app that set a maximum
59
+ // of 96 is still 200pt when it fits.
60
+ struct FontFit {
61
+ double ratio{1.0};
62
+ double minimum{4.0};
63
+ double maximum{96.0};
64
+ // `minimumFontScale`: the same floor as `minimumFontSize`, said as a ratio
65
+ // instead. Android's spelling of the prop, which iOS ignores -- and there is
66
+ // no reason to ignore it here, the search being a search over exactly this
67
+ // ratio. Zero means the app said nothing.
68
+ double minimumRatio{0.0};
69
+
70
+ bool scales() const { return ratio != 1.0; }
71
+
72
+ double apply(double size) const {
73
+ if (ratio == 1.0) {
74
+ return size;
75
+ }
76
+ return std::clamp(size * ratio, minimum, maximum);
77
+ }
78
+ };
79
+
80
+ // iOS's own defaults for the two bounds, which React Native documents nowhere
81
+ // else: 4 and 96 points, from `RCTTextLayoutManager`.
82
+ inline constexpr double kFontFitMinimumSize = 4.0;
83
+ inline constexpr double kFontFitMaximumSize = 96.0;
84
+
85
+ // The bounds a paragraph asked for, with those defaults for the props it left
86
+ // unset -- which arrive as NaN, React Native's "nothing here".
87
+ inline FontFit fontFitLimits(const facebook::react::ParagraphAttributes &paragraphAttributes) {
88
+ FontFit fit;
89
+ if (!std::isnan(paragraphAttributes.minimumFontSize)) {
90
+ fit.minimum = static_cast<double>(paragraphAttributes.minimumFontSize);
91
+ }
92
+ if (!std::isnan(paragraphAttributes.maximumFontSize)) {
93
+ fit.maximum = static_cast<double>(paragraphAttributes.maximumFontSize);
94
+ }
95
+ // A minimum above the maximum is an app contradicting itself; the minimum
96
+ // wins, so the text is readable rather than invisible.
97
+ fit.maximum = std::max(fit.maximum, fit.minimum);
98
+ // `minimumFontScale`, clamped to the range React Native's own comment gives
99
+ // for it: 0.01 to 1. A scale of 1 is a paragraph that may not shrink at all,
100
+ // which is a legal thing to ask for and worth not treating as "unset".
101
+ if (!std::isnan(paragraphAttributes.minimumFontScale)) {
102
+ fit.minimumRatio = std::clamp(static_cast<double>(paragraphAttributes.minimumFontScale),
103
+ 0.01,
104
+ 1.0);
105
+ }
106
+ return fit;
107
+ }
108
+
109
+ // Whether a box is one a paragraph can be fitted to at all.
110
+ //
111
+ // An infinite dimension is Yoga saying "as much as you need", and there is
112
+ // nothing to shrink to fit: a paragraph measured unconstrained always fits.
113
+ inline bool fontFitBoxIsFinite(const FontFitBox &box) {
114
+ return std::isfinite(box.width) && std::isfinite(box.height) && box.width > 0.0
115
+ && box.height > 0.0;
116
+ }
117
+
118
+ // The ratio that makes `measure` fit inside `box`.
119
+ //
120
+ // `measure` is called with a ratio and answers the size the paragraph takes
121
+ // with every font size scaled by it -- which is the host's job, and the only
122
+ // part of this that knows what text is.
123
+ template <typename Measure>
124
+ FontFit fontFitToBox(FontFit limits, const Measure &measure, const FontFitBox &box) {
125
+ if (!fontFitBoxIsFinite(box)) {
126
+ return limits;
127
+ }
128
+
129
+ const auto fits = [&box](const FontFitBox &size) {
130
+ return size.width <= box.width && size.height <= box.height;
131
+ };
132
+
133
+ // Text that already fits is not touched, which is upstream's first move and
134
+ // the reason this never grows anything.
135
+ const FontFitBox unscaled = measure(1.0);
136
+ if (fits(unscaled)) {
137
+ return limits;
138
+ }
139
+
140
+ // `minimumFontScale` is the floor when an app named one, and upstream's
141
+ // 1/128 otherwise. Searching below the floor would be searching for an
142
+ // answer the app has refused.
143
+ double bottom = std::max(1.0 / 128.0, limits.minimumRatio);
144
+ double top = 1.0;
145
+ // Upstream's floor for "nothing fitted", so a paragraph that cannot fit at
146
+ // all comes out tiny rather than unscaled -- and no smaller than the ratio
147
+ // the app allowed.
148
+ double lastThatFits = std::max(0.02, limits.minimumRatio);
149
+ double ratio = (top + bottom) / 2.0;
150
+
151
+ // A fit within this much of the box's width is good enough. Without it the
152
+ // search runs its full depth on every paragraph that needs shrinking.
153
+ constexpr double kCloseEnough = 0.01;
154
+ // And this is when the interval has closed, which bounds the loop at about
155
+ // eight probes.
156
+ constexpr double kSettled = 0.005;
157
+
158
+ while (true) {
159
+ FontFit candidate = limits;
160
+ candidate.ratio = ratio;
161
+ const FontFitBox size = measure(ratio);
162
+ if (fits(size)) {
163
+ if (box.width - size.width < box.width * kCloseEnough) {
164
+ return candidate;
165
+ }
166
+ bottom = ratio;
167
+ lastThatFits = ratio;
168
+ } else {
169
+ top = ratio;
170
+ }
171
+
172
+ const double next = (top + bottom) / 2.0;
173
+ if (std::abs(top - bottom) < kSettled || std::abs(top - next) < kSettled
174
+ || std::abs(bottom - next) < kSettled) {
175
+ FontFit settled = limits;
176
+ settled.ratio = lastThatFits;
177
+ return settled;
178
+ }
179
+ ratio = next;
180
+ }
181
+ }
182
+
183
+ } // namespace basalt
@@ -0,0 +1,153 @@
1
+ // `allowFontScaling`, `maxFontSizeMultiplier` and the system's text scale.
2
+ //
3
+ // Three props and one platform setting that together decide what size a
4
+ // fragment is actually drawn at. Both hosts had the same four lines of it,
5
+ // multiplying by `fontSizeMultiplier` when it was set, and neither read the two
6
+ // props that are there to control that multiplication: an app that says
7
+ // `allowFontScaling={false}` means "do not grow this text when the system is
8
+ // set to large text", which is the usual thing to say about a label whose box
9
+ // cannot grow.
10
+ //
11
+ // ## Where the multiplier comes from, which differs from iOS
12
+ //
13
+ // `fontSizeMultiplier` is a prop, and React Native's JavaScript never sets it:
14
+ // it stays NaN. iOS gets its scaling from `dynamicTypeRamp` and `UIFontMetrics`
15
+ // instead, and Android gets it from the device configuration's `fontScale`,
16
+ // which is a scalar the platform owns. A desktop is the Android shape of the
17
+ // problem: GTK publishes a text scale through `GtkSettings:gtk-xft-dpi`, and
18
+ // Windows through `UISettings.TextScaleFactor`, so the host passes what its
19
+ // platform says and nothing has to invent one. macOS publishes no scalar at
20
+ // all, which is why its scale is 1 and why that is recorded rather than faked.
21
+ //
22
+ // So the two multiply, which is Android's arrangement: the system's scale times
23
+ // whatever the prop says. Not "the prop when it is set, the scale otherwise",
24
+ // which was the first version of this and was wrong in a way worth recording:
25
+ // `TextAttributes::defaultTextAttributes()` sets `fontSizeMultiplier` to **1**
26
+ // rather than to NaN, so every fragment arrives with one set and a prop-first
27
+ // rule cancels the platform's scale on every paragraph in the app. The probe
28
+ // that found it printed `size=16 scale=1.6`.
29
+ //
30
+ // The clamping is upstream's, copied from
31
+ // `RCTEffectiveFontSizeMultiplierFromTextAttributes`: `maxFontSizeMultiplier`
32
+ // applies only when it is at least 1, because 0 and NaN are both how React
33
+ // Native spells "no limit", and a limit below 1 would shrink text that nothing
34
+ // asked to shrink. It applies to the product, so it caps total growth, which is
35
+ // what an app asking for "no more than a quarter larger" means.
36
+ //
37
+ // `dynamicTypeRamp` is not here. It names a `UIFontMetrics` text style, which
38
+ // is an iOS API with no desktop equivalent; backlog/text.md records it.
39
+
40
+ #pragma once
41
+
42
+ #include <react/renderer/attributedstring/TextAttributes.h>
43
+
44
+ #include <algorithm>
45
+ #include <atomic>
46
+ #include <cmath>
47
+ #include <cstdlib>
48
+
49
+ namespace basalt {
50
+
51
+ namespace detail {
52
+
53
+ inline std::atomic<float> &systemFontScaleStorage() {
54
+ static std::atomic<float> scale{1.0F};
55
+ return scale;
56
+ }
57
+
58
+ } // namespace detail
59
+
60
+ // What a test asked for, or nothing.
61
+ //
62
+ // `BASALT_TEST_FONT_SCALE` is here because two of the three platforms publish
63
+ // no text scale at all, so a scenario on them could otherwise never see the
64
+ // difference `allowFontScaling` makes, and a feature with no test that fails
65
+ // when it is removed is not finished.
66
+ //
67
+ // Not in core/TestSettle.h's list of scripted input, deliberately: that list is
68
+ // what a host schedules on its timer, and this schedules nothing. It is a
69
+ // setting read once, the way `BASALT_TEST_DIALOG` answers rather than acts.
70
+ inline float testFontScale() {
71
+ static const float requested = []() -> float {
72
+ const char *const value = std::getenv("BASALT_TEST_FONT_SCALE");
73
+ if (value == nullptr || *value == '\0') {
74
+ return 0.0F;
75
+ }
76
+ char *end = nullptr;
77
+ const double parsed = std::strtod(value, &end);
78
+ if (end == value || !(parsed > 0.0)) {
79
+ return 0.0F;
80
+ }
81
+ return static_cast<float>(parsed);
82
+ }();
83
+ return requested;
84
+ }
85
+
86
+ // The scale the platform reports, which a host reads once at startup.
87
+ //
88
+ // Read once, and on the main thread, because that is where a platform's
89
+ // settings object may be touched: GTK measures text off the main thread, so
90
+ // asking `GtkSettings` from inside the text builder would be asking it from the
91
+ // wrong thread. The cost is that a scale changed while the app is running does
92
+ // not reflow text that is already laid out; backlog/text.md records the signal
93
+ // that would.
94
+ inline void setSystemFontScale(float scale) {
95
+ detail::systemFontScaleStorage().store(scale > 0.0F ? scale : 1.0F,
96
+ std::memory_order_relaxed);
97
+ }
98
+
99
+ // What the text builders multiply by. A test's scale wins over the platform's,
100
+ // which is the only way a platform that has none can be tested at all.
101
+ inline float systemFontScale() {
102
+ const float requested = testFontScale();
103
+ return requested > 0.0F
104
+ ? requested
105
+ : detail::systemFontScaleStorage().load(std::memory_order_relaxed);
106
+ }
107
+
108
+ // What `fontSize` is multiplied by, given what the platform reports.
109
+ //
110
+ // `systemScale` is the platform's text scale: 1 where there is none, and
111
+ // whatever the setting says where there is. A scale that is not a positive
112
+ // number is 1, because a zero or negative one would make text disappear and a
113
+ // setting is not worth trusting that far.
114
+ inline float effectiveFontSizeMultiplier(
115
+ const facebook::react::TextAttributes &textAttributes,
116
+ float systemScale) {
117
+ if (!textAttributes.allowFontScaling.value_or(true)) {
118
+ return 1.0F;
119
+ }
120
+
121
+ if (!(systemScale > 0.0F)) {
122
+ systemScale = 1.0F;
123
+ }
124
+ // A multiplier that is not a positive number is 1 rather than nothing: NaN is
125
+ // "unset" and zero would multiply the text away.
126
+ const float requested = (!std::isnan(textAttributes.fontSizeMultiplier) &&
127
+ textAttributes.fontSizeMultiplier > 0.0)
128
+ ? static_cast<float>(textAttributes.fontSizeMultiplier)
129
+ : 1.0F;
130
+ const float multiplier = requested * systemScale;
131
+
132
+ const float limit = std::isnan(textAttributes.maxFontSizeMultiplier)
133
+ ? 0.0F
134
+ : static_cast<float>(textAttributes.maxFontSizeMultiplier);
135
+ return limit >= 1.0F ? std::min(limit, multiplier) : multiplier;
136
+ }
137
+
138
+ // The size a fragment is drawn at, in density-independent pixels.
139
+ //
140
+ // `defaultFontSize` is the host's own default, which differs between them
141
+ // because the platforms' system fonts do; `fontSize` being unset is NaN rather
142
+ // than zero.
143
+ inline float effectiveFontSize(
144
+ const facebook::react::TextAttributes &textAttributes,
145
+ float defaultFontSize,
146
+ float systemScale) {
147
+ const float size = std::isnan(textAttributes.fontSize)
148
+ ? defaultFontSize
149
+ : static_cast<float>(textAttributes.fontSize);
150
+ return size * effectiveFontSizeMultiplier(textAttributes, systemScale);
151
+ }
152
+
153
+ } // namespace basalt