basalt-win32 0.0.0-stage → 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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/cli/runWindows.js +35 -0
  3. package/cli/shared.js +75 -0
  4. package/native/CMakeLists.txt +429 -0
  5. package/native/tests/test_win32_accessibility.cpp +585 -0
  6. package/native/tests/test_win32_animated_image.cpp +351 -0
  7. package/native/tests/test_win32_clipboard.cpp +121 -0
  8. package/native/tests/test_win32_focus.cpp +195 -0
  9. package/native/tests/test_win32_hittest.cpp +750 -0
  10. package/native/tests/test_win32_image.cpp +370 -0
  11. package/native/tests/test_win32_imageloader.cpp +208 -0
  12. package/native/tests/test_win32_input.cpp +511 -0
  13. package/native/tests/test_win32_key_events.cpp +84 -0
  14. package/native/tests/test_win32_mounting.cpp +757 -0
  15. package/native/tests/test_win32_paint.cpp +1799 -0
  16. package/native/tests/test_win32_scrollview.cpp +647 -0
  17. package/native/tests/test_win32_selection.cpp +215 -0
  18. package/native/tests/test_win32_text.cpp +1456 -0
  19. package/native/tests/test_win32_textinput.cpp +1594 -0
  20. package/native/tests/test_win32_titlebar.cpp +111 -0
  21. package/native/tests/test_win32_titlebar_actions.cpp +62 -0
  22. package/native/tests/test_win32_view.cpp +222 -0
  23. package/native/win32/ColorSchemeWin32.cpp +56 -0
  24. package/native/win32/ComponentRegistryWin32.cpp +156 -0
  25. package/native/win32/DirectWriteLayout.cpp +485 -0
  26. package/native/win32/DirectWriteLayout.h +77 -0
  27. package/native/win32/DirectWriteLayoutManager.cpp +154 -0
  28. package/native/win32/FontRegistryDirectWrite.cpp +117 -0
  29. package/native/win32/PlatformServicesWin32.cpp +744 -0
  30. package/native/win32/RnWin32Accessible.cpp +343 -0
  31. package/native/win32/RnWin32Accessible.h +130 -0
  32. package/native/win32/RnWin32Image.cpp +588 -0
  33. package/native/win32/RnWin32Image.h +139 -0
  34. package/native/win32/RnWin32TextLayout.cpp +1379 -0
  35. package/native/win32/RnWin32TextLayout.h +390 -0
  36. package/native/win32/RnWin32View.cpp +2956 -0
  37. package/native/win32/RnWin32View.h +926 -0
  38. package/native/win32/Win32AnimationChoreographer.cpp +76 -0
  39. package/native/win32/Win32AnimationChoreographer.h +61 -0
  40. package/native/win32/Win32Clip.h +209 -0
  41. package/native/win32/Win32Cursors.cpp +309 -0
  42. package/native/win32/Win32Cursors.h +121 -0
  43. package/native/win32/Win32DropTarget.cpp +567 -0
  44. package/native/win32/Win32DropTarget.h +42 -0
  45. package/native/win32/Win32Focus.cpp +262 -0
  46. package/native/win32/Win32Focus.h +109 -0
  47. package/native/win32/Win32ImageLoader.cpp +199 -0
  48. package/native/win32/Win32ImageLoader.h +133 -0
  49. package/native/win32/Win32InputScopes.h +92 -0
  50. package/native/win32/Win32KeyEvents.cpp +126 -0
  51. package/native/win32/Win32KeyEvents.h +20 -0
  52. package/native/win32/Win32MenuBar.cpp +382 -0
  53. package/native/win32/Win32MenuBar.h +16 -0
  54. package/native/win32/Win32MountingManager.cpp +1282 -0
  55. package/native/win32/Win32MountingManager.h +243 -0
  56. package/native/win32/Win32Offscreen.h +53 -0
  57. package/native/win32/Win32Packaging.cpp +161 -0
  58. package/native/win32/Win32Packaging.h +56 -0
  59. package/native/win32/Win32RunLoopObserver.cpp +48 -0
  60. package/native/win32/Win32RunLoopObserver.h +42 -0
  61. package/native/win32/Win32ScrollView.cpp +718 -0
  62. package/native/win32/Win32ScrollView.h +256 -0
  63. package/native/win32/Win32SkiaContext.cpp +221 -0
  64. package/native/win32/Win32SkiaContext.h +53 -0
  65. package/native/win32/Win32SkiaModule.cpp +80 -0
  66. package/native/win32/Win32SkiaModule.h +50 -0
  67. package/native/win32/Win32SkiaPeer.cpp +358 -0
  68. package/native/win32/Win32SkiaPeer.h +66 -0
  69. package/native/win32/Win32SkiaUnicode.cpp +42 -0
  70. package/native/win32/Win32Snapshot.cpp +201 -0
  71. package/native/win32/Win32Snapshot.h +66 -0
  72. package/native/win32/Win32Strings.h +54 -0
  73. package/native/win32/Win32TextInput.cpp +1296 -0
  74. package/native/win32/Win32TextInput.h +341 -0
  75. package/native/win32/Win32TitleBar.cpp +562 -0
  76. package/native/win32/Win32TitleBar.h +146 -0
  77. package/native/win32/Win32TitleBarLayout.cpp +46 -0
  78. package/native/win32/Win32TitleBarLayout.h +58 -0
  79. package/native/win32/Win32TouchDispatcher.cpp +577 -0
  80. package/native/win32/Win32TouchDispatcher.h +201 -0
  81. package/native/win32/Win32UiThread.h +27 -0
  82. package/native/win32/Win32WindowControl.cpp +329 -0
  83. package/native/win32/Win32WindowModule.cpp +209 -0
  84. package/native/win32/Win32WindowModule.h +72 -0
  85. package/native/win32/demo_layout_win32.cpp +232 -0
  86. package/native/win32/main_win32.cpp +2722 -0
  87. package/native/win32/mount_harness_win32.cpp +316 -0
  88. package/package.json +25 -4
  89. package/react-native.config.js +22 -0
  90. package/README.md +0 -3
@@ -0,0 +1,757 @@
1
+ // Tests for Win32MountingManager: does a stream of ShadowViewMutations produce
2
+ // the view tree it describes?
3
+ //
4
+ // Deliberately the same nine questions `tests/test_mounting.cpp` asks of GTK
5
+ // and `tests/test_appkit_mounting.mm` asks of AppKit, in the same order, with
6
+ // the same tags and frames. All three managers share their walk, so most of
7
+ // this is asking whether the sharing actually holds -- and the day it stops,
8
+ // these fail here rather than in an app.
9
+ //
10
+ // One is new and is about this platform specifically:
11
+ // `win32_delete_frees_the_view` exists because Windows is the first of the
12
+ // three where the registry's ownership is written out rather than delegated to
13
+ // a refcount or to ARC.
14
+
15
+ #include "TestHarness.h"
16
+ #include "TreeDump.h"
17
+
18
+ #include "DirectWriteLayout.h"
19
+ #include "Win32MountingManager.h"
20
+
21
+ #include <react/renderer/components/view/ViewProps.h>
22
+ #include <react/renderer/graphics/Color.h>
23
+
24
+ #include <cstdint>
25
+ #include <cstdio>
26
+ #include <sstream>
27
+ #include <vector>
28
+
29
+ using basalt::Win32MountingManager;
30
+ using basalt::win32::RnWin32View;
31
+ using facebook::react::ColorComponents;
32
+ using facebook::react::LayoutMetrics;
33
+ using facebook::react::MountingTransaction;
34
+ using facebook::react::ShadowView;
35
+ using facebook::react::ShadowViewMutation;
36
+ using facebook::react::ShadowViewMutationList;
37
+ using facebook::react::SurfaceId;
38
+ using facebook::react::Tag;
39
+ using facebook::react::TransactionTelemetry;
40
+ using facebook::react::ViewProps;
41
+
42
+ namespace {
43
+
44
+ constexpr SurfaceId kSurfaceId = 1;
45
+
46
+ ShadowView makeView(Tag tag,
47
+ float x,
48
+ float y,
49
+ float width,
50
+ float height,
51
+ float red = 0.0F,
52
+ float green = 0.0F,
53
+ float blue = 0.0F) {
54
+ auto props = std::make_shared<ViewProps>();
55
+ props->backgroundColor = facebook::react::colorFromComponents(
56
+ ColorComponents{.red = red, .green = green, .blue = blue, .alpha = 1.0F});
57
+
58
+ LayoutMetrics metrics;
59
+ metrics.frame = {.origin = {.x = x, .y = y}, .size = {.width = width, .height = height}};
60
+
61
+ ShadowView view;
62
+ view.componentName = "View";
63
+ view.surfaceId = kSurfaceId;
64
+ view.tag = tag;
65
+ view.props = props;
66
+ view.layoutMetrics = metrics;
67
+ return view;
68
+ }
69
+
70
+ // Applies mutations synchronously. executeMount would post to the UI thread,
71
+ // which a test has no message loop to pump -- though with no host installed
72
+ // postToUiThread runs inline anyway, so this is about being explicit rather
73
+ // than about avoiding a hang.
74
+ void apply(Win32MountingManager &manager, ShadowViewMutationList &&mutations) {
75
+ manager.applyTransaction(
76
+ kSurfaceId, MountingTransaction(kSurfaceId, 1, std::move(mutations), TransactionTelemetry{}));
77
+ }
78
+
79
+ size_t childCount(const RnWin32View *view) {
80
+ return view->children().size();
81
+ }
82
+
83
+ // The tag of the nth child, or -1.
84
+ int32_t childTagAt(const RnWin32View *view, size_t index) {
85
+ if (index >= view->children().size()) {
86
+ return -1;
87
+ }
88
+ return view->children()[index]->tag();
89
+ }
90
+
91
+ } // namespace
92
+
93
+ TEST(win32_create_and_insert_builds_the_tree) {
94
+ Win32MountingManager manager;
95
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
96
+
97
+ ShadowViewMutationList mutations;
98
+ mutations.push_back(ShadowViewMutation::CreateMutation(makeView(10, 0, 0, 100, 50)));
99
+ mutations.push_back(ShadowViewMutation::CreateMutation(makeView(11, 0, 60, 100, 50)));
100
+ mutations.push_back(
101
+ ShadowViewMutation::InsertMutation(kSurfaceId, makeView(10, 0, 0, 100, 50), 0));
102
+ mutations.push_back(
103
+ ShadowViewMutation::InsertMutation(kSurfaceId, makeView(11, 0, 60, 100, 50), 1));
104
+ apply(manager, std::move(mutations));
105
+
106
+ EXPECT_EQ(childCount(root), size_t{2});
107
+ EXPECT_EQ(childTagAt(root, 0), 10);
108
+ EXPECT_EQ(childTagAt(root, 1), 11);
109
+
110
+ manager.destroySurfaceRoot(kSurfaceId);
111
+ }
112
+
113
+ TEST(win32_insert_honours_the_index) {
114
+ Win32MountingManager manager;
115
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
116
+
117
+ ShadowViewMutationList first;
118
+ first.push_back(ShadowViewMutation::CreateMutation(makeView(20, 0, 0, 10, 10)));
119
+ first.push_back(ShadowViewMutation::CreateMutation(makeView(21, 0, 0, 10, 10)));
120
+ first.push_back(ShadowViewMutation::InsertMutation(kSurfaceId, makeView(20, 0, 0, 10, 10), 0));
121
+ first.push_back(ShadowViewMutation::InsertMutation(kSurfaceId, makeView(21, 0, 0, 10, 10), 1));
122
+ apply(manager, std::move(first));
123
+
124
+ // An Insert's index counts positions in the parent's *final* child list, so
125
+ // this lands between the two. An append-only implementation passes every
126
+ // other test in this file.
127
+ ShadowViewMutationList second;
128
+ second.push_back(ShadowViewMutation::CreateMutation(makeView(22, 0, 0, 10, 10)));
129
+ second.push_back(ShadowViewMutation::InsertMutation(kSurfaceId, makeView(22, 0, 0, 10, 10), 1));
130
+ apply(manager, std::move(second));
131
+
132
+ EXPECT_EQ(childCount(root), size_t{3});
133
+ EXPECT_EQ(childTagAt(root, 0), 20);
134
+ EXPECT_EQ(childTagAt(root, 1), 22);
135
+ EXPECT_EQ(childTagAt(root, 2), 21);
136
+
137
+ manager.destroySurfaceRoot(kSurfaceId);
138
+ }
139
+
140
+ TEST(win32_remove_detaches_but_does_not_destroy) {
141
+ Win32MountingManager manager;
142
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
143
+
144
+ ShadowViewMutationList mutations;
145
+ mutations.push_back(ShadowViewMutation::CreateMutation(makeView(30, 0, 0, 10, 10)));
146
+ mutations.push_back(ShadowViewMutation::InsertMutation(kSurfaceId, makeView(30, 0, 0, 10, 10), 0));
147
+ apply(manager, std::move(mutations));
148
+
149
+ ShadowViewMutationList removal;
150
+ removal.push_back(ShadowViewMutation::RemoveMutation(kSurfaceId, makeView(30, 0, 0, 10, 10), 0));
151
+ apply(manager, std::move(removal));
152
+
153
+ // A reparent is a Remove and an Insert with no Delete between them, so the
154
+ // view has to survive the gap -- and the registry is the only thing holding
155
+ // it. Asking the registry is the assertion: if Remove had destroyed it, this
156
+ // would be null or a dangling pointer.
157
+ EXPECT_EQ(childCount(root), size_t{0});
158
+ EXPECT(manager.viewForTag(30) != nullptr);
159
+
160
+ manager.destroySurfaceRoot(kSurfaceId);
161
+ }
162
+
163
+ TEST(win32_update_changes_frame_without_reparenting) {
164
+ Win32MountingManager manager;
165
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
166
+
167
+ ShadowViewMutationList mutations;
168
+ mutations.push_back(ShadowViewMutation::CreateMutation(makeView(40, 0, 0, 100, 50)));
169
+ mutations.push_back(
170
+ ShadowViewMutation::InsertMutation(kSurfaceId, makeView(40, 0, 0, 100, 50), 0));
171
+ apply(manager, std::move(mutations));
172
+
173
+ ShadowViewMutationList update;
174
+ update.push_back(ShadowViewMutation::UpdateMutation(
175
+ makeView(40, 0, 0, 100, 50), makeView(40, 5, 6, 70, 30), kSurfaceId));
176
+ apply(manager, std::move(update));
177
+
178
+ RnWin32View *view = manager.viewForTag(40);
179
+ EXPECT(view != nullptr);
180
+ EXPECT_EQ(childCount(root), size_t{1});
181
+ EXPECT_NEAR(view->frame().x, 5.0, 0.001);
182
+ EXPECT_NEAR(view->frame().y, 6.0, 0.001);
183
+ EXPECT_NEAR(view->frame().width, 70.0, 0.001);
184
+ EXPECT_NEAR(view->frame().height, 30.0, 0.001);
185
+
186
+ manager.destroySurfaceRoot(kSurfaceId);
187
+ }
188
+
189
+ TEST(win32_delete_removes_the_view_from_the_registry) {
190
+ Win32MountingManager manager;
191
+ manager.createSurfaceRoot(kSurfaceId);
192
+
193
+ ShadowViewMutationList mutations;
194
+ mutations.push_back(ShadowViewMutation::CreateMutation(makeView(50, 0, 0, 10, 10)));
195
+ mutations.push_back(ShadowViewMutation::InsertMutation(kSurfaceId, makeView(50, 0, 0, 10, 10), 0));
196
+ apply(manager, std::move(mutations));
197
+ EXPECT(manager.viewForTag(50) != nullptr);
198
+
199
+ // Fabric guarantees the Remove before the Delete, and this is that order.
200
+ ShadowViewMutationList teardown;
201
+ teardown.push_back(ShadowViewMutation::RemoveMutation(kSurfaceId, makeView(50, 0, 0, 10, 10), 0));
202
+ teardown.push_back(ShadowViewMutation::DeleteMutation(makeView(50, 0, 0, 10, 10)));
203
+ apply(manager, std::move(teardown));
204
+
205
+ EXPECT(manager.viewForTag(50) == nullptr);
206
+
207
+ manager.destroySurfaceRoot(kSurfaceId);
208
+ }
209
+
210
+ TEST(win32_nested_children_are_parented_to_their_own_parent) {
211
+ Win32MountingManager manager;
212
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
213
+
214
+ ShadowViewMutationList mutations;
215
+ mutations.push_back(ShadowViewMutation::CreateMutation(makeView(60, 0, 0, 100, 100)));
216
+ mutations.push_back(ShadowViewMutation::CreateMutation(makeView(61, 10, 10, 20, 20)));
217
+ mutations.push_back(
218
+ ShadowViewMutation::InsertMutation(kSurfaceId, makeView(60, 0, 0, 100, 100), 0));
219
+ // Parented to 60, not to the surface root.
220
+ mutations.push_back(ShadowViewMutation::InsertMutation(60, makeView(61, 10, 10, 20, 20), 0));
221
+ apply(manager, std::move(mutations));
222
+
223
+ EXPECT_EQ(childCount(root), size_t{1});
224
+ RnWin32View *middle = manager.viewForTag(60);
225
+ EXPECT(middle != nullptr);
226
+ EXPECT_EQ(childCount(middle), size_t{1});
227
+ EXPECT_EQ(childTagAt(middle, 0), 61);
228
+
229
+ manager.destroySurfaceRoot(kSurfaceId);
230
+ }
231
+
232
+ TEST(win32_surface_root_is_reused_not_recreated) {
233
+ Win32MountingManager manager;
234
+ RnWin32View *first = manager.createSurfaceRoot(kSurfaceId);
235
+ RnWin32View *second = manager.createSurfaceRoot(kSurfaceId);
236
+
237
+ // In Fabric a SurfaceId *is* the root node's tag, and Fabric emits no Create
238
+ // for it -- the root shadow node is the base of every diff, so it must
239
+ // already exist when the first transaction arrives. Asking twice must not
240
+ // make two.
241
+ EXPECT(first == second);
242
+
243
+ manager.destroySurfaceRoot(kSurfaceId);
244
+ }
245
+
246
+ TEST(win32_a_mutation_for_an_unknown_tag_is_survivable) {
247
+ Win32MountingManager manager;
248
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
249
+
250
+ // What a bug upstream, or a transaction racing a surface teardown, looks like
251
+ // from down here. It must not be fatal.
252
+ ShadowViewMutationList mutations;
253
+ mutations.push_back(
254
+ ShadowViewMutation::InsertMutation(kSurfaceId, makeView(999, 0, 0, 10, 10), 0));
255
+ mutations.push_back(ShadowViewMutation::RemoveMutation(kSurfaceId, makeView(998, 0, 0, 10, 10), 0));
256
+ mutations.push_back(ShadowViewMutation::DeleteMutation(makeView(997, 0, 0, 10, 10)));
257
+ apply(manager, std::move(mutations));
258
+
259
+ EXPECT_EQ(childCount(root), size_t{0});
260
+
261
+ manager.destroySurfaceRoot(kSurfaceId);
262
+ }
263
+
264
+ TEST(win32_props_reach_the_view_through_a_mutation) {
265
+ Win32MountingManager manager;
266
+ manager.createSurfaceRoot(kSurfaceId);
267
+
268
+ ShadowViewMutationList mutations;
269
+ mutations.push_back(
270
+ ShadowViewMutation::CreateMutation(makeView(70, 0, 0, 64, 32, 1.0F, 0.0F, 0.0F)));
271
+ mutations.push_back(ShadowViewMutation::InsertMutation(
272
+ kSurfaceId, makeView(70, 0, 0, 64, 32, 1.0F, 0.0F, 0.0F), 0));
273
+ apply(manager, std::move(mutations));
274
+
275
+ // The whole trip: a ViewProps built here, through the walk, into the view's
276
+ // own state and out again as the dump the three hosts compare.
277
+ RnWin32View *view = manager.viewForTag(70);
278
+ EXPECT(view != nullptr);
279
+ EXPECT(view->hasBackgroundColor());
280
+ EXPECT_EQ(view->describeTree(), std::string("view tag=70 frame=(0,0 64x32) bg=#ff0000ff\n"));
281
+
282
+ manager.destroySurfaceRoot(kSurfaceId);
283
+ }
284
+
285
+ TEST(win32_has_component_matches_the_registered_descriptors) {
286
+ Win32MountingManager manager;
287
+
288
+ // Two statements of one fact, and when they disagree the registry wins:
289
+ // Fabric builds shadow nodes nothing can mount and the app renders blank
290
+ // rectangles rather than reporting anything. See ComponentRegistryWin32.cpp.
291
+ EXPECT(manager.hasComponent("View"));
292
+ EXPECT(manager.hasComponent("RootView"));
293
+ EXPECT(manager.hasComponent("Paragraph"));
294
+ EXPECT(manager.hasComponent("Image"));
295
+ EXPECT(manager.hasComponent("ScrollView"));
296
+ EXPECT(manager.hasComponent("TextInput"));
297
+ EXPECT(manager.hasComponent("ActivityIndicatorView"));
298
+ EXPECT(manager.hasComponent("Switch"));
299
+ EXPECT(manager.hasComponent("ModalHostView"));
300
+ EXPECT(manager.hasComponent("PullToRefreshView"));
301
+ // What React Native substitutes for a component nobody registered. Mounting
302
+ // it is what turns "nothing happens" into a view the tree dump can show.
303
+ EXPECT(manager.hasComponent("UnimplementedNativeView"));
304
+
305
+ // Claiming a component this host cannot mount is worse than admitting the
306
+ // gap. `ActivityIndicator` is here rather than above on purpose: the
307
+ // component React Native actually mounts is `ActivityIndicatorView`, and
308
+ // answering to the name a reader expects would be answering to a name
309
+ // nothing sends.
310
+ EXPECT(!manager.hasComponent("Slider"));
311
+ EXPECT(!manager.hasComponent("ActivityIndicator"));
312
+ EXPECT(!manager.hasComponent("SomethingNobodyHasHeardOf"));
313
+ }
314
+
315
+ // --- transformOrigin --------------------------------------------------------
316
+ //
317
+ // `resolveTransform` folds the origin into the matrix, and all three hosts
318
+ // call it -- so this has been "passed through but never exercised" since it
319
+ // was written. What makes it checkable without a display is that the anchor
320
+ // is a claim about a point: scaling about the top-left must leave the
321
+ // top-left corner exactly where it was.
322
+ //
323
+ // The hosts anchor a transform at the view's centre, which is what an
324
+ // untouched `transformOrigin` means, so the matrix is in centre-relative
325
+ // coordinates and the top-left corner is at (-w/2, -h/2).
326
+ //
327
+ // The same two tests are in the other two suites, with the same tags, for the
328
+ // same reason the ones above are.
329
+
330
+ namespace {
331
+
332
+ ShadowView makeTransformed(Tag tag,
333
+ float width,
334
+ float height,
335
+ facebook::react::Transform transform,
336
+ facebook::react::TransformOrigin origin = {}) {
337
+ // Fresh props rather than a copy of makeView's: BaseViewProps has a deleted
338
+ // copy constructor at React Native 0.86, which is what a current Expo app
339
+ // installs. Copying compiled against main and broke the version an app
340
+ // actually has -- found by building a real create-expo-app, not here.
341
+ ShadowView view = makeView(tag, 0.0F, 0.0F, width, height);
342
+ auto props = std::make_shared<ViewProps>();
343
+ props->transform = transform;
344
+ props->transformOrigin = origin;
345
+ view.props = props;
346
+ return view;
347
+ }
348
+
349
+ } // namespace
350
+
351
+ TEST(transform_origin_anchors_the_corner_it_names) {
352
+ Win32MountingManager manager;
353
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
354
+
355
+ using facebook::react::Transform;
356
+ using facebook::react::TransformOrigin;
357
+ using facebook::react::UnitType;
358
+ using facebook::react::ValueUnit;
359
+
360
+ // Doubled about the top-left of a 100x50 view.
361
+ TransformOrigin topLeft;
362
+ topLeft.xy = {ValueUnit(0.0F, UnitType::Point), ValueUnit(0.0F, UnitType::Point)};
363
+
364
+ ShadowViewMutationList mutations;
365
+ mutations.push_back(ShadowViewMutation::CreateMutation(
366
+ makeTransformed(30, 100.0F, 50.0F, Transform::Scale(2.0F, 2.0F, 1.0F), topLeft)));
367
+ mutations.push_back(ShadowViewMutation::InsertMutation(
368
+ kSurfaceId,
369
+ makeTransformed(30, 100.0F, 50.0F, Transform::Scale(2.0F, 2.0F, 1.0F), topLeft),
370
+ 0));
371
+ apply(manager, std::move(mutations));
372
+
373
+ const auto matrix = basalt::testing::transformIn(root->describeTree(), 30);
374
+ EXPECT_EQ(matrix[0], 2.0);
375
+ EXPECT_EQ(matrix[3], 2.0);
376
+
377
+ // The corner the origin names, in the centre-relative coordinates the
378
+ // matrix is written in, has to come back to itself: (-50,-25) scaled by two
379
+ // is (-100,-50), so the translation has to put 50 and 25 back.
380
+ const double cornerX = matrix[0] * -50.0 + matrix[2] * -25.0 + matrix[4];
381
+ const double cornerY = matrix[1] * -50.0 + matrix[3] * -25.0 + matrix[5];
382
+ EXPECT_EQ(cornerX, -50.0);
383
+ EXPECT_EQ(cornerY, -25.0);
384
+
385
+ manager.destroySurfaceRoot(kSurfaceId);
386
+ }
387
+
388
+ // And with no origin set, the centre is the anchor -- which is the default
389
+ // every host relies on, and the reason an unset origin must not be folded in
390
+ // as (0,0).
391
+ TEST(no_transform_origin_anchors_the_centre) {
392
+ Win32MountingManager manager;
393
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
394
+
395
+ using facebook::react::Transform;
396
+
397
+ ShadowViewMutationList mutations;
398
+ mutations.push_back(ShadowViewMutation::CreateMutation(
399
+ makeTransformed(31, 100.0F, 50.0F, Transform::Scale(2.0F, 2.0F, 1.0F))));
400
+ mutations.push_back(ShadowViewMutation::InsertMutation(
401
+ kSurfaceId, makeTransformed(31, 100.0F, 50.0F, Transform::Scale(2.0F, 2.0F, 1.0F)), 0));
402
+ apply(manager, std::move(mutations));
403
+
404
+ const auto matrix = basalt::testing::transformIn(root->describeTree(), 31);
405
+ EXPECT_EQ(matrix[0], 2.0);
406
+ EXPECT_EQ(matrix[3], 2.0);
407
+ // No translation at all: the centre is already the anchor.
408
+ EXPECT_EQ(matrix[4], 0.0);
409
+ EXPECT_EQ(matrix[5], 0.0);
410
+
411
+ manager.destroySurfaceRoot(kSurfaceId);
412
+ }
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // What a TextAttributes becomes on this platform: the case mapping and the
416
+ // decoration flags.
417
+ //
418
+ // Here rather than in tests/test_win32_text.cpp because both need React
419
+ // Native's TextAttributes, and that file is compiled into the toolkit-only
420
+ // build as well. The pixels are its half; this is the translation.
421
+ // ---------------------------------------------------------------------------
422
+ namespace {
423
+
424
+ facebook::react::TextAttributes withTransform(facebook::react::TextTransform transform) {
425
+ facebook::react::TextAttributes attributes;
426
+ attributes.textTransform = transform;
427
+ return attributes;
428
+ }
429
+
430
+ std::string transformed(facebook::react::TextTransform transform, const std::string &text) {
431
+ return basalt::win32::transformedFragmentText(withTransform(transform), text);
432
+ }
433
+
434
+ } // namespace
435
+
436
+ TEST(win32_text_transform_maps_case) {
437
+ using facebook::react::TextTransform;
438
+
439
+ EXPECT_EQ(transformed(TextTransform::Uppercase, "hello there"), std::string("HELLO THERE"));
440
+ EXPECT_EQ(transformed(TextTransform::Lowercase, "HELLO THERE"), std::string("hello there"));
441
+
442
+ // Through LCMapStringEx rather than through `std::toupper`, which is what
443
+ // makes this work for anything but ASCII: a byte-wise map leaves an accented
444
+ // letter alone and looks right in English.
445
+ // Written as UTF-8 escapes rather than as the letters themselves, because
446
+ // this file carries no encoding declaration and MSVC without /utf-8 reads a
447
+ // literal's bytes as the machine's code page. The string is "creme brulee"
448
+ // with a grave, a circumflex and an acute on it.
449
+ EXPECT_EQ(transformed(TextTransform::Uppercase, "cr\xc3\xa8me br\xc3\xbbl\xc3\xa9" "e"),
450
+ std::string("CR\xc3\x88ME BR\xc3\x9bL\xc3\x89" "E"));
451
+
452
+
453
+ // React Native's own capitalize rule, from RCTAttributedTextUtils.mm and
454
+ // copied on all three hosts: split on single spaces, lowercase the word, and
455
+ // uppercase its first character unless it is a digit. So "iOS" becomes "Ios",
456
+ // which is surprising and is what the other platforms do.
457
+ EXPECT_EQ(transformed(TextTransform::Capitalize, "hello iOS world"),
458
+ std::string("Hello Ios World"));
459
+ EXPECT_EQ(transformed(TextTransform::Capitalize, "3rd place"), std::string("3rd Place"));
460
+ // Double spaces are kept, which falls out of splitting on single ones.
461
+ EXPECT_EQ(transformed(TextTransform::Capitalize, "a b"), std::string("A B"));
462
+
463
+ // No transform, and the empty string, both come back untouched.
464
+ facebook::react::TextAttributes plain;
465
+ EXPECT_EQ(basalt::win32::transformedFragmentText(plain, "Left Alone"),
466
+ std::string("Left Alone"));
467
+ EXPECT_EQ(transformed(TextTransform::Uppercase, ""), std::string(""));
468
+ }
469
+
470
+ // **Windows keeps the German sharp s where the other two hosts expand it.**
471
+ // Measured on CI on 2026-10-09, which is the only Windows this repository has:
472
+ // `LCMapStringEx` with `LCMAP_UPPERCASE | LCMAP_LINGUISTIC_CASING` maps it to
473
+ // itself, so "Strasse" written with it uppercases to "STRA" + the sharp s +
474
+ // "E", seven bytes. GLib's `g_utf8_strup` and NSString's `uppercaseString`
475
+ // both answer "STRASSE", and so does every browser on Windows, because they go
476
+ // through ICU rather than through the locale API.
477
+ //
478
+ // Asserted rather than tolerated, so that the day this host is changed the test
479
+ // says so and the record gets updated with it.
480
+ // backlog/platform-windows.md names the call that would close the gap:
481
+ // `u_strToUpper`, from the ICU that Windows 10 1703 and later ship, which does
482
+ // Unicode's full case mapping.
483
+ TEST(win32_text_transform_keeps_the_sharp_s) {
484
+ const std::string mapped =
485
+ transformed(facebook::react::TextTransform::Uppercase, "Stra\xc3\x9f" "e");
486
+ EXPECT_EQ(mapped, std::string("STRA\xc3\x9f" "E"));
487
+ // Which is one byte longer than "STRASSE" is, and a byte shorter than the
488
+ // input: the sharp s is two bytes in UTF-8 and did not change.
489
+ EXPECT_EQ((int)mapped.size(), 7);
490
+ }
491
+
492
+ TEST(win32_a_decoration_reaches_the_style) {
493
+ using facebook::react::TextDecorationLineType;
494
+
495
+ facebook::react::TextAttributes attributes;
496
+ EXPECT(!basalt::win32::buildTextStyle(attributes).underline);
497
+ EXPECT(!basalt::win32::buildTextStyle(attributes).strikethrough);
498
+
499
+ attributes.textDecorationLineType = TextDecorationLineType::Underline;
500
+ EXPECT(basalt::win32::buildTextStyle(attributes).underline);
501
+ EXPECT(!basalt::win32::buildTextStyle(attributes).strikethrough);
502
+
503
+ attributes.textDecorationLineType = TextDecorationLineType::Strikethrough;
504
+ EXPECT(!basalt::win32::buildTextStyle(attributes).underline);
505
+ EXPECT(basalt::win32::buildTextStyle(attributes).strikethrough);
506
+
507
+ attributes.textDecorationLineType = TextDecorationLineType::UnderlineStrikethrough;
508
+ EXPECT(basalt::win32::buildTextStyle(attributes).underline);
509
+ EXPECT(basalt::win32::buildTextStyle(attributes).strikethrough);
510
+
511
+ // `None` is a line the app turned off, not an absent prop, and it draws
512
+ // nothing -- which core/TextDecorations.h answers for all three hosts.
513
+ attributes.textDecorationLineType = TextDecorationLineType::None;
514
+ EXPECT(!basalt::win32::buildTextStyle(attributes).underline);
515
+ EXPECT(!basalt::win32::buildTextStyle(attributes).strikethrough);
516
+ }
517
+
518
+ // ---------------------------------------------------------------------------
519
+ // writingDirection, fontVariant and a fragment's opacity, as they arrive.
520
+ //
521
+ // The pixels for the first are in tests/test_win32_text.cpp; these are the
522
+ // translations, which need React Native's TextAttributes.
523
+ // ---------------------------------------------------------------------------
524
+ TEST(win32_a_writing_direction_reaches_the_style) {
525
+ using facebook::react::WritingDirection;
526
+
527
+ facebook::react::TextAttributes attributes;
528
+ EXPECT(!basalt::win32::buildTextStyle(attributes).rightToLeft);
529
+
530
+ attributes.baseWritingDirection = WritingDirection::RightToLeft;
531
+ EXPECT(basalt::win32::buildTextStyle(attributes).rightToLeft);
532
+
533
+ attributes.baseWritingDirection = WritingDirection::LeftToRight;
534
+ EXPECT(!basalt::win32::buildTextStyle(attributes).rightToLeft);
535
+
536
+ // `natural` is left-to-right here, as it is on the other two hosts for Latin
537
+ // text: DirectWrite takes a direction rather than deciding one from the
538
+ // string, so there is no third state to pass on.
539
+ attributes.baseWritingDirection = WritingDirection::Natural;
540
+ EXPECT(!basalt::win32::buildTextStyle(attributes).rightToLeft);
541
+ }
542
+
543
+ // `lineBreakMode`, which React Native's JavaScript spells `lineBreakModeIOS`:
544
+ // whether a line that does not fit wraps or is cut, and where.
545
+ //
546
+ // The prop reached nothing here -- `RnTextStyle` had no field for it -- so a
547
+ // `<Text lineBreakModeIOS="tail">` wrapped onto four lines on this host and cut
548
+ // to one on the platform the prop is named after. What the style carries is
549
+ // the mode; what DirectWrite does with it is in RnWin32TextLayout.cpp, which
550
+ // turns wrapping off for the four that cut and trims the line.
551
+ TEST(win32_a_line_break_mode_reaches_the_style) {
552
+ using basalt::win32::RnLineBreakMode;
553
+ using facebook::react::LineBreakMode;
554
+
555
+ facebook::react::TextAttributes attributes;
556
+ // Nothing asked for wraps between words, which is React Native's default and
557
+ // what every ordinary paragraph depends on.
558
+ EXPECT(basalt::win32::buildTextStyle(attributes).lineBreakMode == RnLineBreakMode::Word);
559
+
560
+ attributes.lineBreakMode = LineBreakMode::Word;
561
+ EXPECT(basalt::win32::buildTextStyle(attributes).lineBreakMode == RnLineBreakMode::Word);
562
+ attributes.lineBreakMode = LineBreakMode::Char;
563
+ EXPECT(basalt::win32::buildTextStyle(attributes).lineBreakMode == RnLineBreakMode::Char);
564
+ attributes.lineBreakMode = LineBreakMode::Clip;
565
+ EXPECT(basalt::win32::buildTextStyle(attributes).lineBreakMode == RnLineBreakMode::Clip);
566
+ attributes.lineBreakMode = LineBreakMode::Tail;
567
+ EXPECT(basalt::win32::buildTextStyle(attributes).lineBreakMode == RnLineBreakMode::Tail);
568
+
569
+ // Head and middle arrive as themselves even though DirectWrite cuts the end
570
+ // for both: the style says what the app asked for, so the host can say what
571
+ // it did with it rather than pretending the app asked for a tail.
572
+ attributes.lineBreakMode = LineBreakMode::Head;
573
+ EXPECT(basalt::win32::buildTextStyle(attributes).lineBreakMode == RnLineBreakMode::Head);
574
+ attributes.lineBreakMode = LineBreakMode::Middle;
575
+ EXPECT(basalt::win32::buildTextStyle(attributes).lineBreakMode == RnLineBreakMode::Middle);
576
+ }
577
+
578
+ TEST(win32_an_alignment_keeps_start_and_end_relative) {
579
+ using basalt::win32::RnTextAlign;
580
+ using facebook::react::TextAlignment;
581
+
582
+ facebook::react::TextAttributes attributes;
583
+ // Nothing asked for is natural, not physical left: the difference only shows
584
+ // in a right-to-left paragraph, where it is the difference between the entry
585
+ // this closed and the bug it described.
586
+ EXPECT(basalt::win32::buildTextStyle(attributes).align == RnTextAlign::Natural);
587
+
588
+ attributes.alignment = TextAlignment::Left;
589
+ EXPECT(basalt::win32::buildTextStyle(attributes).align == RnTextAlign::Left);
590
+ attributes.alignment = TextAlignment::Right;
591
+ EXPECT(basalt::win32::buildTextStyle(attributes).align == RnTextAlign::Right);
592
+ attributes.alignment = TextAlignment::Center;
593
+ EXPECT(basalt::win32::buildTextStyle(attributes).align == RnTextAlign::Center);
594
+ attributes.alignment = TextAlignment::Justified;
595
+ EXPECT(basalt::win32::buildTextStyle(attributes).align == RnTextAlign::Justified);
596
+ attributes.alignment = TextAlignment::Natural;
597
+ EXPECT(basalt::win32::buildTextStyle(attributes).align == RnTextAlign::Natural);
598
+ #if BASALT_RN_MINOR >= 87
599
+ // `start` and `end` are resolved against the *layout* direction before they
600
+ // reach DirectWrite, which is what the fourth argument carries. Nothing
601
+ // passed is a left-to-right app, so they are the left and right edges.
602
+ attributes.alignment = TextAlignment::Start;
603
+ EXPECT(basalt::win32::buildTextStyle(attributes).align == RnTextAlign::Left);
604
+ attributes.alignment = TextAlignment::End;
605
+ EXPECT(basalt::win32::buildTextStyle(attributes).align == RnTextAlign::Right);
606
+
607
+ // And the other way round on a mirrored desktop, for the same Latin text:
608
+ // this is the case DirectWrite's own LEADING and TRAILING get wrong, they
609
+ // being relative to the paragraph rather than to the app. See
610
+ // core/TextAlignments.h and backlog/text.md.
611
+ const basalt::TextDirections mirrored{.paragraph = false, .relative = true};
612
+ attributes.alignment = TextAlignment::Start;
613
+ EXPECT(basalt::win32::buildTextStyle(attributes, {}, std::nullopt, mirrored).align
614
+ == RnTextAlign::Right);
615
+ attributes.alignment = TextAlignment::End;
616
+ EXPECT(basalt::win32::buildTextStyle(attributes, {}, std::nullopt, mirrored).align
617
+ == RnTextAlign::Left);
618
+ #endif
619
+ }
620
+
621
+ TEST(win32_font_variants_become_opentype_tags) {
622
+ using facebook::react::FontVariant;
623
+
624
+ facebook::react::TextAttributes attributes;
625
+ EXPECT(basalt::win32::buildTextStyle(attributes).fontFeatures.empty());
626
+
627
+ attributes.fontVariant = static_cast<FontVariant>(
628
+ static_cast<int>(FontVariant::SmallCaps) | static_cast<int>(FontVariant::TabularNums));
629
+ const auto features = basalt::win32::buildTextStyle(attributes).fontFeatures;
630
+ EXPECT_EQ((int)features.size(), 2);
631
+ // In core's order rather than the bitmask's, which is what makes this
632
+ // comparable with the GTK host's settings string.
633
+ EXPECT_EQ(features[0], std::string("smcp"));
634
+ EXPECT_EQ(features[1], std::string("tnum"));
635
+
636
+ // A stylistic alternate is `ssNN`, which is the case a table of tags gets
637
+ // wrong and core builds from the number instead.
638
+ attributes.fontVariant = FontVariant::StylisticSeven;
639
+ const auto alternate = basalt::win32::buildTextStyle(attributes).fontFeatures;
640
+ EXPECT_EQ((int)alternate.size(), 1);
641
+ EXPECT_EQ(alternate[0], std::string("ss07"));
642
+ }
643
+
644
+ TEST(win32_a_fragment_opacity_multiplies_the_alpha) {
645
+ facebook::react::TextAttributes attributes;
646
+ attributes.foregroundColor = facebook::react::colorFromComponents(
647
+ facebook::react::ColorComponents{1.0F, 0.0F, 0.0F, 1.0F});
648
+
649
+ // No opacity asked for: opaque, which is also what an unset colour is.
650
+ EXPECT_NEAR(basalt::win32::buildTextStyle(attributes).color[3], 1.0f, 0.01f);
651
+
652
+ attributes.opacity = 0.5;
653
+ const auto style = basalt::win32::buildTextStyle(attributes);
654
+ EXPECT_NEAR(style.color[3], 0.5f, 0.01f);
655
+ // And the channels are untouched: opacity is alpha, not a tint.
656
+ EXPECT_NEAR(style.color[0], 1.0f, 0.01f);
657
+ EXPECT_NEAR(style.color[1], 0.0f, 0.01f);
658
+
659
+ // Out of range is clamped, a stylesheet being able to ask for either.
660
+ attributes.opacity = 2.0;
661
+ EXPECT_NEAR(basalt::win32::buildTextStyle(attributes).color[3], 1.0f, 0.01f);
662
+ attributes.opacity = -1.0;
663
+ EXPECT_NEAR(basalt::win32::buildTextStyle(attributes).color[3], 0.0f, 0.01f);
664
+ }
665
+
666
+ // --- adjustsFontSizeToFit ----------------------------------------------------
667
+ //
668
+ // The search is core's and tested there; what these ask is the half that is
669
+ // DirectWrite's -- that the ratio reaches the font sizes, that the fitted
670
+ // paragraph actually fits, and that a paragraph which never asked is untouched.
671
+ namespace {
672
+
673
+ facebook::react::AttributedString fittingLabel(const std::string &text, float fontSize) {
674
+ facebook::react::TextAttributes attributes;
675
+ attributes.fontSize = fontSize;
676
+
677
+ facebook::react::AttributedString::Fragment fragment;
678
+ fragment.string = text;
679
+ fragment.textAttributes = attributes;
680
+
681
+ facebook::react::AttributedString string;
682
+ string.appendFragment(std::move(fragment));
683
+ return string;
684
+ }
685
+
686
+ } // namespace
687
+
688
+ TEST(win32_adjusts_font_size_to_fit_shrinks_until_it_fits) {
689
+ const auto text = fittingLabel("Delete all of the messages", 32.0f);
690
+
691
+ facebook::react::ParagraphAttributes fitting;
692
+ fitting.adjustsFontSizeToFit = true;
693
+ const basalt::FontFit fit = basalt::win32::textFitScale(text, fitting, 200.0f, 40.0f);
694
+ EXPECT(fit.scales());
695
+ EXPECT(fit.ratio < 1.0);
696
+
697
+ // And the fitted paragraph fits, measured through the same builder the host
698
+ // paints with.
699
+ const auto layout = basalt::win32::buildTextLayout(text, fitting, fit);
700
+ EXPECT(layout != nullptr);
701
+ if (layout == nullptr) {
702
+ return;
703
+ }
704
+ const basalt::win32::RnTextSize size = layout->measure(200.0f);
705
+ EXPECT(size.width <= 200.0f);
706
+ EXPECT(size.height <= 40.0f);
707
+ }
708
+
709
+ TEST(win32_a_paragraph_that_did_not_ask_is_not_shrunk) {
710
+ const auto text = fittingLabel("Delete all of the messages", 32.0f);
711
+ facebook::react::ParagraphAttributes plain;
712
+ EXPECT(!basalt::win32::textFitScale(text, plain, 200.0f, 40.0f).scales());
713
+
714
+ const auto layout = basalt::win32::buildTextLayout(text, plain);
715
+ EXPECT(layout != nullptr);
716
+ if (layout == nullptr) {
717
+ return;
718
+ }
719
+ // Wrapped and taller than the box, rather than shrunk into it.
720
+ EXPECT(layout->measure(200.0f).height > 40.0f);
721
+ }
722
+
723
+ TEST(win32_a_paragraph_that_already_fits_keeps_its_size) {
724
+ facebook::react::ParagraphAttributes fitting;
725
+ fitting.adjustsFontSizeToFit = true;
726
+ EXPECT(
727
+ !basalt::win32::textFitScale(fittingLabel("Hi", 16.0f), fitting, 300.0f, 100.0f).scales());
728
+ }
729
+
730
+ TEST(win32_the_minimum_font_size_stops_the_shrinking) {
731
+ facebook::react::ParagraphAttributes fitting;
732
+ fitting.adjustsFontSizeToFit = true;
733
+ fitting.minimumFontSize = 24.0f;
734
+ const basalt::FontFit fit = basalt::win32::textFitScale(
735
+ fittingLabel("Delete all of the messages", 32.0f), fitting, 60.0f, 30.0f);
736
+ EXPECT_NEAR(fit.apply(32.0), 24.0, 0.0001);
737
+
738
+ // Which is the size the style ends up with, this being where the ratio is
739
+ // applied.
740
+ facebook::react::TextAttributes attributes;
741
+ attributes.fontSize = 32.0;
742
+ EXPECT_NEAR(basalt::win32::buildTextStyle(attributes, fit).fontSize, 24.0f, 0.01f);
743
+ }
744
+
745
+ TEST(win32_a_line_limit_does_not_hide_the_overflow_from_the_search) {
746
+ // `numberOfLines` with `adjustsFontSizeToFit` is the pairing a button label
747
+ // is written with, and the reason the search measures the *untruncated*
748
+ // paragraph: a one-line limit makes a layout that fits any box by throwing
749
+ // text away.
750
+ facebook::react::ParagraphAttributes oneLine;
751
+ oneLine.adjustsFontSizeToFit = true;
752
+ oneLine.maximumNumberOfLines = 1;
753
+ const basalt::FontFit fit = basalt::win32::textFitScale(
754
+ fittingLabel("Delete all of the messages", 32.0f), oneLine, 200.0f, 40.0f);
755
+ EXPECT(fit.scales());
756
+ EXPECT(fit.ratio < 1.0);
757
+ }