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,127 @@
1
+ // `Settings`, which on iOS is `NSUserDefaults` and on a desktop is a file.
2
+ //
3
+ // React Native's `Settings` is four calls -- `get`, `set`, `watchKeys`,
4
+ // `clearWatch` -- over one native module, `SettingsManager`, which is a
5
+ // `getEnforcing` lookup. Nobody answered it here, so an app that reached for
6
+ // the API got React Native's `SettingsFallback`: three `console.warn`s and a
7
+ // null. The module was never even reached, for the same reason `Share` was not
8
+ // -- `Settings.js` branches on `Platform.OS === 'ios'` and everything else
9
+ // takes the fallback -- so this needs the JavaScript override as well; see
10
+ // src/overrides/Settings.ts.
11
+ //
12
+ // ## Why a file rather than each desktop's own settings store
13
+ //
14
+ // Each of the three has one, and none of them is the same shape as this API.
15
+ // macOS has `NSUserDefaults`, which is the thing `Settings` wraps and which a
16
+ // host could use directly. GTK's nearest equivalent is `GSettings`, which
17
+ // refuses a key that is not in a compiled schema -- and this API's whole
18
+ // contract is that an app invents its own keys at runtime, so a schema cannot
19
+ // exist. Windows has the registry, which has no JSON and no arrays.
20
+ //
21
+ // So: a JSON object in a file, identically on all three. One implementation
22
+ // with one set of semantics beats one real answer and two approximations that
23
+ // differ in what they can store. The cost is that macOS settings are not
24
+ // visible to `defaults read`, which is recorded in docs/backlog/modules.md
25
+ // along with the call that would change it.
26
+ //
27
+ // ## What is in the file
28
+ //
29
+ // A single JSON object, written whole on every change. Not a log and not a
30
+ // database: `Settings` is a handful of keys an app remembers between runs --
31
+ // a window's last tab, whether the tour was seen -- and an app with more than
32
+ // that wants AsyncStorage or SQLite, neither of which this pretends to be.
33
+ //
34
+ // Written to a sibling and renamed over, so a process that dies mid-write
35
+ // leaves the previous file rather than half of a new one. That matters more
36
+ // here than it looks: this file is read at startup, and a truncated one would
37
+ // make every later run start empty.
38
+ //
39
+ // ## What is not here
40
+ //
41
+ // `watchKeys` only ever fires for a change this process did not make. That is
42
+ // not a shortcut: iOS sets `_ignoringUpdates` around its own `setValues` for
43
+ // exactly the same reason, because `Settings.set` has already updated
44
+ // JavaScript's copy by the time the native module is called. Firing for an
45
+ // external change means watching the file, which is three different APIs, and
46
+ // is recorded rather than written; see docs/backlog/modules.md.
47
+
48
+ #pragma once
49
+
50
+ #include <folly/dynamic.h>
51
+
52
+ #include <filesystem>
53
+ #include <string>
54
+ #include <vector>
55
+
56
+ namespace basalt {
57
+
58
+ // Where the settings live, or an empty path when there is nowhere to put them.
59
+ //
60
+ // `BASALT_SETTINGS_FILE` wins when it is set, which is how the end-to-end
61
+ // scenario points two runs of a host at the same temporary file instead of at
62
+ // the person's real one, and how a portable install keeps its settings beside
63
+ // itself.
64
+ //
65
+ // Otherwise it is the per-user configuration directory each desktop names --
66
+ // `$XDG_CONFIG_HOME` or `~/.config` on Linux, `~/Library/Application Support`
67
+ // on macOS, `%APPDATA%` on Windows -- plus the application's identifier, plus
68
+ // `settings.json`. The identifier is what `app.identity.json` says, so two
69
+ // applications on one machine do not share a file; an app built by hand has no
70
+ // identity and lands under `basalt`, which is the honest answer for a binary
71
+ // that has not said who it is.
72
+ //
73
+ // Empty when even the home directory is unknown, which is a container with no
74
+ // environment rather than a mistake. A store with no path keeps its values in
75
+ // memory for the life of the process and persists nothing.
76
+ std::filesystem::path settingsFilePath();
77
+
78
+ // One spelling, in one place, for the same reason kTestQuitFileVar is.
79
+ inline constexpr const char *kSettingsFileVar = "BASALT_SETTINGS_FILE";
80
+
81
+ // The key-value store behind `SettingsManager`.
82
+ //
83
+ // Loaded in the constructor and saved on every change. Not thread-safe and not
84
+ // required to be: every caller is a TurboModule method, which runs on the
85
+ // JavaScript thread.
86
+ class SettingsStore {
87
+ public:
88
+ // Reads `file` if it is there. A missing file is the normal first run; a file
89
+ // that is not a JSON object is logged and treated as empty, because an app
90
+ // that refuses to start over a settings file somebody hand-edited would be
91
+ // worse than one that starts with no settings.
92
+ explicit SettingsStore(std::filesystem::path file);
93
+
94
+ // Every setting, as the object `getConstants().settings` hands to JavaScript.
95
+ const folly::dynamic &values() const {
96
+ return values_;
97
+ }
98
+
99
+ // Merges `values` in, one key deep, and answers the keys whose value actually
100
+ // changed. A key whose new value equals the old one is not in that list,
101
+ // which is what decides whether anything needs writing.
102
+ //
103
+ // A null value deletes the key rather than storing a null. That is iOS's
104
+ // behaviour -- `RCTSettingsManager` calls `removeObjectForKey:` when
105
+ // `RCTConvert NSPropertyList:` answers nil -- and an app that sets a key to
106
+ // null means to forget it.
107
+ std::vector<std::string> merge(const folly::dynamic &values);
108
+
109
+ // Removes `keys`, answering the ones that were actually there.
110
+ std::vector<std::string> erase(const std::vector<std::string> &keys);
111
+
112
+ // Where it is kept, for the host that wants to say so in a log.
113
+ const std::filesystem::path &file() const {
114
+ return file_;
115
+ }
116
+
117
+ private:
118
+ // Writes the whole object, through a sibling and a rename. Failure is logged
119
+ // and not reported: there is nothing an app could do about a read-only home
120
+ // directory, and losing a setting is not worth refusing to run over.
121
+ void save() const;
122
+
123
+ std::filesystem::path file_;
124
+ folly::dynamic values_ = folly::dynamic::object();
125
+ };
126
+
127
+ } // namespace basalt
@@ -0,0 +1,38 @@
1
+ // The shape a box shadow is cast by: a corner radius grown by the spread.
2
+ //
3
+ // Not an addition, which is the whole reason this is written down. The CSS spec
4
+ // gives a curve: a corner tighter than the spread is rounded off more gently
5
+ // than the spread alone would make it, so a small radius on a widely spread
6
+ // shadow does not turn into a circle.
7
+ //
8
+ // https://drafts.csswg.org/css-backgrounds/#shadow-shape
9
+ //
10
+ // Shared because two hosts compute it and the third does not: GSK's shadow
11
+ // nodes take the view's own outline and grow it themselves, where AppKit builds
12
+ // a `CGPath` and Direct2D a geometry, and both of those have to do this. It is
13
+ // a port of React Native's iOS half, which took it from the same place; a
14
+ // second hand-rolled copy of a cubic is exactly the kind of thing that ends up
15
+ // subtly different on one platform.
16
+ //
17
+ // A negative spread is allowed and shrinks the box, which is what an inset
18
+ // shadow's hole is built from, so `std::abs` is in the ratio rather than a
19
+ // guard against it.
20
+
21
+ #pragma once
22
+
23
+ #include <cmath>
24
+
25
+ namespace basalt {
26
+
27
+ // `radius` grown by `spread`, never below zero.
28
+ inline double spreadRadius(double radius, double spread) {
29
+ double adjustment = spread;
30
+ if (radius < std::abs(spread)) {
31
+ const double ratio = radius / std::abs(spread);
32
+ adjustment *= 1.0 + std::pow(ratio - 1.0, 3.0);
33
+ }
34
+ const double grown = radius + adjustment;
35
+ return grown > 0.0 ? grown : 0.0;
36
+ }
37
+
38
+ } // namespace basalt
@@ -0,0 +1,11 @@
1
+ #include "SkiaViewComponent.h"
2
+
3
+ namespace facebook::react {
4
+
5
+ // The name the package's JavaScript registers, from
6
+ // `codegenNativeComponent("SkiaView")` in
7
+ // lib/module/specs/SkiaViewNativeComponent.js. 3.x's name; 2.x's is
8
+ // "SkiaPictureView", beside this.
9
+ extern const char SkiaViewComponentName[] = "SkiaView";
10
+
11
+ } // namespace facebook::react
@@ -0,0 +1,50 @@
1
+ // `SkiaView`, which is what a `<Canvas>` from react-native-skia 3.x mounts.
2
+ //
3
+ // The 2.x one is `SkiaPictureView`, in core/SkiaPictureViewComponent.h beside
4
+ // this, and the rename is the whole difference as far as a component registry
5
+ // is concerned: `lib/module/specs/SkiaViewNativeComponent.js` is
6
+ // `codegenNativeComponent("SkiaView")` where it used to say the longer name.
7
+ // Both are registered, because which one an app mounts is decided by the Skia
8
+ // it installed and a host binary does not know which that is until the app's
9
+ // JavaScript runs.
10
+ //
11
+ // Plain `ViewProps`, for the reason the 2.x header spells out at length: a
12
+ // `<Canvas>` does not carry its content in props. It records in JavaScript and
13
+ // pushes the result through the Skia JSI API keyed by an id, and the only thing
14
+ // the native side needs from props is which id it is -- `nativeID`, an ordinary
15
+ // `ViewProps` field that arrives whatever a view config says.
16
+ //
17
+ // What is dropped, which is a shorter list than 2.x's: `opaque`,
18
+ // `highBitDepth`, `androidSurfaceType`, `androidZOrderOnTop` and
19
+ // `pointerEvents`. The two Android ones select a backing view there and mean
20
+ // nothing to a CAMetalLayer. `opaque` and `highBitDepth` are the backing
21
+ // store's format -- an opaque layer and ten bits a channel -- and a canvas
22
+ // without them draws the same picture into an eight-bit one. `pointerEvents`
23
+ // is already a `ViewProps` field and is handled where every other view's is.
24
+ // When one is wanted it belongs in a props class here and in the peer, which
25
+ // is the same call core/ExpoImageComponent.h makes about blurhash.
26
+ //
27
+ // Registered unconditionally, like the 2.x one and like ExpoImage: it costs a
28
+ // name in a registry, and the alternative is a host that has to be rebuilt to
29
+ // run an app that draws. Without Skia in the build the view mounts and stays
30
+ // empty -- and an app that draws has already failed at
31
+ // `getEnforcing('RNSkiaModule')` long before, which is the error that says
32
+ // what is actually missing.
33
+
34
+ #pragma once
35
+
36
+ #include <react/renderer/components/view/ConcreteViewShadowNode.h>
37
+ #include <react/renderer/components/view/ViewEventEmitter.h>
38
+ #include <react/renderer/components/view/ViewProps.h>
39
+ #include <react/renderer/core/ConcreteComponentDescriptor.h>
40
+
41
+ namespace facebook::react {
42
+
43
+ extern const char SkiaViewComponentName[];
44
+
45
+ using SkiaViewShadowNode =
46
+ ConcreteViewShadowNode<SkiaViewComponentName, ViewProps, ViewEventEmitter>;
47
+
48
+ using SkiaViewComponentDescriptor = ConcreteComponentDescriptor<SkiaViewShadowNode>;
49
+
50
+ } // namespace facebook::react
@@ -0,0 +1,103 @@
1
+ #include "SystemLocale.h"
2
+
3
+ #include <algorithm>
4
+ #include <cctype>
5
+ #include <cstdlib>
6
+ #include <mutex>
7
+
8
+ namespace basalt {
9
+
10
+ namespace {
11
+
12
+ struct State {
13
+ std::mutex mutex;
14
+ SystemLocale locale;
15
+ bool allowRTL{true};
16
+ std::optional<bool> forced;
17
+ };
18
+
19
+ State &state() {
20
+ static State value;
21
+ return value;
22
+ }
23
+
24
+ // The scripts that read right to left, by language code. Short because it is
25
+ // short: Arabic, Hebrew, Persian, Urdu and the two Kurdish and Pashto codes
26
+ // cover what a test needs, and nothing here decides a real locale -- the
27
+ // platforms answer that themselves. See the header.
28
+ bool languageReadsRightToLeft(const std::string &identifier) {
29
+ std::string language;
30
+ for (const char letter : identifier) {
31
+ if (letter == '-' || letter == '_' || letter == '.') {
32
+ break;
33
+ }
34
+ language += static_cast<char>(std::tolower(static_cast<unsigned char>(letter)));
35
+ }
36
+ return language == "ar" || language == "he" || language == "iw" || language == "fa" ||
37
+ language == "ur" || language == "ps" || language == "ckb" || language == "yi";
38
+ }
39
+
40
+ } // namespace
41
+
42
+ std::optional<SystemLocale> testLocale() {
43
+ static const std::optional<SystemLocale> requested = []() -> std::optional<SystemLocale> {
44
+ const char *const value = std::getenv("BASALT_TEST_LOCALE");
45
+ if (value == nullptr || *value == '\0') {
46
+ return std::nullopt;
47
+ }
48
+ SystemLocale locale;
49
+ locale.identifier = value;
50
+ locale.rightToLeft = languageReadsRightToLeft(locale.identifier);
51
+ return locale;
52
+ }();
53
+ return requested;
54
+ }
55
+
56
+ void setSystemLocale(SystemLocale locale) {
57
+ State &shared = state();
58
+ const std::lock_guard<std::mutex> guard(shared.mutex);
59
+ shared.locale = std::move(locale);
60
+ }
61
+
62
+ SystemLocale lastKnownSystemLocale() {
63
+ if (const std::optional<SystemLocale> requested = testLocale()) {
64
+ return *requested;
65
+ }
66
+ State &shared = state();
67
+ const std::lock_guard<std::mutex> guard(shared.mutex);
68
+ return shared.locale;
69
+ }
70
+
71
+ bool resolveRightToLeft(bool allowRTL, std::optional<bool> forced, bool systemRightToLeft) {
72
+ if (!allowRTL) {
73
+ return false;
74
+ }
75
+ if (forced.has_value()) {
76
+ return *forced;
77
+ }
78
+ return systemRightToLeft;
79
+ }
80
+
81
+ bool layoutIsRightToLeft() {
82
+ const SystemLocale locale = lastKnownSystemLocale();
83
+ State &shared = state();
84
+ const std::lock_guard<std::mutex> guard(shared.mutex);
85
+ return resolveRightToLeft(shared.allowRTL, shared.forced, locale.rightToLeft);
86
+ }
87
+
88
+ void setAllowRTL(bool allow) {
89
+ State &shared = state();
90
+ const std::lock_guard<std::mutex> guard(shared.mutex);
91
+ shared.allowRTL = allow;
92
+ }
93
+
94
+ void setForceRTL(bool force) {
95
+ State &shared = state();
96
+ const std::lock_guard<std::mutex> guard(shared.mutex);
97
+ // `forceRTL(false)` clears the force rather than forcing left-to-right,
98
+ // which is what React Native's own platforms do: an app turning it off goes
99
+ // back to following the system.
100
+ shared.forced = force ? std::optional<bool>(true) : std::nullopt;
101
+ }
102
+
103
+ } // namespace basalt
@@ -0,0 +1,86 @@
1
+ // Which language the desktop is in, and which way it reads.
2
+ //
3
+ // `I18nManager` answered neither until 2026-10-10: `localeIdentifier` was the
4
+ // empty string and `isRTL` was false unless an app had called `forceRTL`. The
5
+ // reason recorded in CoreModules.cpp was that "deciding it from the system
6
+ // locale would need a locale database this does not have", which is true of
7
+ // *parsing a locale name* and not of the question: every desktop publishes the
8
+ // reading direction directly.
9
+ //
10
+ // GTK `gtk_widget_get_default_direction()`, which GTK itself resolves
11
+ // from the locale -- translators ship "default:RTL" for a
12
+ // right-to-left language and GTK reads it back.
13
+ // AppKit `+[NSLocale characterDirectionForLanguage:]`, which answers from
14
+ // ICU's own data.
15
+ // Windows `GetLocaleInfoEx` with `LOCALE_IREADINGLAYOUT`, where 1 is
16
+ // right-to-left.
17
+ //
18
+ // What this costs if it is wrong is the whole layout mirrored the wrong way,
19
+ // which is why none of the three guesses from the language name.
20
+ //
21
+ // ## Why the layout and not only the flag
22
+ //
23
+ // `I18nManager.isRTL` is what an app branches on, and React Native expects the
24
+ // *layout* to agree: Yoga mirrors `flexDirection: 'row'`, `start`/`end`,
25
+ // padding and margin when it is told the direction, and the host is what tells
26
+ // it, through `LayoutConstraints::layoutDirection`. All three hosts passed
27
+ // `LeftToRight` unconditionally, so even an app calling `forceRTL(true)` and
28
+ // reloading -- which is what React Native's own documentation says to do --
29
+ // got a left-to-right layout with a flag that claimed otherwise.
30
+
31
+ #pragma once
32
+
33
+ #include <optional>
34
+ #include <string>
35
+
36
+ namespace basalt {
37
+
38
+ struct SystemLocale {
39
+ // BCP 47 or POSIX, as the platform spells it: `en-US` on Windows, `en_US` on
40
+ // the other two. Passed through rather than normalised, because React
41
+ // Native's own platforms do the same -- iOS reports `en_US` and Android
42
+ // `en_US` too -- and an app parsing it is parsing its platform's spelling.
43
+ std::string identifier;
44
+ bool rightToLeft{false};
45
+ };
46
+
47
+ // What the desktop says, asked once at startup by the host. **Called on the UI
48
+ // thread**: a toolkit's settings are not thread-safe.
49
+ SystemLocale systemLocale();
50
+
51
+ // What a test asked for, or nothing: `BASALT_TEST_LOCALE=ar-EG` overrides both
52
+ // fields, the direction coming from the language rather than from a database --
53
+ // the five right-to-left scripts are a short list and a test needs no more
54
+ // than that.
55
+ //
56
+ // Here for the same reason `BASALT_TEST_FONT_SCALE` is in core/FontScaling.h:
57
+ // a scenario cannot change the machine's language, and a feature with no test
58
+ // that fails when it is removed is not finished.
59
+ std::optional<SystemLocale> testLocale();
60
+
61
+ // Remembered by the host at startup, readable from any thread.
62
+ void setSystemLocale(SystemLocale locale);
63
+ SystemLocale lastKnownSystemLocale();
64
+
65
+ // Whether the layout should be mirrored, which is `I18nManager.isRTL`.
66
+ //
67
+ // React Native's three controls, in its own order of precedence:
68
+ //
69
+ // allowRTL(false) never mirrored, whatever the locale says
70
+ // forceRTL(true) always mirrored, whatever the locale says
71
+ // otherwise whatever the locale says
72
+ //
73
+ // `forceRTL(false)` is not "force left to right": React Native treats it as
74
+ // clearing the force, which is why the forced state is an optional here.
75
+ bool resolveRightToLeft(bool allowRTL, std::optional<bool> forced, bool systemRightToLeft);
76
+
77
+ // The answer for this process, from what the host reported and what an app has
78
+ // asked for since. Read by the hosts when they build layout constraints and
79
+ // by the I18nManager module.
80
+ bool layoutIsRightToLeft();
81
+
82
+ // The app's half, from `I18nManager`.
83
+ void setAllowRTL(bool allow);
84
+ void setForceRTL(bool force);
85
+
86
+ } // namespace basalt
@@ -72,6 +72,34 @@ std::optional<int> scriptedMenuChoice() {
72
72
  return choice;
73
73
  }
74
74
 
75
+ int scriptedMenuIndex(const std::vector<MenuEntry> &entries, int scripted) {
76
+ // Out of range answers as a dismissal rather than being clamped, which is the
77
+ // opposite of what presentAlert does with a button. A menu's entries are not
78
+ // interchangeable -- picking the nearest one would run something the script
79
+ // did not ask for -- whereas an alert's last button is always the way out.
80
+ //
81
+ // `menuEntryCount` and not `entries.size()`: an index counts every level now,
82
+ // so the valid range is the whole tree and a script naming a submenu's item
83
+ // would otherwise be refused for being past the end of the top level.
84
+ const int count = menuEntryCount(entries);
85
+ if (scripted < 0 || scripted >= count) {
86
+ return -1;
87
+ }
88
+
89
+ // And a dismissal for anything a person could not have chosen either: a
90
+ // parent opens rather than chooses, a separator is a line, an entry naming a
91
+ // role this desktop cannot perform is not in the menu at all, and a disabled
92
+ // entry is drawn and refuses the click. The last of those was missing until
93
+ // an action sheet put its title in the menu as a disabled entry, which a
94
+ // script could then "choose".
95
+ const MenuEntry *entry = menuEntryAt(entries, scripted);
96
+ if (entry == nullptr || entry->isParent() || entry->isSeparator() || !menuEntryShown(*entry) ||
97
+ !entry->enabled) {
98
+ return -1;
99
+ }
100
+ return scripted;
101
+ }
102
+
75
103
  void presentMenu(const MenuRequest &request, MenuCallback onChosen) {
76
104
  const std::optional<int> scripted = scriptedMenuChoice();
77
105
  if (!scripted.has_value()) {
@@ -79,12 +107,23 @@ void presentMenu(const MenuRequest &request, MenuCallback onChosen) {
79
107
  return;
80
108
  }
81
109
 
82
- // Out of range answers as a dismissal rather than being clamped, which is the
83
- // opposite of what presentAlert does with a button. A menu's entries are not
84
- // interchangeable -- picking the nearest one would run something the script
85
- // did not ask for -- whereas an alert's last button is always the way out.
86
- const int last = static_cast<int>(request.entries.size()) - 1;
87
- const int index = (*scripted < 0 || *scripted > last) ? -1 : *scripted;
110
+ const int index = scriptedMenuIndex(request.entries, *scripted);
111
+
112
+ // The platform's half of a role still happens: this seam exists to skip the
113
+ // window a person would have clicked, not to skip what choosing the item does.
114
+ // Without this, `copy` under test would resolve a promise and copy nothing.
115
+ //
116
+ // On the UI thread, which this is not. `showContextMenu` is a TurboModule
117
+ // call, so this runs on the JavaScript thread, and `performMenuRole` touches
118
+ // the window and the focused control -- AppKit from the wrong thread does not
119
+ // return. The symptom was the promise never settling at all: `about` opens a
120
+ // panel, and the call to open it never came back.
121
+ if (index >= 0) {
122
+ const MenuEntry *entry = menuEntryAt(request.entries, index);
123
+ if (entry != nullptr && !entry->role.empty()) {
124
+ postToUiThread([role = entry->role] { performMenuRole(role); });
125
+ }
126
+ }
88
127
  onChosen(index);
89
128
  }
90
129
 
@@ -72,6 +72,21 @@ void presentAlert(const AlertRequest &request, AlertCallback onButton);
72
72
  // stops the process where it stands.
73
73
  std::optional<int> scriptedMenuChoice();
74
74
 
75
+ // Which entry a script naming `scripted` actually lands on, or -1 for a
76
+ // dismissal.
77
+ //
78
+ // What a script can answer is what a real menu can, which is the whole point of
79
+ // the instrument and is four separate refusals: an index past the end of the
80
+ // tree, a separator, a submenu's parent -- which opens rather than chooses --
81
+ // and an entry that is drawn greyed or not drawn at all. Each of those is
82
+ // something a person could not have clicked, so a script that names one is
83
+ // answered the way closing the menu is.
84
+ //
85
+ // Separate from presentMenu so that it can be asserted. The variable is read
86
+ // once and cached for the life of the process, so a test cannot set it, and the
87
+ // arithmetic is the part worth testing anyway.
88
+ int scriptedMenuIndex(const std::vector<MenuEntry> &entries, int scripted);
89
+
75
90
  // `showMenu`, or the scripted answer when there is one. Everything that would
76
91
  // put a popup menu on screen goes through here rather than calling the platform
77
92
  // directly, for the same reason `presentAlert` exists.