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,2956 @@
1
+ #include "RnWin32View.h"
2
+
3
+ #include "Backface.h"
4
+ #include "ControlMetrics.h"
5
+ #include "FocusRing.h"
6
+ #include "RnWin32Image.h"
7
+ #include "RnWin32TextLayout.h"
8
+ #include "ScrollIndicator.h"
9
+ #include "Win32Clip.h"
10
+ #include "Win32Offscreen.h"
11
+ #include "Win32Strings.h"
12
+
13
+ // Which frame an animated image is showing, and the curve a box shadow's corner
14
+ // grows by. Both shared with the other two hosts.
15
+ #include "ImageAnimation.h"
16
+ #include "ShadowShape.h"
17
+
18
+ // Before d2d1.h, which wants the base Windows types and does not pull them in
19
+ // itself. NOMINMAX and WIN32_LEAN_AND_MEAN come from the package's CMakeLists;
20
+ // without the first, windows.h defines `min` and `max` as macros and breaks
21
+ // <algorithm> below at a distance.
22
+ #include <windows.h>
23
+
24
+ #include <d2d1.h>
25
+ #include <d2d1_1.h>
26
+ #include <d2d1effects.h>
27
+ #include <d2d1helper.h>
28
+ #include <wrl/client.h>
29
+
30
+ // `UiaRaiseNotificationEvent`, which is the only call in UI Automation that
31
+ // speaks a string, and the provider interface it raises the event on.
32
+ // `uiautomation.h` is the umbrella header the accessible half already uses.
33
+ #include <uiautomation.h>
34
+
35
+ #include <algorithm>
36
+ #include <chrono>
37
+ #include <optional>
38
+ #include <utility>
39
+ #include <cmath>
40
+ #include <cstdint>
41
+ #include <cstdio>
42
+
43
+ using Microsoft::WRL::ComPtr;
44
+
45
+ namespace basalt::win32 {
46
+
47
+ using basalt::kFocusRingAlpha;
48
+ using basalt::kFocusRingBlue;
49
+ using basalt::kFocusRingGreen;
50
+ using basalt::kFocusRingRed;
51
+ using basalt::kFocusRingWidth;
52
+
53
+ namespace {
54
+
55
+ // A scoped opacity layer, for `opacity` on a view.
56
+ //
57
+ // A layer rather than multiplying every brush's alpha: React Native's opacity
58
+ // composites the subtree as a unit, so two overlapping half-transparent
59
+ // children do not show through each other.
60
+ class ScopedOpacity {
61
+ public:
62
+ ScopedOpacity(ID2D1RenderTarget *target, float opacity) : target_(target) {
63
+ if (opacity >= 1.0f) {
64
+ return;
65
+ }
66
+ if (FAILED(target_->CreateLayer(nullptr, layer_.GetAddressOf()))) {
67
+ return;
68
+ }
69
+ auto parameters = D2D1::LayerParameters();
70
+ parameters.contentBounds = D2D1::InfiniteRect();
71
+ parameters.opacity = opacity;
72
+ target_->PushLayer(parameters, layer_.Get());
73
+ pushed_ = true;
74
+ }
75
+
76
+ ~ScopedOpacity() {
77
+ if (pushed_) {
78
+ target_->PopLayer();
79
+ }
80
+ }
81
+
82
+ ScopedOpacity(const ScopedOpacity &) = delete;
83
+ ScopedOpacity &operator=(const ScopedOpacity &) = delete;
84
+
85
+ private:
86
+ ID2D1RenderTarget *target_;
87
+ ComPtr<ID2D1Layer> layer_;
88
+ bool pushed_ = false;
89
+ };
90
+
91
+ // Escaped the way g_strescape's output is on the GTK side, so a string with a
92
+ // quote or a newline in it stays one line and stays comparable.
93
+ void appendEscaped(std::string &out, const std::string &text) {
94
+ for (const char c : text) {
95
+ switch (c) {
96
+ case '\\':
97
+ out += "\\\\";
98
+ break;
99
+ case '"':
100
+ out += "\\\"";
101
+ break;
102
+ case '\n':
103
+ out += "\\n";
104
+ break;
105
+ default:
106
+ out += c;
107
+ break;
108
+ }
109
+ }
110
+ }
111
+
112
+ template <typename... Args>
113
+ void appendFormat(std::string &out, const char *format, Args... args) {
114
+ char buffer[256];
115
+ const int written = std::snprintf(buffer, sizeof(buffer), format, args...);
116
+ if (written > 0) {
117
+ // snprintf returns what it *would* have written, so a truncated field is
118
+ // clamped here rather than read past the buffer.
119
+ const int limit = static_cast<int>(sizeof(buffer)) - 1;
120
+ out.append(buffer, static_cast<size_t>(written < limit ? written : limit));
121
+ }
122
+ }
123
+
124
+ unsigned toByte(float component) {
125
+ return static_cast<unsigned>(component * 255.0f + 0.5f);
126
+ }
127
+
128
+ // Two 2D affine matrices, in Direct2D's Matrix3x2F order and its row-vector
129
+ // convention: `compose(a, b)` is "apply a, then b", which is what
130
+ // `Matrix3x2F::SetProduct(a, b)` computes. Written out in floats rather than
131
+ // built with D2D1::Matrix3x2F so that hit testing -- which wants none of
132
+ // Direct2D -- can use the same composition painting does.
133
+ void compose(const float a[6], const float b[6], float out[6]) {
134
+ const float r[6] = {
135
+ a[0] * b[0] + a[1] * b[2],
136
+ a[0] * b[1] + a[1] * b[3],
137
+ a[2] * b[0] + a[3] * b[2],
138
+ a[2] * b[1] + a[3] * b[3],
139
+ a[4] * b[0] + a[5] * b[2] + b[4],
140
+ a[4] * b[1] + a[5] * b[3] + b[5],
141
+ };
142
+ for (int i = 0; i < 6; i++) {
143
+ out[i] = r[i];
144
+ }
145
+ }
146
+
147
+ // Maps a point through the inverse of `m`. False when `m` is singular, which is
148
+ // a view scaled to nothing: it paints no pixels, so nothing can be over it.
149
+ bool invertPoint(const float m[6], float x, float y, float &outX, float &outY) {
150
+ const float determinant = m[0] * m[3] - m[1] * m[2];
151
+ if (std::fabs(determinant) < 1e-6f) {
152
+ return false;
153
+ }
154
+ const float shiftedX = x - m[4];
155
+ const float shiftedY = y - m[5];
156
+ outX = (shiftedX * m[3] - shiftedY * m[2]) / determinant;
157
+ outY = (shiftedY * m[0] - shiftedX * m[1]) / determinant;
158
+ return true;
159
+ }
160
+
161
+ } // namespace
162
+
163
+ RnWin32View::RnWin32View(int32_t tag) : tag_(tag) {}
164
+
165
+ RnWin32View::~RnWin32View() {
166
+ // A view owns neither its children nor its parent; the registry owns every
167
+ // view. What a destructor does owe is that nothing is left pointing at it.
168
+ if (parent_ != nullptr) {
169
+ parent_->removeChild(this);
170
+ }
171
+ for (RnWin32View *child : children_) {
172
+ child->parent_ = nullptr;
173
+ }
174
+ }
175
+
176
+ // --- Geometry --------------------------------------------------------------
177
+
178
+ void RnWin32View::setFrame(float x, float y, float width, float height) {
179
+ frame_ = RnRect{x, y, width, height};
180
+ }
181
+
182
+ void RnWin32View::setScrollOffset(float x, float y) {
183
+ scrollX_ = x;
184
+ scrollY_ = y;
185
+ }
186
+
187
+ void RnWin32View::setScrollIndicators(float verticalOffset,
188
+ float verticalLength,
189
+ float horizontalOffset,
190
+ float horizontalLength) {
191
+ indicatorVerticalOffset_ = verticalOffset;
192
+ indicatorVerticalLength_ = verticalLength;
193
+ indicatorHorizontalOffset_ = horizontalOffset;
194
+ indicatorHorizontalLength_ = horizontalLength;
195
+ }
196
+
197
+ void RnWin32View::setScrollIndicatorBorderColour(float red, float green, float blue, float alpha) {
198
+ indicatorBorderColour_[0] = red;
199
+ indicatorBorderColour_[1] = green;
200
+ indicatorBorderColour_[2] = blue;
201
+ indicatorBorderColour_[3] = alpha;
202
+ }
203
+
204
+ void RnWin32View::setScrollIndicatorColour(float red, float green, float blue, float alpha) {
205
+ indicatorColour_[0] = red;
206
+ indicatorColour_[1] = green;
207
+ indicatorColour_[2] = blue;
208
+ indicatorColour_[3] = alpha;
209
+ }
210
+
211
+ // --- Appearance ------------------------------------------------------------
212
+
213
+ void RnWin32View::setBackgroundColor(float red,
214
+ float green,
215
+ float blue,
216
+ float alpha,
217
+ bool hasColor) {
218
+ hasBackgroundColor_ = hasColor;
219
+ backgroundColor_[0] = red;
220
+ backgroundColor_[1] = green;
221
+ backgroundColor_[2] = blue;
222
+ backgroundColor_[3] = alpha;
223
+ }
224
+
225
+ void RnWin32View::setOpacity(float opacity) {
226
+ opacity_ = opacity;
227
+ }
228
+
229
+ void RnWin32View::setTransform(const float *matrix16) {
230
+ if (matrix16 == nullptr) {
231
+ hasTransform_ = false;
232
+ transform_[0] = 1.0f;
233
+ transform_[1] = 0.0f;
234
+ transform_[2] = 0.0f;
235
+ transform_[3] = 1.0f;
236
+ transform_[4] = 0.0f;
237
+ transform_[5] = 0.0f;
238
+ updateBackFace();
239
+ return;
240
+ }
241
+
242
+ // CSS matrix3d order, which is column-major: m11 m12 m13 m14 m21 ... So the
243
+ // 2D affine part is elements 0, 1, 4, 5, 12 and 13, and those are the same
244
+ // six the GTK side pulls out of its graphene matrix and the macOS side out of
245
+ // its CATransform3D.
246
+ hasTransform_ = true;
247
+ transform_[0] = matrix16[0];
248
+ transform_[1] = matrix16[1];
249
+ transform_[2] = matrix16[4];
250
+ transform_[3] = matrix16[5];
251
+ transform_[4] = matrix16[12];
252
+ transform_[5] = matrix16[13];
253
+ updateBackFace();
254
+ }
255
+
256
+ void RnWin32View::setHidesBackFace(bool hides) {
257
+ if (hidesBackFace_ == hides) {
258
+ return;
259
+ }
260
+ hidesBackFace_ = hides;
261
+ updateBackFace();
262
+ }
263
+
264
+ // Hidden the same way the app's own `display: none` hides a view, because the
265
+ // effect is the same -- not painted, not hit tested, children included.
266
+ //
267
+ // The two reasons are kept apart and combined here. `hidden_` is one flag and
268
+ // `display: none` writes it too, so whichever of the two spoke last used to
269
+ // answer for both -- and layout metrics are applied after props, so a card
270
+ // turned away from the viewer was reliably un-hidden a moment later.
271
+ void RnWin32View::applyVisibility() {
272
+ hidden_ = hiddenByApp_ || hiddenByBackFace_;
273
+ }
274
+
275
+ void RnWin32View::updateBackFace() {
276
+ // Recomputed rather than returned early on, so that turning the prop off
277
+ // shows a view it had hidden instead of leaving it hidden forever.
278
+ hiddenByBackFace_ =
279
+ hidesBackFace_ &&
280
+ basalt::facesAway(transform_[0], transform_[1], transform_[2], transform_[3]);
281
+ applyVisibility();
282
+ }
283
+
284
+ void RnWin32View::setClipsChildren(bool clips) {
285
+ clipsChildren_ = clips;
286
+ }
287
+
288
+ void RnWin32View::setCornerRadius(float radius) {
289
+ const float radii[8] = {radius, radius, radius, radius, radius, radius, radius, radius};
290
+ setCornerRadii(radii);
291
+ }
292
+
293
+ void RnWin32View::setCornerRadii(const float radii[8]) {
294
+ hasCornerRadii_ = false;
295
+ for (int i = 0; i < 8; i++) {
296
+ cornerRadii_[i] = radii != nullptr ? radii[i] : 0.0f;
297
+ if (cornerRadii_[i] > 0.0f) {
298
+ hasCornerRadii_ = true;
299
+ }
300
+ }
301
+ }
302
+
303
+ void RnWin32View::setBorders(const float widths[4], const float colours[16]) {
304
+ // "Has a border" is decided here, once, the way rn_view_set_borders decides
305
+ // it on GTK: some edge both wide and visible. Paint and the tree dump both
306
+ // read this flag, so they cannot disagree about whether a border exists.
307
+ hasBorders_ = false;
308
+ for (int i = 0; i < 4; i++) {
309
+ borderWidths_[i] = widths != nullptr ? widths[i] : 0.0f;
310
+ for (int c = 0; c < 4; c++) {
311
+ borderColours_[i * 4 + c] = colours != nullptr ? colours[i * 4 + c] : 0.0f;
312
+ }
313
+ if (borderWidths_[i] > 0.0f && borderColours_[i * 4 + 3] > 0.0f) {
314
+ hasBorders_ = true;
315
+ }
316
+ }
317
+ }
318
+
319
+ void RnWin32View::setZIndex(int zIndex) {
320
+ zIndex_ = zIndex;
321
+ }
322
+
323
+ void RnWin32View::setHidden(bool hidden) {
324
+ hiddenByApp_ = hidden;
325
+ applyVisibility();
326
+ }
327
+
328
+ // --- Content ----------------------------------------------------------------
329
+
330
+ void RnWin32View::setTextSelectable(bool selectable) {
331
+ if (textSelectable_ == selectable) {
332
+ return;
333
+ }
334
+ textSelectable_ = selectable;
335
+ // A paragraph that stops being selectable keeps no highlight: the prop going
336
+ // false is an app saying this text is not to be selected, and leaving the
337
+ // last selection drawn would be the one state nothing can clear.
338
+ // No repaint request here, for the reason `setImage` makes none: this host
339
+ // invalidates once per mounted transaction rather than per view, and a
340
+ // selection changed by the pointer is repainted by whoever changed it. See
341
+ // Win32TouchDispatcher's repaint requester.
342
+ if (!selectable && textLayout_ != nullptr && textLayout_->selectionLength() > 0) {
343
+ textLayout_->setSelection(0, 0);
344
+ }
345
+ }
346
+
347
+ int RnWin32View::textIndexAtPoint(float x, float y) const {
348
+ if (textLayout_ == nullptr) {
349
+ return -1;
350
+ }
351
+ // In the paragraph's own coordinates: the view's, minus its padding. The same
352
+ // move the draw makes, or a drag would select the character beside the one
353
+ // under the pointer.
354
+ return static_cast<int>(textLayout_->indexAtPoint(x - textInsetLeft_,
355
+ y - textInsetTop_,
356
+ frame_.width - textInsetLeft_ -
357
+ textInsetRight_,
358
+ frame_.height - textInsetTop_ -
359
+ textInsetBottom_));
360
+ }
361
+
362
+ float RnWin32View::textVerticalOffset() const {
363
+ if (textLayout_ == nullptr) {
364
+ return 0.0f;
365
+ }
366
+ return textLayout_->verticalOffsetFor(frame_.width - textInsetLeft_ - textInsetRight_,
367
+ frame_.height - textInsetTop_ - textInsetBottom_);
368
+ }
369
+
370
+ void RnWin32View::setScrollSnap(std::string description) {
371
+ scrollSnap_ = std::move(description);
372
+ }
373
+
374
+ void RnWin32View::setTextInset(float left, float top, float right, float bottom) {
375
+ // No repaint request here, for the reason `setImage` makes none: this host
376
+ // invalidates once per mounted transaction, and this is only ever set during
377
+ // one.
378
+ textInsetLeft_ = left;
379
+ textInsetTop_ = top;
380
+ textInsetRight_ = right;
381
+ textInsetBottom_ = bottom;
382
+ }
383
+
384
+ void RnWin32View::setTextSelection(int start, int length) {
385
+ if (textLayout_ == nullptr) {
386
+ return;
387
+ }
388
+ const unsigned from = static_cast<unsigned>(std::max(0, start));
389
+ const unsigned count = static_cast<unsigned>(std::max(0, length));
390
+ if (textLayout_->selectionStart() == from && textLayout_->selectionLength() == count) {
391
+ return;
392
+ }
393
+ textLayout_->setSelection(from, count);
394
+ }
395
+
396
+ int RnWin32View::textSelectionStart() const {
397
+ return textLayout_ == nullptr ? 0 : static_cast<int>(textLayout_->selectionStart());
398
+ }
399
+
400
+ int RnWin32View::textSelectionLength() const {
401
+ return textLayout_ == nullptr ? 0 : static_cast<int>(textLayout_->selectionLength());
402
+ }
403
+
404
+ std::string RnWin32View::selectedText() const {
405
+ return textLayout_ == nullptr ? std::string{} : textLayout_->selectedText();
406
+ }
407
+
408
+ void RnWin32View::setParagraphNames(const char *writingDirection,
409
+ const char *textAlign,
410
+ const char *textVerticalAlign) {
411
+ // Literals from core, not copies, the way the GTK host stores the same three.
412
+ writingDirectionName_ = writingDirection;
413
+ textAlignName_ = textAlign;
414
+ textVerticalAlignName_ = textVerticalAlign;
415
+ }
416
+
417
+ void RnWin32View::setTextLayout(std::shared_ptr<RnWin32TextLayout> layout) {
418
+ // A selection survives a rebuild of the same text and not a change to it.
419
+ // Every mutation that touches a paragraph builds a new layout -- a parent
420
+ // re-rendering is enough -- so dropping the selection each time would make it
421
+ // impossible to keep one in a live app; keeping it across *different* text
422
+ // would highlight whatever now sits at those offsets. The other two hosts
423
+ // draw the same line in their own setters.
424
+ if (textLayout_ != nullptr && layout != nullptr && textLayout_->selectionLength() > 0 &&
425
+ textLayout_->text() == layout->text()) {
426
+ layout->setSelection(textLayout_->selectionStart(), textLayout_->selectionLength());
427
+ }
428
+ textLayout_ = std::move(layout);
429
+ }
430
+
431
+ void RnWin32View::setImageTint(bool hasTint, const float components[4]) {
432
+ // No repaint request here, for the same reason `setImage` makes none: this
433
+ // host invalidates once per mounted transaction rather than per view. See
434
+ // requestRepaint in main_win32.cpp.
435
+ hasImageTint_ = hasTint;
436
+ if (hasTint && components != nullptr) {
437
+ for (int i = 0; i < 4; i++) {
438
+ imageTint_[i] = components[i];
439
+ }
440
+ }
441
+ }
442
+
443
+ void RnWin32View::setImageBlur(float radius) {
444
+ // Negative is no blur rather than a crash, which nothing stops an app from
445
+ // sending: the other two hosts clamp it for the same reason, and GSK would
446
+ // otherwise take it.
447
+ imageBlur_ = radius > 0.0f ? radius : 0.0f;
448
+ }
449
+
450
+ void RnWin32View::setImage(std::shared_ptr<RnWin32Image> image, RnImageFit fit) {
451
+ imageFit_ = fit;
452
+ // An animation owns `image_`, and the mounting manager re-applies the first
453
+ // frame on every mutation that touched this view, a layout-only one included.
454
+ // Taking it would restart the GIF on every resize, so the fit is all that is
455
+ // taken here.
456
+ if (imageFrames_.size() > 1 && image == imageFrames_.front()) {
457
+ return;
458
+ }
459
+ image_ = std::move(image);
460
+ }
461
+
462
+ void RnWin32View::setImageFrames(std::vector<std::shared_ptr<RnWin32Image>> frames,
463
+ std::vector<unsigned> delaysMs,
464
+ unsigned loopCount) {
465
+ const bool same = frames.size() == imageFrames_.size() && !frames.empty()
466
+ && frames.front() == imageFrames_.front() && loopCount == imageLoopCount_;
467
+ if (same) {
468
+ // The same animation again, which is what a layout-only mutation produces:
469
+ // the loader hands back the vector it cached, so this compares the first
470
+ // frame and keeps the animation's place rather than starting over.
471
+ return;
472
+ }
473
+
474
+ imageFrames_ = std::move(frames);
475
+ imageDelays_ = std::move(delaysMs);
476
+ imageLoopCount_ = loopCount;
477
+ imageFrame_ = 0;
478
+ imageElapsedMs_ = 0.0;
479
+ if (imageFrames_.size() < 2 || imageDelays_.size() != imageFrames_.size()) {
480
+ imageFrames_.clear();
481
+ imageDelays_.clear();
482
+ return;
483
+ }
484
+ image_ = imageFrames_.front();
485
+ }
486
+
487
+ double RnWin32View::advanceImageAnimation(double milliseconds) {
488
+ if (imageFrames_.size() < 2) {
489
+ return 0.0;
490
+ }
491
+ if (milliseconds > 0.0) {
492
+ imageElapsedMs_ += milliseconds;
493
+ }
494
+
495
+ const basalt::ImageAnimationStep step = basalt::imageAnimationStep(
496
+ imageDelays_, imageLoopCount_, static_cast<std::uint64_t>(imageElapsedMs_));
497
+ if (step.frame != imageFrame_ && step.frame < imageFrames_.size()) {
498
+ imageFrame_ = step.frame;
499
+ image_ = imageFrames_[step.frame];
500
+ }
501
+ return step.nextInMs;
502
+ }
503
+
504
+ bool RnWin32View::advanceImageAnimations(double milliseconds) {
505
+ bool animating = advanceImageAnimation(milliseconds) > 0.0;
506
+ for (RnWin32View *child : children_) {
507
+ if (child != nullptr && child->advanceImageAnimations(milliseconds)) {
508
+ animating = true;
509
+ }
510
+ }
511
+ return animating;
512
+ }
513
+
514
+ bool RnWin32View::hasAnimatedImage() const {
515
+ if (imageFrames_.size() > 1) {
516
+ return true;
517
+ }
518
+ for (const RnWin32View *child : children_) {
519
+ if (child != nullptr && child->hasAnimatedImage()) {
520
+ return true;
521
+ }
522
+ }
523
+ return false;
524
+ }
525
+
526
+ void RnWin32View::setPainter(std::shared_ptr<RnWin32Painter> painter) {
527
+ painter_ = std::move(painter);
528
+ }
529
+
530
+ // --- Accessibility ----------------------------------------------------------
531
+
532
+ void RnWin32View::setAccessibleInfo(const RnAccessibleInfo &info) {
533
+ accessible_ = info;
534
+ }
535
+
536
+ IRawElementProviderSimple *RnWin32View::createAccessibleProvider() const {
537
+ // The relation is resolved here rather than stored resolved, because the
538
+ // provider holds a snapshot and this is the moment it is taken: the view it
539
+ // points at is alive now, and whatever its label says now is what a client
540
+ // asking now should be told.
541
+ if (!labelledBy_.empty() && labelledBy_.front() != nullptr) {
542
+ RnAccessibleInfo info = accessible_;
543
+ info.labelledBy =
544
+ std::make_shared<const RnAccessibleInfo>(labelledBy_.front()->accessibleInfo());
545
+ return basalt::win32::createAccessibleProvider(info);
546
+ }
547
+ return basalt::win32::createAccessibleProvider(accessible_);
548
+ }
549
+
550
+ void RnWin32View::setTextChecking(const char *spellCheck, const char *autoCorrect) {
551
+ spellCheck_ = spellCheck != nullptr ? spellCheck : "";
552
+ autoCorrect_ = autoCorrect != nullptr ? autoCorrect : "";
553
+ }
554
+
555
+ void RnWin32View::setInputHiding(bool caretHidden, bool contextMenuHidden) {
556
+ caretHidden_ = caretHidden;
557
+ contextMenuHidden_ = contextMenuHidden;
558
+ }
559
+
560
+ void RnWin32View::setInputKinds(const char *autoCapitalize, const char *keyboardType) {
561
+ autoCapitalize_ = autoCapitalize != nullptr ? autoCapitalize : "";
562
+ keyboardType_ = keyboardType != nullptr ? keyboardType : "";
563
+ }
564
+
565
+ void RnWin32View::setLabelledBy(std::vector<RnWin32View *> labels) {
566
+ labelledBy_ = std::move(labels);
567
+ }
568
+
569
+ void RnWin32View::collectTextInto(std::string &out) const {
570
+ if (textLayout_ != nullptr) {
571
+ const std::string &text = textLayout_->text();
572
+ if (!text.empty()) {
573
+ if (!out.empty()) {
574
+ out += ' ';
575
+ }
576
+ out += text;
577
+ }
578
+ }
579
+ for (const RnWin32View *child : children_) {
580
+ if (child != nullptr) {
581
+ child->collectTextInto(out);
582
+ }
583
+ }
584
+ }
585
+
586
+ std::string RnWin32View::collectText() const {
587
+ std::string out;
588
+ collectTextInto(out);
589
+ return out;
590
+ }
591
+
592
+ void RnWin32View::announce(const std::string &text, bool assertive) {
593
+ if (text.empty()) {
594
+ return;
595
+ }
596
+ lastAnnouncement_ = text;
597
+
598
+ // A provider to raise it on, which is also the one thing UIA needs that the
599
+ // other two hosts do not: there is no view object a client knows about, only
600
+ // what a provider says. Released straight after -- the event carries the
601
+ // string, and nothing keeps the element.
602
+ ComPtr<IRawElementProviderSimple> provider;
603
+ provider.Attach(createAccessibleProvider());
604
+ if (!provider) {
605
+ return;
606
+ }
607
+
608
+ // Real BSTRs rather than a cast wide pointer: a BSTR carries its length ahead
609
+ // of its characters, and UIA reads that length. A `wchar_t *` cast to BSTR
610
+ // works until something asks how long it is.
611
+ const BSTR spoken = SysAllocString(widen(text).c_str());
612
+ const BSTR activity = SysAllocString(std::to_wstring(tag_).c_str());
613
+ if (spoken != nullptr && activity != nullptr) {
614
+ // `NotificationProcessing_ImportantAll` for an assertive region and
615
+ // `NotificationProcessing_All` for a polite one, which is as close as UIA's
616
+ // four processing hints come to ARIA's two politeness levels: both say
617
+ // "read every one of these", and the important variant jumps the queue.
618
+ //
619
+ // `NotificationKind_ActionCompleted` is the kind for "something finished
620
+ // and here is the result", which is what a status line saying `Saved` is.
621
+ // The activity id groups notifications from one source; this host uses the
622
+ // view's tag, so two regions do not cancel each other out.
623
+ UiaRaiseNotificationEvent(
624
+ provider.Get(),
625
+ NotificationKind_ActionCompleted,
626
+ assertive ? NotificationProcessing_ImportantAll : NotificationProcessing_All,
627
+ spoken,
628
+ activity);
629
+ }
630
+ SysFreeString(spoken);
631
+ SysFreeString(activity);
632
+ }
633
+
634
+ // --- Geometry, resolved -----------------------------------------------------
635
+
636
+ void RnWin32View::localToParent(float out[6]) const {
637
+ const float translation[6] = {1.0f, 0.0f, 0.0f, 1.0f, frame_.x, frame_.y};
638
+ if (!hasTransform_) {
639
+ for (int i = 0; i < 6; i++) {
640
+ out[i] = translation[i];
641
+ }
642
+ return;
643
+ }
644
+
645
+ // Anchored at the view's centre by moving the centre to the origin and back
646
+ // around the transform, which is what React Native means by an untouched
647
+ // `transformOrigin` and what every other platform here does. The frame's
648
+ // translation comes last because this composes "apply a, then b".
649
+ const float centreX = frame_.width / 2.0f;
650
+ const float centreY = frame_.height / 2.0f;
651
+ const float toOrigin[6] = {1.0f, 0.0f, 0.0f, 1.0f, -centreX, -centreY};
652
+ const float fromOrigin[6] = {1.0f, 0.0f, 0.0f, 1.0f, centreX, centreY};
653
+
654
+ float composed[6];
655
+ compose(toOrigin, transform_, composed);
656
+ compose(composed, fromOrigin, composed);
657
+ compose(composed, translation, out);
658
+ }
659
+
660
+ bool RnWin32View::pageToLocal(const RnWin32View *root,
661
+ float pageX,
662
+ float pageY,
663
+ float &outX,
664
+ float &outY) const {
665
+ float toPage[6] = {1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f};
666
+
667
+ for (const RnWin32View *node = this; node != nullptr && node != root;) {
668
+ float local[6];
669
+ node->localToParent(local);
670
+ compose(toPage, local, toPage);
671
+
672
+ const RnWin32View *parent = node->parent();
673
+ if (parent == nullptr) {
674
+ break;
675
+ }
676
+ // A scrolled ancestor moves its children by its offset, which is not part
677
+ // of any child's own placement -- the same offset `paintChildren`
678
+ // subtracts and `hitTest` adds back.
679
+ const float scroll[6] = {1.0f,
680
+ 0.0f,
681
+ 0.0f,
682
+ 1.0f,
683
+ -parent->scrollX(),
684
+ -parent->scrollY() + parent->textVerticalOffset()};
685
+ compose(toPage, scroll, toPage);
686
+ node = parent;
687
+ }
688
+
689
+ return invertPoint(toPage, pageX, pageY, outX, outY);
690
+ }
691
+
692
+ std::vector<RnWin32View *> RnWin32View::childrenInPaintOrder() const {
693
+ std::vector<RnWin32View *> ordered = children_;
694
+ const bool needsSorting =
695
+ std::any_of(ordered.begin(), ordered.end(), [](const RnWin32View *child) {
696
+ return child->zIndex() != 0;
697
+ });
698
+ if (needsSorting) {
699
+ // Stable, so equal zIndex keeps document order -- which is what CSS and
700
+ // React Native both promise.
701
+ std::stable_sort(
702
+ ordered.begin(), ordered.end(), [](const RnWin32View *a, const RnWin32View *b) {
703
+ return a->zIndex() < b->zIndex();
704
+ });
705
+ }
706
+ return ordered;
707
+ }
708
+
709
+ // --- Tree ------------------------------------------------------------------
710
+
711
+ void RnWin32View::insertChild(RnWin32View *child, int index) {
712
+ if (child == nullptr || child == this) {
713
+ return;
714
+ }
715
+ if (child->parent_ != nullptr) {
716
+ child->parent_->removeChild(child);
717
+ }
718
+
719
+ // Fabric's index counts positions in the parent's *final* child list, so a
720
+ // later mutation lands between two existing children. Clamped rather than
721
+ // trusted: a transaction racing a surface teardown can name a position that
722
+ // no longer exists, and that has to be survivable.
723
+ const int count = static_cast<int>(children_.size());
724
+ const int at = std::clamp(index, 0, count);
725
+ children_.insert(children_.begin() + at, child);
726
+ child->parent_ = this;
727
+ }
728
+
729
+ void RnWin32View::removeChild(RnWin32View *child) {
730
+ const auto it = std::find(children_.begin(), children_.end(), child);
731
+ if (it == children_.end()) {
732
+ return;
733
+ }
734
+ (*it)->parent_ = nullptr;
735
+ children_.erase(it);
736
+ }
737
+
738
+ // --- Painting --------------------------------------------------------------
739
+
740
+ void RnWin32View::paint(ID2D1RenderTarget *target) const {
741
+ if (target == nullptr || hidden_) {
742
+ return;
743
+ }
744
+
745
+ D2D1::Matrix3x2F parentTransform;
746
+ target->GetTransform(&parentTransform);
747
+
748
+ // From localToParent rather than composed here, so that hit testing -- which
749
+ // inverts the same six numbers -- cannot end up with a different idea of
750
+ // where this view is.
751
+ float localValues[6];
752
+ localToParent(localValues);
753
+ const D2D1::Matrix3x2F local(localValues[0],
754
+ localValues[1],
755
+ localValues[2],
756
+ localValues[3],
757
+ localValues[4],
758
+ localValues[5]);
759
+ target->SetTransform(local * parentTransform);
760
+
761
+ // `filter`, which applies to this view and everything inside it, as CSS says.
762
+ // That needs the subtree as a picture, so it goes through an offscreen bitmap
763
+ // and an effect graph; everything else draws straight onto the target.
764
+ //
765
+ // A blended child needs the same picture for a different reason: a blend
766
+ // takes the backdrop as an input, and the backdrop is this view's content
767
+ // plus whatever was painted before that child. Both cases are "the subtree
768
+ // into a bitmap first", which is what made `paintContents` worth splitting
769
+ // out; a view with both takes the filter's path and the blend is skipped
770
+ // inside it, see paintFiltered.
771
+ if (filters_.needsEffects()) {
772
+ paintFiltered(target);
773
+ } else if (hasBlendedChild()) {
774
+ paintBlendLayer(target);
775
+ } else {
776
+ paintContents(target);
777
+ }
778
+
779
+ target->SetTransform(parentTransform);
780
+ }
781
+
782
+ void RnWin32View::paintContents(ID2D1RenderTarget *target,
783
+ ID2D1BitmapRenderTarget *blendLayer) const {
784
+ const D2D1_RECT_F bounds = D2D1::RectF(0.0f, 0.0f, frame_.width, frame_.height);
785
+
786
+ // The transform this view's contents are drawn in, read rather than composed
787
+ // because the two callers set it differently: `paint` has applied this view's
788
+ // own matrix to the window's target, and `paintFiltered` draws into an
789
+ // offscreen bitmap that starts at the identity because the whole subtree goes
790
+ // there. Kept because `paintChildren` leaves a <ScrollView>'s offset applied
791
+ // and what comes after it does not scroll.
792
+ D2D1::Matrix3x2F contentTransform;
793
+ target->GetTransform(&contentTransform);
794
+
795
+ {
796
+ // `filter`'s own `opacity()` multiplies the view's: CSS has two ways to ask
797
+ // for the same thing and an app can use both. Folded in here rather than
798
+ // being an effect of its own, which is what the GTK host does too.
799
+ // No layer when the subtree is going through one of its own: a pushed
800
+ // layer holds everything drawn inside it in an intermediate surface, and
801
+ // the blended children below read the target's bitmap. `paintBlendLayer`
802
+ // applies the opacity to that bitmap instead, which is the same picture.
803
+ const ScopedOpacity fade(target,
804
+ blendLayer != nullptr ? 1.0f : opacity_ * filters_.opacity);
805
+
806
+ // The outset shadows, behind everything this view draws: CSS puts them
807
+ // under the background, which is also the only place they can go without
808
+ // showing through a translucent one.
809
+ paintBoxShadows(target, false);
810
+
811
+ // The background is always clipped to the rounded box, even when children
812
+ // are not: `overflow: visible` lets a child escape the corner, but the
813
+ // view's own fill still has to respect its border radius.
814
+ if (hasBackgroundColor_) {
815
+ ComPtr<ID2D1SolidColorBrush> brush;
816
+ // An explicit sRGB colour. Letting the target pick a device space shifts
817
+ // every colour slightly on a wide-gamut display -- not wrong exactly, but
818
+ // different from the same app on Linux, which is the one thing this
819
+ // project is trying not to be.
820
+ const D2D1_COLOR_F colour = D2D1::ColorF(
821
+ backgroundColor_[0], backgroundColor_[1], backgroundColor_[2], backgroundColor_[3]);
822
+ if (SUCCEEDED(target->CreateSolidColorBrush(colour, brush.GetAddressOf()))) {
823
+ if (hasCornerRadii_) {
824
+ // The real shape: each corner its own, elliptical where React Native
825
+ // says so. See roundedBoxGeometry in Win32Clip.h.
826
+ ComPtr<ID2D1Factory> factory;
827
+ target->GetFactory(factory.GetAddressOf());
828
+ const ComPtr<ID2D1Geometry> box = roundedBoxGeometry(factory.Get(), bounds, cornerRadii_);
829
+ if (box) {
830
+ target->FillGeometry(box.Get(), brush.Get());
831
+ }
832
+ } else {
833
+ target->FillRectangle(bounds, brush.Get());
834
+ }
835
+ }
836
+ }
837
+
838
+ // The `backgroundImage` gradients, over the background colour and under the
839
+ // content: CSS's background-image is above its background-color.
840
+ paintGradients(target);
841
+
842
+ // The inset shadows, over the background and under the content, which is
843
+ // where CSS puts them. (AppKit gets this one wrong and says so: a sublayer
844
+ // is above the view's own text.)
845
+ paintBoxShadows(target, true);
846
+
847
+ // Then the image, then the text, then the children -- the order
848
+ // `rn_view_snapshot` uses on GTK. Nothing in React Native puts two of these
849
+ // on one view, but the order still has to be decided somewhere, and it is
850
+ // cheaper to match than to argue about later.
851
+ if (image_ != nullptr) {
852
+ // The four floats as stored; RnWin32Image builds the Direct2D colour,
853
+ // where d2d1.h is in scope. See its header for why it takes floats.
854
+ image_->draw(target,
855
+ frame_.width,
856
+ frame_.height,
857
+ imageFit_,
858
+ hasImageTint_ ? imageTint_ : nullptr,
859
+ imageBlur_);
860
+ }
861
+
862
+ // Then anything this layer cannot draw itself -- the Skia surface behind a
863
+ // `<Canvas>`, and nothing else so far. Here rather than in its own pass so
864
+ // that it is inside the clip, the transform and the opacity this view has
865
+ // already applied, which is the whole point of drawing it in the walk.
866
+ if (painter_ != nullptr) {
867
+ painter_->draw(target, frame_.width, frame_.height);
868
+ }
869
+
870
+ // Text sits above the background and below any children, which is the
871
+ // order `<Text>` with nested views expects. The paragraph draws itself at
872
+ // the view's *content* origin, in the box Yoga gave it minus its padding
873
+ // and border -- the same box it was measured against, because both go
874
+ // through RnWin32TextLayout. See setTextInset.
875
+ if (textLayout_ != nullptr) {
876
+ const bool inset = textInsetLeft_ != 0.0f || textInsetTop_ != 0.0f;
877
+ if (inset) {
878
+ target->SetTransform(
879
+ D2D1::Matrix3x2F::Translation(textInsetLeft_, textInsetTop_) * contentTransform);
880
+ }
881
+ textLayout_->draw(target,
882
+ frame_.width - textInsetLeft_ - textInsetRight_,
883
+ frame_.height - textInsetTop_ - textInsetBottom_);
884
+ if (inset) {
885
+ target->SetTransform(contentTransform);
886
+ }
887
+ }
888
+
889
+ // A control sits where the text would: above the background, below the
890
+ // children. Nothing in React Native puts children inside a <Switch>, but
891
+ // the order still has to be decided somewhere.
892
+ if (control_ != Control::None) {
893
+ paintControl(target);
894
+ }
895
+
896
+ paintChildren(target, blendLayer);
897
+
898
+ // paintChildren leaves a ScrollView's offset applied, and nothing below
899
+ // here scrolls with the content -- so put the transform back to this view's
900
+ // own before drawing any of it.
901
+ target->SetTransform(contentTransform);
902
+
903
+ // The scrollbars: an overlay, so above the content and above any children,
904
+ // which is what "overlay indicator" means -- and below the DevTools overlay
905
+ // and the focus ring, neither of which belongs to the app.
906
+ if (indicatorVerticalLength_ > 0.0f || indicatorHorizontalLength_ > 0.0f) {
907
+ paintScrollIndicators(target);
908
+ }
909
+
910
+ // The border: over the content, as GTK and AppKit draw it, and inside the
911
+ // view's own rounded box rather than around it -- React Native's border is
912
+ // part of the box, which is why Yoga has already inset the content by it.
913
+ // Below the DevTools overlay and the focus ring, which are not the app's.
914
+ if (hasBorders_) {
915
+ paintBorders(target);
916
+ }
917
+
918
+ // The outline, outside the box and over the border: it is not a border, and
919
+ // it sits `offset` away from the box's edge. After the children for the
920
+ // clipping reason in paintOutline, and below the DevTools overlay and the
921
+ // focus ring, which are not the app's.
922
+ paintOutline(target);
923
+
924
+ // React DevTools' overlay, over everything including the children. Above
925
+ // the app on purpose: it is not part of it, and an inspected element half
926
+ // hidden behind a card would be pointing at the wrong thing.
927
+ if (!highlights_.empty()) {
928
+ paintHighlights(target);
929
+ }
930
+
931
+ // The focus ring, over everything including the children, because it is the
932
+ // answer to "where am I" and must not be hidden by what it is drawn on.
933
+ //
934
+ // Inside the view's own bounds rather than around them, which is where both
935
+ // other hosts draw it -- AppKit's is clipped to the view and GTK's matches
936
+ // that on purpose. Stroked down the middle of the line, so the rectangle is
937
+ // inset by half of it to keep the whole ring inside.
938
+ if (showsFocusRing_) {
939
+ ComPtr<ID2D1SolidColorBrush> brush;
940
+ const D2D1_COLOR_F colour = D2D1::ColorF(
941
+ kFocusRingRed, kFocusRingGreen, kFocusRingBlue, kFocusRingAlpha);
942
+ if (SUCCEEDED(target->CreateSolidColorBrush(colour, brush.GetAddressOf()))) {
943
+ const float inset = kFocusRingWidth / 2.0f;
944
+ const D2D1_RECT_F ring = D2D1::RectF(inset,
945
+ inset,
946
+ (std::max)(inset, frame_.width - inset),
947
+ (std::max)(inset, frame_.height - inset));
948
+ if (hasCornerRadii_) {
949
+ // The view's own shape, each radius pulled in by the inset so the
950
+ // ring follows the corner rather than cutting across it.
951
+ float insetRadii[8];
952
+ for (int i = 0; i < 8; i++) {
953
+ insetRadii[i] = (std::max)(0.0f, cornerRadii_[i] - inset);
954
+ }
955
+ ComPtr<ID2D1Factory> factory;
956
+ target->GetFactory(factory.GetAddressOf());
957
+ const ComPtr<ID2D1Geometry> outline =
958
+ roundedBoxGeometry(factory.Get(), ring, insetRadii);
959
+ if (outline) {
960
+ target->DrawGeometry(outline.Get(), brush.Get(), kFocusRingWidth);
961
+ }
962
+ } else {
963
+ target->DrawRectangle(ring, brush.Get(), kFocusRingWidth);
964
+ }
965
+ }
966
+ }
967
+ }
968
+ }
969
+
970
+ // The border, as the ring between the view's rounded box and that box inset by
971
+ // each edge's width.
972
+ //
973
+ // The inner corners follow CSS: each outer radius less the width of the edge
974
+ // it meets, and never below zero -- which is why a thick border on a small
975
+ // radius has a square inside, on every platform.
976
+ //
977
+ // One colour all round is one fill. Different colours per edge are how CSS
978
+ // draws them: the ring is split along the diagonals from each outer corner to
979
+ // the matching inner one, and each side is filled in its own colour. Those
980
+ // sides are filled aliased inside a layer whose mask is the anti-aliased ring:
981
+ // adjacent anti-aliased fills leave a faint seam down each diagonal where their
982
+ // coverages meet, and the mask already gives the ring's own edges their
983
+ // smoothing.
984
+ // The dash pattern for a style, in Direct2D's units.
985
+ //
986
+ // Direct2D counts dash lengths in multiples of the stroke width, where GSK and
987
+ // Core Animation take absolute ones -- so the other two hosts' {3w, 2w} is
988
+ // {3, 2} here, and theirs is scaled to the width for the reason a browser
989
+ // scales it: a fixed pattern reads as a hairline on a thick border and as a
990
+ // solid line on a thin one. Dotted is a zero-length dash with round caps,
991
+ // which is what makes a dot a dot rather than a short dash.
992
+ //
993
+ // Answers with the stroke style, or null for a solid line and for a failure,
994
+ // which draws solid: a ring drawn solid is wrong in a way a reader can see, and
995
+ // no ring at all looks like the prop being ignored.
996
+ static ComPtr<ID2D1StrokeStyle> dashStyleFor(ID2D1Factory *factory,
997
+ RnWin32View::LineStyle style) {
998
+ if (factory == nullptr || style == RnWin32View::LineStyle::Solid) {
999
+ return nullptr;
1000
+ }
1001
+ const bool dots = style == RnWin32View::LineStyle::Dotted;
1002
+ const D2D1_CAP_STYLE cap = dots ? D2D1_CAP_STYLE_ROUND : D2D1_CAP_STYLE_FLAT;
1003
+ const float pattern[2] = {dots ? 0.0f : 3.0f, 2.0f};
1004
+ const D2D1_STROKE_STYLE_PROPERTIES properties = D2D1::StrokeStyleProperties(
1005
+ cap, cap, cap, D2D1_LINE_JOIN_MITER, 10.0f, D2D1_DASH_STYLE_CUSTOM, 0.0f);
1006
+
1007
+ ComPtr<ID2D1StrokeStyle> stroke;
1008
+ if (FAILED(factory->CreateStrokeStyle(properties, pattern, 2, stroke.GetAddressOf()))) {
1009
+ return nullptr;
1010
+ }
1011
+ return stroke;
1012
+ }
1013
+
1014
+ void RnWin32View::setBorderStyle(LineStyle style) {
1015
+ borderStyle_ = style;
1016
+ }
1017
+
1018
+ void RnWin32View::setOutline(float width,
1019
+ float offset,
1020
+ const float colour[4],
1021
+ LineStyle style) {
1022
+ outlineWidth_ = width > 0.0f ? width : 0.0f;
1023
+ outlineOffset_ = offset;
1024
+ for (int component = 0; component < 4; component++) {
1025
+ outlineColour_[component] = colour != nullptr ? colour[component] : 0.0f;
1026
+ }
1027
+ outlineStyle_ = style;
1028
+ }
1029
+
1030
+ void RnWin32View::setFilters(const Filters &filters) {
1031
+ filters_ = filters;
1032
+ }
1033
+
1034
+ // `filter`, as an effect graph over this subtree's own picture.
1035
+ //
1036
+ // CSS applies a filter to an element *and its descendants*, so the subtree has
1037
+ // to exist as an image before anything can be done to it: the contents go into
1038
+ // a compatible bitmap, the effects run over that, and the result is drawn where
1039
+ // the view is. The other two hosts get the same shape from their compositors,
1040
+ // GSK by pushing nodes and Core Animation by holding filters on the layer.
1041
+ //
1042
+ // The order is the one `core/Filters.h` settled and backlog/correctness.md
1043
+ // records: the colour matrix and the blur commute, so either way round is the
1044
+ // same picture, and the drop shadows are outermost, cast by the blurred and
1045
+ // recoloured result rather than recoloured themselves. That is CSS's
1046
+ // `grayscale(1) drop-shadow(...)` and not the other order, which is the limit
1047
+ // that header names.
1048
+ //
1049
+ // **A child that overflows a filtered view is cropped to the view's box here**,
1050
+ // because the bitmap is the view's own size. GSK blurs the overflow because its
1051
+ // node tree carries the subtree's real extent, which nothing in this host
1052
+ // computes; backlog/platform-windows.md records it rather than leaving it to be
1053
+ // discovered.
1054
+ void RnWin32View::paintFiltered(ID2D1RenderTarget *target) const {
1055
+ ComPtr<ID2D1DeviceContext> context;
1056
+ // With a format that keeps alpha rather than the window's, which has none:
1057
+ // every one of these effects is defined on what is *not* covered as much as
1058
+ // on what is. See Win32Offscreen.h.
1059
+ const ComPtr<ID2D1BitmapRenderTarget> offscreen =
1060
+ createOffscreen(target, frame_.width, frame_.height);
1061
+ if (FAILED(target->QueryInterface(IID_PPV_ARGS(&context))) || !offscreen) {
1062
+ // No device context, no effects: the unfiltered picture is the honest
1063
+ // answer, since a filter that cannot be applied should not take the view
1064
+ // with it.
1065
+ paintContents(target);
1066
+ return;
1067
+ }
1068
+
1069
+ offscreen->BeginDraw();
1070
+ offscreen->Clear(D2D1::ColorF(0.0f, 0.0f, 0.0f, 0.0f));
1071
+ paintContents(offscreen.Get());
1072
+ if (FAILED(offscreen->EndDraw())) {
1073
+ paintContents(target);
1074
+ return;
1075
+ }
1076
+
1077
+ ComPtr<ID2D1Bitmap> picture;
1078
+ if (FAILED(offscreen->GetBitmap(picture.GetAddressOf())) || !picture) {
1079
+ paintContents(target);
1080
+ return;
1081
+ }
1082
+
1083
+ // The chain, built from the bitmap outwards. `source` is whatever the last
1084
+ // effect produced, or the bitmap when there is none. Each effect is held for
1085
+ // as long as the graph is drawn, which is why they are declared out here.
1086
+ ComPtr<ID2D1Effect> straighten;
1087
+ ComPtr<ID2D1Effect> matrix;
1088
+ ComPtr<ID2D1Effect> blur;
1089
+ ComPtr<ID2D1Image> source;
1090
+ picture->QueryInterface(IID_PPV_ARGS(&source));
1091
+
1092
+ if (filters_.hasMatrix
1093
+ && SUCCEEDED(context->CreateEffect(CLSID_D2D1ColorMatrix, matrix.GetAddressOf()))
1094
+ && matrix) {
1095
+ // Direct2D multiplies a *row* vector by the matrix, so its m(i, j) is the
1096
+ // weight of input channel i in output channel j -- the transpose of core's,
1097
+ // whose rows are outputs. The fifth row is the offset.
1098
+ D2D1_MATRIX_5X4_F wanted{};
1099
+ float *const cells = &wanted._11;
1100
+ for (int input = 0; input < 4; input++) {
1101
+ for (int output = 0; output < 4; output++) {
1102
+ cells[input * 4 + output] = filters_.matrix[output * 4 + input];
1103
+ }
1104
+ }
1105
+ for (int output = 0; output < 4; output++) {
1106
+ cells[16 + output] = filters_.offset[output];
1107
+ }
1108
+ matrix->SetValue(D2D1_COLORMATRIX_PROP_COLOR_MATRIX, wanted);
1109
+ // Clamped, which is what every browser does with a matrix that leaves the
1110
+ // range.
1111
+ matrix->SetValue(D2D1_COLORMATRIX_PROP_CLAMP_OUTPUT, TRUE);
1112
+
1113
+ // **CSS's filters are defined on unpremultiplied colour**, and getting that
1114
+ // took two measurements rather than a reading of the documentation.
1115
+ //
1116
+ // `D2D1_COLORMATRIX_ALPHA_MODE_STRAIGHT` sounds like the answer and is not:
1117
+ // with it set, a half-transparent red whose alpha the matrix forces opaque
1118
+ // came out dark red, which is 0.5 read as a colour rather than as a colour
1119
+ // times a coverage. So that setting does not mean "convert for me"; it
1120
+ // means "the data is already straight, leave the alpha alone".
1121
+ //
1122
+ // What does convert is `CLSID_D2D1UnPremultiply` ahead of the matrix, with
1123
+ // the matrix then told PREMULTIPLIED -- which, read the same way, means
1124
+ // "premultiply the result on the way out". An explicit `Premultiply` effect
1125
+ // after it premultiplied a second time, which the second measurement
1126
+ // caught: a half-transparent view with an *identity* matrix came back at a
1127
+ // quarter of its colour.
1128
+ //
1129
+ // Both tests are kept, and they pin the pair from opposite ends: one has an
1130
+ // opaque result, where premultiplying is a no-op and only the conversion on
1131
+ // the way in shows; the other is transparent throughout, where only the
1132
+ // conversion on the way out does.
1133
+ matrix->SetValue(D2D1_COLORMATRIX_PROP_ALPHA_MODE,
1134
+ D2D1_COLORMATRIX_ALPHA_MODE_PREMULTIPLIED);
1135
+ if (SUCCEEDED(context->CreateEffect(CLSID_D2D1UnPremultiply, straighten.GetAddressOf()))
1136
+ && straighten) {
1137
+ straighten->SetInput(0, source.Get());
1138
+ source.Reset();
1139
+ straighten->GetOutput(source.GetAddressOf());
1140
+ }
1141
+ matrix->SetInput(0, source.Get());
1142
+ source.Reset();
1143
+ matrix->GetOutput(source.GetAddressOf());
1144
+ }
1145
+
1146
+ if (filters_.blurRadius > 0.0f
1147
+ && SUCCEEDED(context->CreateEffect(CLSID_D2D1GaussianBlur, blur.GetAddressOf()))
1148
+ && blur) {
1149
+ // Half the radius is the standard deviation, which is the conversion both
1150
+ // other hosts make and `core/Filters.h` documents.
1151
+ blur->SetValue(D2D1_GAUSSIANBLUR_PROP_STANDARD_DEVIATION, filters_.blurRadius / 2.0f);
1152
+ blur->SetInput(0, source.Get());
1153
+ source.Reset();
1154
+ blur->GetOutput(source.GetAddressOf());
1155
+ }
1156
+
1157
+ // The shadows first, so they are behind the picture, and in the order they
1158
+ // were written: the first is the one nearest the content, which is the order
1159
+ // `core/Filters.h` keeps them in.
1160
+ for (const FilterShadow &shadow : filters_.shadows) {
1161
+ if (shadow.colour[3] <= 0.0f) {
1162
+ continue;
1163
+ }
1164
+ ComPtr<ID2D1Effect> cast;
1165
+ if (FAILED(context->CreateEffect(CLSID_D2D1Shadow, cast.GetAddressOf())) || !cast) {
1166
+ continue;
1167
+ }
1168
+ cast->SetInput(0, source.Get());
1169
+ cast->SetValue(D2D1_SHADOW_PROP_BLUR_STANDARD_DEVIATION, shadow.standardDeviation);
1170
+ const D2D1_VECTOR_4F tint{
1171
+ shadow.colour[0], shadow.colour[1], shadow.colour[2], shadow.colour[3]};
1172
+ cast->SetValue(D2D1_SHADOW_PROP_COLOR, tint);
1173
+ context->DrawImage(cast.Get(), D2D1::Point2F(shadow.dx, shadow.dy));
1174
+ }
1175
+
1176
+ context->DrawImage(source.Get(), D2D1::Point2F(0.0f, 0.0f));
1177
+ }
1178
+
1179
+ void RnWin32View::setGradients(std::vector<Gradient> gradients) {
1180
+ gradients_ = std::move(gradients);
1181
+ }
1182
+
1183
+ // The `backgroundImage` gradients, above the background colour and below the
1184
+ // content, which is where CSS paints a background image.
1185
+ //
1186
+ // Back to front, the first in the list being the one on top, and clipped to the
1187
+ // view's border box -- CSS's painting area -- which is also what keeps a
1188
+ // gradient from squaring off a rounded corner.
1189
+ //
1190
+ // The geometry and the stops were resolved in the mounting manager against the
1191
+ // *image's* size and offset to where the image goes, so this draws a gradient
1192
+ // and does no CSS: `area` is the rectangle it fills and `tile` is the period it
1193
+ // repeats in. An axis that does not repeat arrives with the painting area as
1194
+ // its tile, which comes out drawn once; see core/BackgroundLayers.h.
1195
+ void RnWin32View::paintGradients(ID2D1RenderTarget *target) const {
1196
+ if (gradients_.empty()) {
1197
+ return;
1198
+ }
1199
+ const D2D1_RECT_F box = D2D1::RectF(0.0f, 0.0f, frame_.width, frame_.height);
1200
+
1201
+ for (size_t index = gradients_.size(); index > 0; index--) {
1202
+ const Gradient &gradient = gradients_[index - 1];
1203
+ if (gradient.stops.empty() || gradient.area[2] <= 0.0f || gradient.area[3] <= 0.0f) {
1204
+ continue;
1205
+ }
1206
+
1207
+ std::vector<D2D1_GRADIENT_STOP> stops;
1208
+ stops.reserve(gradient.stops.size());
1209
+ for (const GradientStop &stop : gradient.stops) {
1210
+ stops.push_back(D2D1_GRADIENT_STOP{
1211
+ stop.offset,
1212
+ D2D1::ColorF(stop.colour[0], stop.colour[1], stop.colour[2], stop.colour[3])});
1213
+ }
1214
+
1215
+ // `D2D1_GAMMA_2_2` interpolates in sRGB rather than in linear light, which
1216
+ // is what CSS says and what the other two hosts do: GSK and Core Graphics
1217
+ // both interpolate the values as given. Linear light would be defensible
1218
+ // and would make the same stylesheet a different picture here.
1219
+ //
1220
+ // Clamped at both ends, which is also CSS: past the last stop the gradient
1221
+ // is that stop's colour, and that is what fills the rest of the image's
1222
+ // rectangle -- the ending shape of a radial gradient is the radius, not the
1223
+ // edge of the box.
1224
+ ComPtr<ID2D1GradientStopCollection> collection;
1225
+ if (FAILED(target->CreateGradientStopCollection(stops.data(),
1226
+ static_cast<UINT32>(stops.size()),
1227
+ D2D1_GAMMA_2_2,
1228
+ D2D1_EXTEND_MODE_CLAMP,
1229
+ collection.GetAddressOf()))
1230
+ || !collection) {
1231
+ continue;
1232
+ }
1233
+
1234
+ ComPtr<ID2D1Brush> brush;
1235
+ if (gradient.kind == Gradient::Kind::Radial) {
1236
+ ComPtr<ID2D1RadialGradientBrush> radial;
1237
+ // The centre and two radii straight from `core/Gradients.h`: Direct2D's
1238
+ // brush takes an ellipse where Core Graphics takes a circle and a
1239
+ // transform, so this is the host that needs no coordinate scaling.
1240
+ const D2D1_RADIAL_GRADIENT_BRUSH_PROPERTIES properties =
1241
+ D2D1::RadialGradientBrushProperties(
1242
+ D2D1::Point2F(gradient.centreX, gradient.centreY),
1243
+ D2D1::Point2F(0.0f, 0.0f),
1244
+ gradient.radiusX,
1245
+ gradient.radiusY);
1246
+ if (SUCCEEDED(target->CreateRadialGradientBrush(
1247
+ properties, collection.Get(), radial.GetAddressOf()))) {
1248
+ brush = radial;
1249
+ }
1250
+ } else {
1251
+ ComPtr<ID2D1LinearGradientBrush> linear;
1252
+ const D2D1_LINEAR_GRADIENT_BRUSH_PROPERTIES properties =
1253
+ D2D1::LinearGradientBrushProperties(
1254
+ D2D1::Point2F(gradient.startX, gradient.startY),
1255
+ D2D1::Point2F(gradient.endX, gradient.endY));
1256
+ if (SUCCEEDED(target->CreateLinearGradientBrush(
1257
+ properties, collection.Get(), linear.GetAddressOf()))) {
1258
+ brush = linear;
1259
+ }
1260
+ }
1261
+ if (!brush) {
1262
+ continue;
1263
+ }
1264
+
1265
+ // Always clipped, radii or not: CSS clips a background to the border box,
1266
+ // and `backgroundSize` and `backgroundPosition` can both put the image
1267
+ // outside it.
1268
+ const ScopedGeometryClip clip(target, box, cornerRadii_);
1269
+
1270
+ const D2D1_RECT_F area = D2D1::RectF(gradient.area[0],
1271
+ gradient.area[1],
1272
+ gradient.area[0] + gradient.area[2],
1273
+ gradient.area[1] + gradient.area[3]);
1274
+ if (!gradient.repeats || gradient.tile[2] <= 0.0f || gradient.tile[3] <= 0.0f) {
1275
+ target->FillRectangle(area, brush.Get());
1276
+ continue;
1277
+ }
1278
+
1279
+ // Tiled. The gradient's own geometry sits where the first tile is, so each
1280
+ // tile moves the *brush* rather than the gradient: one brush, one fill per
1281
+ // tile, which is the shape the AppKit host settled on. GSK has a repeat
1282
+ // node and needs none of this.
1283
+ //
1284
+ // From the first tile that can reach the box, which may be above and left
1285
+ // of it: `backgroundPosition` can put the first tile anywhere, and the ones
1286
+ // before it still cover the box.
1287
+ const float width = gradient.tile[2];
1288
+ const float height = gradient.tile[3];
1289
+ const float firstX =
1290
+ gradient.tile[0] - std::ceil((gradient.tile[0] - box.left) / width) * width;
1291
+ const float firstY =
1292
+ gradient.tile[1] - std::ceil((gradient.tile[1] - box.top) / height) * height;
1293
+ for (float y = firstY; y < box.bottom; y += height) {
1294
+ for (float x = firstX; x < box.right; x += width) {
1295
+ brush->SetTransform(
1296
+ D2D1::Matrix3x2F::Translation(x - gradient.tile[0], y - gradient.tile[1]));
1297
+ // The image's rectangle inside this tile, moved with it: a tile is the
1298
+ // period and the image need not fill it.
1299
+ target->FillRectangle(
1300
+ D2D1::RectF(area.left + (x - gradient.tile[0]),
1301
+ area.top + (y - gradient.tile[1]),
1302
+ area.right + (x - gradient.tile[0]),
1303
+ area.bottom + (y - gradient.tile[1])),
1304
+ brush.Get());
1305
+ }
1306
+ }
1307
+ brush->SetTransform(D2D1::Matrix3x2F::Identity());
1308
+ }
1309
+ }
1310
+
1311
+ void RnWin32View::setBoxShadows(std::vector<BoxShadow> shadows) {
1312
+ boxShadows_ = std::move(shadows);
1313
+ }
1314
+
1315
+ void RnWin32View::paintBoxShadows(ID2D1RenderTarget *target, bool inset) const {
1316
+ if (boxShadows_.empty()) {
1317
+ return;
1318
+ }
1319
+ // Back to front: CSS's first shadow is the one on top, so the list is walked
1320
+ // in reverse and the first one ends up painted last. Both other hosts do the
1321
+ // same with the same list.
1322
+ for (size_t index = boxShadows_.size(); index > 0; index--) {
1323
+ const BoxShadow &shadow = boxShadows_[index - 1];
1324
+ if (shadow.inset != inset) {
1325
+ continue;
1326
+ }
1327
+ // A shadow with no colour at all is one React Native could not parse, and
1328
+ // painting it black is worse than painting nothing. The other two hosts
1329
+ // skip it for the same reason.
1330
+ if (shadow.colour[3] <= 0.0f) {
1331
+ continue;
1332
+ }
1333
+ paintBoxShadow(target, shadow);
1334
+ }
1335
+ }
1336
+
1337
+ // One box shadow: a shape, a blur of that shape's alpha, and a clip.
1338
+ //
1339
+ // GSK has a shadow node per kind whose arguments are CSS's, and AppKit has
1340
+ // CALayer's shadow properties with a path; Direct2D has `CLSID_D2D1Shadow`,
1341
+ // which takes what is drawn and hands back its alpha blurred and coloured. So
1342
+ // the blur is the platform's and the work here is the geometry, which is the
1343
+ // same geometry the AppKit half builds as a CGPath -- and the corner radii grow
1344
+ // through `core/ShadowShape.h`'s curve on both, because a spread is not an
1345
+ // addition.
1346
+ //
1347
+ // **An outset shadow never paints inside the box that casts it and an inset one
1348
+ // never outside it**, which is CSS and is also what keeps a translucent
1349
+ // background from showing the shadow underneath it. That is the clip, and for
1350
+ // the outset case it is a shape no rectangle can express: everything around a
1351
+ // rounded box. See `geometryWithHole`.
1352
+ void RnWin32View::paintBoxShadow(ID2D1RenderTarget *target, const BoxShadow &shadow) const {
1353
+ ComPtr<ID2D1Factory> factory;
1354
+ target->GetFactory(factory.GetAddressOf());
1355
+ if (!factory) {
1356
+ return;
1357
+ }
1358
+
1359
+ // CSS does not allow a negative blur radius and React Native parses one
1360
+ // anyway, so it is clamped here. The spread may be negative and is not: GSK
1361
+ // asserts the same pair, and a sabotage run that swapped the two took that
1362
+ // host's suite down with it.
1363
+ const float blur = shadow.blur > 0.0f ? shadow.blur : 0.0f;
1364
+ // Room around the box for everything the shadow can reach. A gaussian is
1365
+ // truncated at about three standard deviations, which is one and a half
1366
+ // radii; twice the radius is the usual slack and is what the room below is.
1367
+ const float room = std::abs(shadow.dx) + std::abs(shadow.dy)
1368
+ + std::abs(shadow.spread) + 2.0f * blur + 2.0f;
1369
+ const D2D1_RECT_F box = D2D1::RectF(0.0f, 0.0f, frame_.width, frame_.height);
1370
+ const D2D1_RECT_F around =
1371
+ D2D1::RectF(-room, -room, frame_.width + room, frame_.height + room);
1372
+ const float square[8] = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f};
1373
+
1374
+ // The shape that casts the shadow, and the clip that keeps it where CSS puts
1375
+ // it.
1376
+ ComPtr<ID2D1Geometry> caster;
1377
+ ComPtr<ID2D1Geometry> mask;
1378
+ float radii[8];
1379
+ if (!shadow.inset) {
1380
+ for (int corner = 0; corner < 8; corner++) {
1381
+ radii[corner] =
1382
+ static_cast<float>(basalt::spreadRadius(cornerRadii_[corner], shadow.spread));
1383
+ }
1384
+ const D2D1_RECT_F grown = D2D1::RectF(box.left - shadow.spread + shadow.dx,
1385
+ box.top - shadow.spread + shadow.dy,
1386
+ box.right + shadow.spread + shadow.dx,
1387
+ box.bottom + shadow.spread + shadow.dy);
1388
+ caster = roundedBoxGeometry(factory.Get(), grown, radii);
1389
+ mask = geometryWithHole(factory.Get(),
1390
+ roundedBoxGeometry(factory.Get(), around, square).Get(),
1391
+ roundedBoxGeometry(factory.Get(), box, cornerRadii_).Get());
1392
+ } else {
1393
+ // The hole the shadow spills in from: the box moved by the offset and
1394
+ // shrunk by the spread. An inset shadow with no offset is a ring and one
1395
+ // with an offset is a crescent, which falls out of this rather than being
1396
+ // arranged.
1397
+ for (int corner = 0; corner < 8; corner++) {
1398
+ radii[corner] =
1399
+ static_cast<float>(basalt::spreadRadius(cornerRadii_[corner], -shadow.spread));
1400
+ }
1401
+ D2D1_RECT_F hole = D2D1::RectF(box.left + shadow.spread + shadow.dx,
1402
+ box.top + shadow.spread + shadow.dy,
1403
+ box.right - shadow.spread + shadow.dx,
1404
+ box.bottom - shadow.spread + shadow.dy);
1405
+ // A spread wider than the box turns the hole inside out, which would draw
1406
+ // the shape rather than the hole.
1407
+ if (hole.right < hole.left) {
1408
+ hole.right = hole.left;
1409
+ }
1410
+ if (hole.bottom < hole.top) {
1411
+ hole.bottom = hole.top;
1412
+ }
1413
+ caster = geometryWithHole(factory.Get(),
1414
+ roundedBoxGeometry(factory.Get(), around, square).Get(),
1415
+ roundedBoxGeometry(factory.Get(), hole, radii).Get());
1416
+ mask = roundedBoxGeometry(factory.Get(), box, cornerRadii_);
1417
+ }
1418
+ if (!caster) {
1419
+ return;
1420
+ }
1421
+
1422
+ const ScopedGeometryClip clip(mask ? target : nullptr, mask.Get());
1423
+
1424
+ // The blur, which is an effect and so needs a device context and a bitmap to
1425
+ // run over: the shape goes into an offscreen the size of the box plus the
1426
+ // room, and `CLSID_D2D1Shadow` blurs that bitmap's alpha and colours it. The
1427
+ // shape is drawn in opaque black because only its alpha is read.
1428
+ ComPtr<ID2D1DeviceContext> context;
1429
+ if (blur > 0.0f && SUCCEEDED(target->QueryInterface(IID_PPV_ARGS(&context)))) {
1430
+ const ComPtr<ID2D1BitmapRenderTarget> offscreen =
1431
+ createOffscreen(target, frame_.width + 2.0f * room, frame_.height + 2.0f * room);
1432
+ ComPtr<ID2D1SolidColorBrush> ink;
1433
+ if (offscreen
1434
+ && SUCCEEDED(offscreen->CreateSolidColorBrush(D2D1::ColorF(0.0f, 0.0f, 0.0f, 1.0f),
1435
+ ink.GetAddressOf()))) {
1436
+ offscreen->BeginDraw();
1437
+ offscreen->Clear(D2D1::ColorF(0.0f, 0.0f, 0.0f, 0.0f));
1438
+ offscreen->SetTransform(D2D1::Matrix3x2F::Translation(room, room));
1439
+ offscreen->FillGeometry(caster.Get(), ink.Get());
1440
+ offscreen->SetTransform(D2D1::Matrix3x2F::Identity());
1441
+ ComPtr<ID2D1Bitmap> drawn;
1442
+ ComPtr<ID2D1Effect> effect;
1443
+ if (SUCCEEDED(offscreen->EndDraw())
1444
+ && SUCCEEDED(offscreen->GetBitmap(drawn.GetAddressOf())) && drawn
1445
+ && SUCCEEDED(context->CreateEffect(CLSID_D2D1Shadow, effect.GetAddressOf()))
1446
+ && effect) {
1447
+ effect->SetInput(0, drawn.Get());
1448
+ // Half the radius is the standard deviation, which is the conversion
1449
+ // every blur in this host makes and the one React Native's iOS half
1450
+ // settled on.
1451
+ effect->SetValue(D2D1_SHADOW_PROP_BLUR_STANDARD_DEVIATION, blur / 2.0f);
1452
+ const D2D1_VECTOR_4F tint{
1453
+ shadow.colour[0], shadow.colour[1], shadow.colour[2], shadow.colour[3]};
1454
+ effect->SetValue(D2D1_SHADOW_PROP_COLOR, tint);
1455
+ // Back where the shape was: it was drawn `room` in from the bitmap's
1456
+ // own origin so the blur had somewhere to go.
1457
+ context->DrawImage(effect.Get(), D2D1::Point2F(-room, -room));
1458
+ return;
1459
+ }
1460
+ }
1461
+ }
1462
+
1463
+ // No blur asked for, or no effect to be had: the shape filled in the
1464
+ // shadow's colour. For a blur of zero that is the whole picture, and
1465
+ // otherwise it is the same fallback the text shadow takes -- a hard shadow
1466
+ // rather than none, since a prop that cannot be honoured should not take the
1467
+ // view with it.
1468
+ ComPtr<ID2D1SolidColorBrush> brush;
1469
+ const D2D1_COLOR_F colour =
1470
+ D2D1::ColorF(shadow.colour[0], shadow.colour[1], shadow.colour[2], shadow.colour[3]);
1471
+ if (SUCCEEDED(target->CreateSolidColorBrush(colour, brush.GetAddressOf()))) {
1472
+ target->FillGeometry(caster.Get(), brush.Get());
1473
+ }
1474
+ }
1475
+
1476
+ // CSS's outline, outside the box and over everything.
1477
+ //
1478
+ // **Nothing clips it, and that is the decision rather than an oversight.** CSS
1479
+ // does not clip an element's own outline for `overflow: hidden`, so a ring
1480
+ // drawn inside the clip would vanish on exactly the views that most often carry
1481
+ // one. Here that is free: `paintChildren` scopes its own clip, so by the time
1482
+ // this runs the clip is already popped. GTK gets it the same way, by appending
1483
+ // after the children; AppKit has to move the ring into the parent's layer.
1484
+ //
1485
+ // Stroked rather than filled like the border, for the two reasons
1486
+ // backlog/correctness.md records: a stroke takes a dash pattern, so dotted and
1487
+ // dashed need no second mechanism, and it takes an arbitrary path, so
1488
+ // elliptical radii need no special case. A stroke straddles its path, so the
1489
+ // path is the ring's centre line: offset plus half the width out from the
1490
+ // border edge, which is where the other two hosts put theirs.
1491
+ void RnWin32View::paintOutline(ID2D1RenderTarget *target) const {
1492
+ if (outlineWidth_ <= 0.0f || outlineColour_[3] <= 0.0f) {
1493
+ return;
1494
+ }
1495
+
1496
+ ComPtr<ID2D1Factory> factory;
1497
+ target->GetFactory(factory.GetAddressOf());
1498
+ if (!factory) {
1499
+ return;
1500
+ }
1501
+
1502
+ // The ring's outer edge is the box grown by the offset plus the width; its
1503
+ // centre line is half a width back from that.
1504
+ const float centre = outlineOffset_ + outlineWidth_ / 2.0f;
1505
+ const D2D1_RECT_F rect = D2D1::RectF(
1506
+ -centre, -centre, frame_.width + centre, frame_.height + centre);
1507
+
1508
+ // Each non-zero radius grows by what the ring moved out, so the ring stays
1509
+ // concentric with a rounded card; a corner that was square stays square,
1510
+ // which is what React Native's iOS half does and what both other hosts do.
1511
+ float radii[8];
1512
+ for (int index = 0; index < 8; index++) {
1513
+ radii[index] = cornerRadii_[index] > 0.0f ? cornerRadii_[index] + centre : 0.0f;
1514
+ }
1515
+
1516
+ const ComPtr<ID2D1Geometry> ring = roundedBoxGeometry(factory.Get(), rect, radii);
1517
+ if (!ring) {
1518
+ return;
1519
+ }
1520
+
1521
+ ComPtr<ID2D1SolidColorBrush> brush;
1522
+ const D2D1_COLOR_F colour = D2D1::ColorF(
1523
+ outlineColour_[0], outlineColour_[1], outlineColour_[2], outlineColour_[3]);
1524
+ if (FAILED(target->CreateSolidColorBrush(colour, brush.GetAddressOf()))) {
1525
+ return;
1526
+ }
1527
+
1528
+ const ComPtr<ID2D1StrokeStyle> dashed = dashStyleFor(factory.Get(), outlineStyle_);
1529
+ target->DrawGeometry(ring.Get(), brush.Get(), outlineWidth_, dashed.Get());
1530
+ }
1531
+
1532
+ // A dotted or dashed border: one stroked path around the rounded box rather
1533
+ // than four filled edges.
1534
+ //
1535
+ // Which is why the style belongs to the whole border and not to a side, the
1536
+ // same decision both other hosts made: a stroked path carries one dash pattern.
1537
+ // The width and the colour are the first side's, for the same reason.
1538
+ //
1539
+ // Inset by half the width, because a stroke straddles its path where the filled
1540
+ // ring sits inside the box. Without that a 4pt dashed border would paint two
1541
+ // points outside the view and overlap its neighbour.
1542
+ void RnWin32View::paintStrokedBorder(ID2D1RenderTarget *target) const {
1543
+ ComPtr<ID2D1Factory> factory;
1544
+ target->GetFactory(factory.GetAddressOf());
1545
+ if (!factory) {
1546
+ return;
1547
+ }
1548
+
1549
+ const float width = borderWidths_[0] > 0.0f ? borderWidths_[0] : 1.0f;
1550
+ const D2D1_RECT_F rect = D2D1::RectF(width / 2.0f,
1551
+ width / 2.0f,
1552
+ (std::max)(width / 2.0f, frame_.width - width / 2.0f),
1553
+ (std::max)(width / 2.0f, frame_.height - width / 2.0f));
1554
+ // The radii shrink with the path, so a dashed border on a rounded card keeps
1555
+ // the card's corner rather than cutting across it.
1556
+ float radii[8];
1557
+ for (int index = 0; index < 8; index++) {
1558
+ radii[index] = cornerRadii_[index] > width / 2.0f ? cornerRadii_[index] - width / 2.0f : 0.0f;
1559
+ }
1560
+
1561
+ const ComPtr<ID2D1Geometry> path = roundedBoxGeometry(factory.Get(), rect, radii);
1562
+ if (!path) {
1563
+ return;
1564
+ }
1565
+
1566
+ ComPtr<ID2D1SolidColorBrush> brush;
1567
+ const D2D1_COLOR_F colour = D2D1::ColorF(
1568
+ borderColours_[0], borderColours_[1], borderColours_[2], borderColours_[3]);
1569
+ if (FAILED(target->CreateSolidColorBrush(colour, brush.GetAddressOf()))) {
1570
+ return;
1571
+ }
1572
+
1573
+ const ComPtr<ID2D1StrokeStyle> dashed = dashStyleFor(factory.Get(), borderStyle_);
1574
+ target->DrawGeometry(path.Get(), brush.Get(), width, dashed.Get());
1575
+ }
1576
+
1577
+ void RnWin32View::paintBorders(ID2D1RenderTarget *target) const {
1578
+ // Dotted and dashed are a different drawing entirely; see paintStrokedBorder.
1579
+ if (borderStyle_ != LineStyle::Solid) {
1580
+ paintStrokedBorder(target);
1581
+ return;
1582
+ }
1583
+
1584
+ ComPtr<ID2D1Factory> factory;
1585
+ target->GetFactory(factory.GetAddressOf());
1586
+ if (!factory) {
1587
+ return;
1588
+ }
1589
+
1590
+ const float width = frame_.width;
1591
+ const float height = frame_.height;
1592
+ const float top = borderWidths_[0];
1593
+ const float right = borderWidths_[1];
1594
+ const float bottom = borderWidths_[2];
1595
+ const float left = borderWidths_[3];
1596
+
1597
+ const auto floor0 = [](float value) { return (std::max)(0.0f, value); };
1598
+ const D2D1_RECT_F outerRect = D2D1::RectF(0.0f, 0.0f, width, height);
1599
+ const D2D1_RECT_F innerRect = D2D1::RectF(
1600
+ left, top, (std::max)(left, width - right), (std::max)(top, height - bottom));
1601
+ const float innerRadii[8] = {
1602
+ floor0(cornerRadii_[0] - left),
1603
+ floor0(cornerRadii_[1] - top),
1604
+ floor0(cornerRadii_[2] - right),
1605
+ floor0(cornerRadii_[3] - top),
1606
+ floor0(cornerRadii_[4] - right),
1607
+ floor0(cornerRadii_[5] - bottom),
1608
+ floor0(cornerRadii_[6] - left),
1609
+ floor0(cornerRadii_[7] - bottom),
1610
+ };
1611
+
1612
+ const ComPtr<ID2D1Geometry> outer = roundedBoxGeometry(factory.Get(), outerRect, cornerRadii_);
1613
+ const ComPtr<ID2D1Geometry> inner = roundedBoxGeometry(factory.Get(), innerRect, innerRadii);
1614
+ if (!outer || !inner) {
1615
+ return;
1616
+ }
1617
+
1618
+ ComPtr<ID2D1PathGeometry> ring;
1619
+ ComPtr<ID2D1GeometrySink> sink;
1620
+ if (FAILED(factory->CreatePathGeometry(ring.GetAddressOf())) ||
1621
+ FAILED(ring->Open(sink.GetAddressOf())) ||
1622
+ FAILED(outer->CombineWithGeometry(
1623
+ inner.Get(), D2D1_COMBINE_MODE_EXCLUDE, nullptr, sink.Get())) ||
1624
+ FAILED(sink->Close())) {
1625
+ return;
1626
+ }
1627
+
1628
+ const auto colourOf = [this](int edge) {
1629
+ const float *c = &borderColours_[edge * 4];
1630
+ return D2D1::ColorF(c[0], c[1], c[2], c[3]);
1631
+ };
1632
+
1633
+ bool oneColour = true;
1634
+ for (int edge = 1; edge < 4; edge++) {
1635
+ for (int c = 0; c < 4; c++) {
1636
+ if (borderColours_[edge * 4 + c] != borderColours_[c]) {
1637
+ oneColour = false;
1638
+ }
1639
+ }
1640
+ }
1641
+
1642
+ if (oneColour) {
1643
+ ComPtr<ID2D1SolidColorBrush> brush;
1644
+ if (SUCCEEDED(target->CreateSolidColorBrush(colourOf(0), brush.GetAddressOf()))) {
1645
+ target->FillGeometry(ring.Get(), brush.Get());
1646
+ }
1647
+ return;
1648
+ }
1649
+
1650
+ ComPtr<ID2D1Layer> layer;
1651
+ if (FAILED(target->CreateLayer(nullptr, layer.GetAddressOf()))) {
1652
+ return;
1653
+ }
1654
+ auto parameters = D2D1::LayerParameters();
1655
+ parameters.contentBounds = D2D1::InfiniteRect();
1656
+ parameters.geometricMask = ring.Get();
1657
+ target->PushLayer(parameters, layer.Get());
1658
+
1659
+ const D2D1_ANTIALIAS_MODE previous = target->GetAntialiasMode();
1660
+ target->SetAntialiasMode(D2D1_ANTIALIAS_MODE_ALIASED);
1661
+
1662
+ // Each side, from its two outer corners to the matching inner ones. Top,
1663
+ // right, bottom, left, as the widths and colours are kept.
1664
+ const D2D1_POINT_2F sides[4][4] = {
1665
+ {{0.0f, 0.0f}, {width, 0.0f}, {width - right, top}, {left, top}},
1666
+ {{width, 0.0f}, {width, height}, {width - right, height - bottom}, {width - right, top}},
1667
+ {{width, height}, {0.0f, height}, {left, height - bottom}, {width - right, height - bottom}},
1668
+ {{0.0f, height}, {0.0f, 0.0f}, {left, top}, {left, height - bottom}},
1669
+ };
1670
+ for (int edge = 0; edge < 4; edge++) {
1671
+ if (borderWidths_[edge] <= 0.0f || borderColours_[edge * 4 + 3] <= 0.0f) {
1672
+ continue;
1673
+ }
1674
+ ComPtr<ID2D1PathGeometry> side;
1675
+ ComPtr<ID2D1GeometrySink> sideSink;
1676
+ ComPtr<ID2D1SolidColorBrush> brush;
1677
+ if (FAILED(factory->CreatePathGeometry(side.GetAddressOf())) ||
1678
+ FAILED(side->Open(sideSink.GetAddressOf())) ||
1679
+ FAILED(target->CreateSolidColorBrush(colourOf(edge), brush.GetAddressOf()))) {
1680
+ continue;
1681
+ }
1682
+ sideSink->BeginFigure(sides[edge][0], D2D1_FIGURE_BEGIN_FILLED);
1683
+ sideSink->AddLines(&sides[edge][1], 3);
1684
+ sideSink->EndFigure(D2D1_FIGURE_END_CLOSED);
1685
+ if (SUCCEEDED(sideSink->Close())) {
1686
+ target->FillGeometry(side.Get(), brush.Get());
1687
+ }
1688
+ }
1689
+
1690
+ target->SetAntialiasMode(previous);
1691
+ target->PopLayer();
1692
+ }
1693
+
1694
+ // Reads the current transform rather than being handed one, which is what keeps
1695
+ // D2D1_MATRIX_3X2_F -- and windows.h behind it -- out of the header.
1696
+
1697
+ // ---------------------------------------------------------------------------
1698
+ // Controls
1699
+ // ---------------------------------------------------------------------------
1700
+
1701
+ namespace {
1702
+
1703
+ // Where a spinner is in its turn, 0..1, from a monotonic clock.
1704
+ //
1705
+ // A clock rather than per-view state: every spinner on screen turns together,
1706
+ // which is what a real toolkit spinner does, and it means a view needs no
1707
+ // animation bookkeeping of its own. One turn a second, which is roughly what
1708
+ // GtkSpinner and NSProgressIndicator do.
1709
+ //
1710
+ // Here rather than in core/DesktopControls.h because this target deliberately
1711
+ // does not link React Native, and that header does.
1712
+ float spinnerTurn() {
1713
+ using namespace std::chrono;
1714
+ const double seconds = duration<double>(steady_clock::now().time_since_epoch()).count();
1715
+ return static_cast<float>(seconds - std::floor(seconds));
1716
+ }
1717
+
1718
+ D2D1_COLOR_F colourOr(const float rgba[4], bool has, D2D1_COLOR_F fallback) {
1719
+ return has ? D2D1::ColorF(rgba[0], rgba[1], rgba[2], rgba[3]) : fallback;
1720
+ }
1721
+
1722
+ // The greys the rest of this host draws controls in. Windows' own accent colour
1723
+ // would be more native and is a per-user setting read through UISettings, which
1724
+ // is a WinRT dependency this package does not have; React Native's own default
1725
+ // switch is not accent-coloured either.
1726
+ //
1727
+ // Which is why the on colour is *green*, React Native's own, where GTK and
1728
+ // AppKit take the desktop's accent and draw a blue one. It was called
1729
+ // `kTrackOnBlue` until 2026-10-10, when a scenario measuring "is this switch
1730
+ // accent-coloured" measured blueness, passed on two hosts and failed here --
1731
+ // the name having been the only blue thing about it.
1732
+ constexpr float kTrackOffGrey = 0.78f;
1733
+ constexpr float kTrackOnGreen[3] = {0.20f, 0.60f, 0.35f};
1734
+ constexpr float kSpinnerGrey = 0.45f;
1735
+
1736
+ } // namespace
1737
+
1738
+ void RnWin32View::setControl(Control kind, const ControlStyle &style, std::string description) {
1739
+ control_ = kind;
1740
+ controlStyle_ = style;
1741
+ controlDescription_ = std::move(description);
1742
+ }
1743
+
1744
+ bool RnWin32View::hasAnimatingSpinner() const {
1745
+ if (control_ == Control::Spinner && controlStyle_.on) {
1746
+ return true;
1747
+ }
1748
+ if (hidden_) {
1749
+ return false;
1750
+ }
1751
+ for (const RnWin32View *child : children_) {
1752
+ if (child != nullptr && child->hasAnimatingSpinner()) {
1753
+ return true;
1754
+ }
1755
+ }
1756
+ return false;
1757
+ }
1758
+
1759
+ void RnWin32View::setHighlights(std::vector<Highlight> highlights) {
1760
+ highlights_ = std::move(highlights);
1761
+ }
1762
+
1763
+ void RnWin32View::paintHighlights(ID2D1RenderTarget *target) const {
1764
+ ComPtr<ID2D1SolidColorBrush> brush;
1765
+ if (FAILED(target->CreateSolidColorBrush(D2D1::ColorF(0, 0, 0, 1.0f), brush.GetAddressOf()))) {
1766
+ return;
1767
+ }
1768
+ for (const Highlight &highlight : highlights_) {
1769
+ const D2D1_RECT_F area = D2D1::RectF(highlight.x,
1770
+ highlight.y,
1771
+ highlight.x + highlight.width,
1772
+ highlight.y + highlight.height);
1773
+ if (highlight.filled) {
1774
+ brush->SetColor(
1775
+ D2D1::ColorF(highlight.color[0], highlight.color[1], highlight.color[2]));
1776
+ brush->SetOpacity(highlight.color[3]);
1777
+ target->FillRectangle(area, brush.Get());
1778
+ }
1779
+ // Opaque on the outline even when the fill is not, so the edge of an
1780
+ // inspected element is a line rather than a suggestion. Stroked down the
1781
+ // middle, so the rectangle is inset by half the width to keep it inside.
1782
+ brush->SetColor(D2D1::ColorF(highlight.color[0], highlight.color[1], highlight.color[2]));
1783
+ brush->SetOpacity(1.0f);
1784
+ const float inset = basalt::kHighlightBorderWidth / 2.0f;
1785
+ target->DrawRectangle(
1786
+ D2D1::RectF(area.left + inset, area.top + inset, area.right - inset, area.bottom - inset),
1787
+ brush.Get(),
1788
+ basalt::kHighlightBorderWidth);
1789
+ }
1790
+ }
1791
+
1792
+ void RnWin32View::paintControl(ID2D1RenderTarget *target) const {
1793
+ if (control_ == Control::Spinner) {
1794
+ // A stopped indicator draws nothing at all, which is what
1795
+ // `hidesWhenStopped` means and what a <RefreshControl> that is not
1796
+ // refreshing does.
1797
+ if (!controlStyle_.on && controlStyle_.hidesWhenStopped) {
1798
+ return;
1799
+ }
1800
+
1801
+ // Eight dots around a circle, the leading one opaque and the rest fading
1802
+ // behind it. The same shape GtkSpinner and NSProgressIndicator draw, which
1803
+ // matters because these three hosts are screenshotted side by side.
1804
+ const float diameter = std::min(
1805
+ {controlStyle_.large ? basalt::kSpinnerLarge : basalt::kSpinnerSmall,
1806
+ frame_.width,
1807
+ frame_.height});
1808
+ if (diameter <= 0.0f) {
1809
+ return;
1810
+ }
1811
+ const float centreX = frame_.width / 2.0f;
1812
+ const float centreY = frame_.height / 2.0f;
1813
+ const float radius = diameter / 2.0f;
1814
+ const float dotRadius = std::max(radius / 6.0f, 1.0f);
1815
+
1816
+ ComPtr<ID2D1SolidColorBrush> brush;
1817
+ const D2D1_COLOR_F base = colourOr(controlStyle_.thumb,
1818
+ controlStyle_.hasThumb,
1819
+ D2D1::ColorF(kSpinnerGrey, kSpinnerGrey, kSpinnerGrey, 1.0f));
1820
+ if (FAILED(target->CreateSolidColorBrush(base, brush.GetAddressOf()))) {
1821
+ return;
1822
+ }
1823
+
1824
+ constexpr int kDots = 8;
1825
+ // A stopped indicator that is still shown is drawn still, at phase zero,
1826
+ // rather than at whatever the clock happens to say -- so a screenshot of a
1827
+ // stopped spinner is the same picture every time.
1828
+ const float turn = controlStyle_.on ? spinnerTurn() : 0.0f;
1829
+ const int leading = static_cast<int>(turn * kDots) % kDots;
1830
+ for (int i = 0; i < kDots; i++) {
1831
+ const float angle = 6.2831853f * static_cast<float>(i) / static_cast<float>(kDots);
1832
+ const int behind = (leading - i + kDots) % kDots;
1833
+ brush->SetOpacity(1.0f - static_cast<float>(behind) / static_cast<float>(kDots));
1834
+ const D2D1_ELLIPSE dot = D2D1::Ellipse(
1835
+ D2D1::Point2F(centreX + std::cos(angle) * (radius - dotRadius),
1836
+ centreY + std::sin(angle) * (radius - dotRadius)),
1837
+ dotRadius,
1838
+ dotRadius);
1839
+ target->FillEllipse(dot, brush.Get());
1840
+ }
1841
+ return;
1842
+ }
1843
+
1844
+ // A switch: a rounded track with a circular thumb at one end. Sized from
1845
+ // core/ControlMetrics.h -- the same numbers the shadow node measures to --
1846
+ // and centred, so a <Switch> given a bigger box keeps its shape rather than
1847
+ // stretching.
1848
+ const float width = std::min(basalt::kSwitchWidth, frame_.width);
1849
+ const float height = std::min(basalt::kSwitchHeight, frame_.height);
1850
+ if (width <= 0.0f || height <= 0.0f) {
1851
+ return;
1852
+ }
1853
+ const float left = (frame_.width - width) / 2.0f;
1854
+ const float top = (frame_.height - height) / 2.0f;
1855
+
1856
+ const D2D1_COLOR_F trackColour = controlStyle_.on
1857
+ ? colourOr(controlStyle_.trackOn,
1858
+ controlStyle_.hasTrackOn,
1859
+ D2D1::ColorF(kTrackOnGreen[0], kTrackOnGreen[1], kTrackOnGreen[2], 1.0f))
1860
+ : colourOr(controlStyle_.trackOff,
1861
+ controlStyle_.hasTrackOff,
1862
+ D2D1::ColorF(kTrackOffGrey, kTrackOffGrey, kTrackOffGrey, 1.0f));
1863
+
1864
+ ComPtr<ID2D1SolidColorBrush> brush;
1865
+ if (FAILED(target->CreateSolidColorBrush(trackColour, brush.GetAddressOf()))) {
1866
+ return;
1867
+ }
1868
+ // Half opacity for a disabled switch, which is what every platform does and
1869
+ // is the only thing `disabled` can mean to something that draws itself.
1870
+ brush->SetOpacity(controlStyle_.disabled ? 0.4f : 1.0f);
1871
+ const float radius = height / 2.0f;
1872
+ target->FillRoundedRectangle(
1873
+ D2D1::RoundedRect(D2D1::RectF(left, top, left + width, top + height), radius, radius),
1874
+ brush.Get());
1875
+
1876
+ const float inset = 2.0f;
1877
+ const float thumbRadius = radius - inset;
1878
+ const float thumbX = controlStyle_.on ? left + width - radius : left + radius;
1879
+ ComPtr<ID2D1SolidColorBrush> thumbBrush;
1880
+ const D2D1_COLOR_F thumbColour =
1881
+ colourOr(controlStyle_.thumb, controlStyle_.hasThumb, D2D1::ColorF(1.0f, 1.0f, 1.0f, 1.0f));
1882
+ if (FAILED(target->CreateSolidColorBrush(thumbColour, thumbBrush.GetAddressOf()))) {
1883
+ return;
1884
+ }
1885
+ thumbBrush->SetOpacity(controlStyle_.disabled ? 0.4f : 1.0f);
1886
+ target->FillEllipse(D2D1::Ellipse(D2D1::Point2F(thumbX, top + radius), thumbRadius, thumbRadius),
1887
+ thumbBrush.Get());
1888
+ }
1889
+
1890
+ // Always drawn, rather than faded in while scrolling and out after: there is no
1891
+ // timer here and no animation, which is why `flashScrollIndicators` stays a
1892
+ // no-op -- there is nothing to flash something already on screen.
1893
+ void RnWin32View::paintScrollIndicators(ID2D1RenderTarget *target) const {
1894
+ const float thickness = static_cast<float>(basalt::kScrollIndicatorThickness);
1895
+ const float inset = static_cast<float>(basalt::kScrollIndicatorInset);
1896
+
1897
+ ComPtr<ID2D1SolidColorBrush> brush;
1898
+ // Neutral and translucent, so it reads over light and dark content alike, and
1899
+ // white instead when `indicatorStyle` asked for it. Core decides which, so
1900
+ // the GTK and AppKit hosts answer the prop with the same colour.
1901
+ if (FAILED(target->CreateSolidColorBrush(D2D1::ColorF(indicatorColour_[0],
1902
+ indicatorColour_[1],
1903
+ indicatorColour_[2],
1904
+ indicatorColour_[3]),
1905
+ brush.GetAddressOf()))) {
1906
+ return;
1907
+ }
1908
+
1909
+ // The hairline around the thumb, which `default` has and the other two do
1910
+ // not. A stroke straddles its own rectangle, so the rounded rect is inset by
1911
+ // half the width and the line lands on the thumb rather than half outside
1912
+ // it -- the same arithmetic the AppKit host uses.
1913
+ ComPtr<ID2D1SolidColorBrush> edgeBrush;
1914
+ const float edgeWidth = static_cast<float>(basalt::kScrollIndicatorBorderWidth);
1915
+ const bool hasEdge = indicatorBorderColour_[3] > 0.0f
1916
+ && SUCCEEDED(target->CreateSolidColorBrush(D2D1::ColorF(indicatorBorderColour_[0],
1917
+ indicatorBorderColour_[1],
1918
+ indicatorBorderColour_[2],
1919
+ indicatorBorderColour_[3]),
1920
+ edgeBrush.GetAddressOf()));
1921
+
1922
+ const float radius = thickness / 2.0f;
1923
+ const float edgeRadius = radius - edgeWidth / 2.0f > 0.0f ? radius - edgeWidth / 2.0f : 0.0f;
1924
+ if (indicatorVerticalLength_ > 0.0f) {
1925
+ const float left = frame_.width - thickness - inset;
1926
+ const D2D1_RECT_F bar = D2D1::RectF(left,
1927
+ indicatorVerticalOffset_,
1928
+ left + thickness,
1929
+ indicatorVerticalOffset_ + indicatorVerticalLength_);
1930
+ target->FillRoundedRectangle(D2D1::RoundedRect(bar, radius, radius), brush.Get());
1931
+ if (hasEdge) {
1932
+ const D2D1_RECT_F inner = D2D1::RectF(bar.left + edgeWidth / 2.0f,
1933
+ bar.top + edgeWidth / 2.0f,
1934
+ bar.right - edgeWidth / 2.0f,
1935
+ bar.bottom - edgeWidth / 2.0f);
1936
+ target->DrawRoundedRectangle(
1937
+ D2D1::RoundedRect(inner, edgeRadius, edgeRadius), edgeBrush.Get(), edgeWidth);
1938
+ }
1939
+ }
1940
+ if (indicatorHorizontalLength_ > 0.0f) {
1941
+ const float top = frame_.height - thickness - inset;
1942
+ const D2D1_RECT_F bar = D2D1::RectF(indicatorHorizontalOffset_,
1943
+ top,
1944
+ indicatorHorizontalOffset_ + indicatorHorizontalLength_,
1945
+ top + thickness);
1946
+ target->FillRoundedRectangle(D2D1::RoundedRect(bar, radius, radius), brush.Get());
1947
+ if (hasEdge) {
1948
+ const D2D1_RECT_F inner = D2D1::RectF(bar.left + edgeWidth / 2.0f,
1949
+ bar.top + edgeWidth / 2.0f,
1950
+ bar.right - edgeWidth / 2.0f,
1951
+ bar.bottom - edgeWidth / 2.0f);
1952
+ target->DrawRoundedRectangle(
1953
+ D2D1::RoundedRect(inner, edgeRadius, edgeRadius), edgeBrush.Get(), edgeWidth);
1954
+ }
1955
+ }
1956
+ }
1957
+
1958
+ namespace {
1959
+
1960
+ // CSS's blend keyword as a Direct2D blend mode, or nothing for a keyword this
1961
+ // host cannot blend.
1962
+ //
1963
+ // The keyword is what crosses the seam -- see core/BlendModes.h -- so this is
1964
+ // the one place that turns it into Direct2D's vocabulary, as the GTK host turns
1965
+ // it into a GskBlendMode and AppKit into a Core Image filter. Sixteen of the
1966
+ // seventeen are the same word in both lists, which is why they are written out
1967
+ // rather than mapped by position: the enum and CSS's list are in different
1968
+ // orders, and a table by position reports a neighbour's blend.
1969
+ //
1970
+ // **`plus-lighter` is `LINEAR_DODGE`**, which is the choice AppKit made with
1971
+ // `CILinearDodgeBlendMode` and for the same reason: the keyword means clamped
1972
+ // addition and linear dodge is addition. That makes this the only one of the
1973
+ // three hosts with all seventeen; GSK has no node for it and paints unblended.
1974
+ std::optional<D2D1_BLEND_MODE> blendModeFor(const std::string &keyword) {
1975
+ if (keyword.empty() || keyword == "normal") {
1976
+ return std::nullopt;
1977
+ }
1978
+ if (keyword == "multiply") {
1979
+ return D2D1_BLEND_MODE_MULTIPLY;
1980
+ }
1981
+ if (keyword == "screen") {
1982
+ return D2D1_BLEND_MODE_SCREEN;
1983
+ }
1984
+ if (keyword == "overlay") {
1985
+ return D2D1_BLEND_MODE_OVERLAY;
1986
+ }
1987
+ if (keyword == "darken") {
1988
+ return D2D1_BLEND_MODE_DARKEN;
1989
+ }
1990
+ if (keyword == "lighten") {
1991
+ return D2D1_BLEND_MODE_LIGHTEN;
1992
+ }
1993
+ if (keyword == "color-dodge") {
1994
+ return D2D1_BLEND_MODE_COLOR_DODGE;
1995
+ }
1996
+ if (keyword == "color-burn") {
1997
+ return D2D1_BLEND_MODE_COLOR_BURN;
1998
+ }
1999
+ if (keyword == "hard-light") {
2000
+ return D2D1_BLEND_MODE_HARD_LIGHT;
2001
+ }
2002
+ if (keyword == "soft-light") {
2003
+ return D2D1_BLEND_MODE_SOFT_LIGHT;
2004
+ }
2005
+ if (keyword == "difference") {
2006
+ return D2D1_BLEND_MODE_DIFFERENCE;
2007
+ }
2008
+ if (keyword == "exclusion") {
2009
+ return D2D1_BLEND_MODE_EXCLUSION;
2010
+ }
2011
+ if (keyword == "hue") {
2012
+ return D2D1_BLEND_MODE_HUE;
2013
+ }
2014
+ if (keyword == "saturation") {
2015
+ return D2D1_BLEND_MODE_SATURATION;
2016
+ }
2017
+ if (keyword == "color") {
2018
+ return D2D1_BLEND_MODE_COLOR;
2019
+ }
2020
+ if (keyword == "luminosity") {
2021
+ return D2D1_BLEND_MODE_LUMINOSITY;
2022
+ }
2023
+ if (keyword == "plus-lighter") {
2024
+ return D2D1_BLEND_MODE_LINEAR_DODGE;
2025
+ }
2026
+ return std::nullopt;
2027
+ }
2028
+
2029
+ } // namespace
2030
+
2031
+ void RnWin32View::setBlendMode(const char *name) {
2032
+ blendMode_ = name != nullptr ? name : "";
2033
+ // `normal` is "no blending", which is also what no keyword at all means. Held
2034
+ // as nothing so that the dump says nothing: `core/BlendModes.h` answers null
2035
+ // for it, so this is only reachable from a direct call, and the three hosts'
2036
+ // lines have to agree either way.
2037
+ if (blendMode_ == "normal") {
2038
+ blendMode_.clear();
2039
+ }
2040
+ blends_ = blendModeFor(blendMode_).has_value();
2041
+ }
2042
+
2043
+ void RnWin32View::paintChildren(ID2D1RenderTarget *target,
2044
+ ID2D1BitmapRenderTarget *blendLayer) const {
2045
+ if (children_.empty()) {
2046
+ return;
2047
+ }
2048
+
2049
+ D2D1::Matrix3x2F worldTransform;
2050
+ target->GetTransform(&worldTransform);
2051
+
2052
+ // `overflow: hidden`, and only that: the background above is clipped whether
2053
+ // or not this is set.
2054
+ //
2055
+ // **With a blended child it cannot be one clip around the whole walk.** A
2056
+ // clip here is a layer with a geometric mask -- see Win32Clip.h -- and a
2057
+ // pushed layer holds everything drawn inside it in an intermediate surface,
2058
+ // where the bitmap a blend reads as its backdrop cannot see it. So each child
2059
+ // gets its own clip instead, which paints the same picture: clipping a group
2060
+ // and clipping each of its members to the same box are the same thing. The
2061
+ // GTK half had to do this too, for a different reason -- there the pops that
2062
+ // close each blend happen between the children.
2063
+ const D2D1_RECT_F bounds = D2D1::RectF(0.0f, 0.0f, frame_.width, frame_.height);
2064
+ const bool clipsEachChild = blendLayer != nullptr && clipsChildren_;
2065
+ const ScopedGeometryClip clip(
2066
+ clipsChildren_ && !clipsEachChild ? target : nullptr, bounds, cornerRadii_);
2067
+
2068
+ // A ScrollView's offset moves its children and nothing else, so it belongs
2069
+ // between this view's transform and theirs.
2070
+ //
2071
+ // A paragraph's vertical alignment moves the views inline in it for the same
2072
+ // reason and in the same place: see textVerticalOffset.
2073
+ const float inlineOffset = textVerticalOffset();
2074
+ const bool movesChildren =
2075
+ scrollX_ != 0.0f || scrollY_ != 0.0f || inlineOffset != 0.0f;
2076
+ const D2D1::Matrix3x2F childTransform = movesChildren
2077
+ ? D2D1::Matrix3x2F::Translation(-scrollX_, -scrollY_ + inlineOffset) * worldTransform
2078
+ : worldTransform;
2079
+ if (movesChildren) {
2080
+ target->SetTransform(childTransform);
2081
+ }
2082
+
2083
+ // Forwards, so the last child painted is on top. Hit testing walks the same
2084
+ // list backwards.
2085
+ //
2086
+ // In paint order rather than insertion order, which matters twice over for a
2087
+ // blend: zIndex can differ from the list, and a blend's backdrop is what was
2088
+ // painted beneath it. The GTK half has to sort the blended children for the
2089
+ // same reason; here the order is simply the order they are blended in.
2090
+ for (const RnWin32View *child : childrenInPaintOrder()) {
2091
+ if (child == nullptr) {
2092
+ continue;
2093
+ }
2094
+ if (blendLayer != nullptr && child->blends() && !child->hidden_) {
2095
+ blendChildIntoLayer(blendLayer, child);
2096
+ continue;
2097
+ }
2098
+ if (clipsEachChild) {
2099
+ // The clip is in this view's own coordinates, so it is pushed with the
2100
+ // scroll offset off and the offset put back for the child itself.
2101
+ target->SetTransform(worldTransform);
2102
+ const ScopedGeometryClip own(target, bounds, cornerRadii_);
2103
+ target->SetTransform(childTransform);
2104
+ child->paint(target);
2105
+ continue;
2106
+ }
2107
+ child->paint(target);
2108
+ }
2109
+ }
2110
+
2111
+ bool RnWin32View::hasBlendedChild() const {
2112
+ for (const RnWin32View *child : children_) {
2113
+ if (child != nullptr && child->blends() && !child->hidden_) {
2114
+ return true;
2115
+ }
2116
+ }
2117
+ return false;
2118
+ }
2119
+
2120
+ // This view's subtree into an offscreen bitmap, so that a blended child has a
2121
+ // backdrop to read, and then that bitmap onto the target.
2122
+ //
2123
+ // The backdrop is the one thing a blend needs and the one thing a view cannot
2124
+ // see: `CLSID_D2D1Blend` takes two images, and what is beneath a child is
2125
+ // whatever its parent painted before it. On GTK the parent pushes a blend node
2126
+ // per blended child before it paints anything; here the parent paints into a
2127
+ // bitmap and each blended child reads it. Same arrangement, different compositor.
2128
+ //
2129
+ // **The backdrop stops at this view**, which is the deviation CSS would not
2130
+ // make and backlog/correctness.md records for GTK as well: CSS blends with the
2131
+ // backdrop of the nearest stacking context, which for a plain <View> reaches
2132
+ // past its parent. What is beneath this view is on the window and cannot be
2133
+ // read back without copying the window; what is inside it is this bitmap. The
2134
+ // AppKit host does blend with the whole layer tree, because Core Animation
2135
+ // composites it, so the two differ and the end-to-end scenario is deliberately
2136
+ // an arrangement where they agree.
2137
+ void RnWin32View::paintBlendLayer(ID2D1RenderTarget *target) const {
2138
+ const ComPtr<ID2D1BitmapRenderTarget> layer =
2139
+ createOffscreen(target, frame_.width, frame_.height);
2140
+ if (!layer) {
2141
+ // No offscreen, no backdrop: the children paint unblended, which is the
2142
+ // same fallback a keyword this host cannot blend takes.
2143
+ paintContents(target);
2144
+ return;
2145
+ }
2146
+
2147
+ layer->BeginDraw();
2148
+ layer->Clear(D2D1::ColorF(0.0f, 0.0f, 0.0f, 0.0f));
2149
+ paintContents(layer.Get(), layer.Get());
2150
+ if (FAILED(layer->EndDraw())) {
2151
+ paintContents(target);
2152
+ return;
2153
+ }
2154
+
2155
+ ComPtr<ID2D1Bitmap> picture;
2156
+ if (FAILED(layer->GetBitmap(picture.GetAddressOf())) || !picture) {
2157
+ paintContents(target);
2158
+ return;
2159
+ }
2160
+
2161
+ // The view's own opacity, which `paintContents` did not apply because a
2162
+ // pushed layer would have hidden the backdrop from the children. On the
2163
+ // bitmap it is the same picture and one multiply rather than a layer.
2164
+ target->DrawBitmap(picture.Get(),
2165
+ D2D1::RectF(0.0f, 0.0f, frame_.width, frame_.height),
2166
+ opacity_ * filters_.opacity,
2167
+ D2D1_BITMAP_INTERPOLATION_MODE_LINEAR,
2168
+ nullptr);
2169
+ }
2170
+
2171
+ // One blended child, in place of painting it onto the layer.
2172
+ //
2173
+ // Three surfaces, and each is there for a reason Direct2D imposes. The child
2174
+ // goes into one of its own because the blend wants it as an image rather than
2175
+ // as a draw. The result goes into another because the backdrop is the layer's
2176
+ // own bitmap, and Direct2D will not read a bitmap it is drawing into. And the
2177
+ // layer is then cleared and the result drawn back, rather than drawn over,
2178
+ // because the result already contains the backdrop: drawing it over would
2179
+ // composite the backdrop with itself.
2180
+ void RnWin32View::blendChildIntoLayer(ID2D1BitmapRenderTarget *layer,
2181
+ const RnWin32View *child) const {
2182
+ const std::optional<D2D1_BLEND_MODE> mode = blendModeFor(child->blendMode());
2183
+ ComPtr<ID2D1DeviceContext> context;
2184
+ if (!mode.has_value() || FAILED(layer->QueryInterface(IID_PPV_ARGS(&context)))) {
2185
+ // A keyword Direct2D has no mode for, or no device context to run an effect
2186
+ // on: the child paints unblended rather than not at all. The dump still
2187
+ // reports the keyword, which is the rule GTK follows for `plus-lighter`.
2188
+ child->paint(layer);
2189
+ return;
2190
+ }
2191
+
2192
+ // What is beneath the child: this view's own content and the children painted
2193
+ // before it, which is exactly what the layer holds at this point.
2194
+ layer->Flush();
2195
+ ComPtr<ID2D1Bitmap> backdrop;
2196
+ if (FAILED(layer->GetBitmap(backdrop.GetAddressOf())) || !backdrop) {
2197
+ child->paint(layer);
2198
+ return;
2199
+ }
2200
+
2201
+ // The child's own picture, in this view's coordinates: the transform the
2202
+ // layer is in, which carries a <ScrollView>'s offset, so the child lands
2203
+ // where it would have been painted.
2204
+ D2D1::Matrix3x2F transform;
2205
+ layer->GetTransform(&transform);
2206
+ const ComPtr<ID2D1BitmapRenderTarget> top =
2207
+ createOffscreen(layer, frame_.width, frame_.height);
2208
+ ComPtr<ID2D1Bitmap> topBitmap;
2209
+ if (!top) {
2210
+ child->paint(layer);
2211
+ return;
2212
+ }
2213
+ top->BeginDraw();
2214
+ top->Clear(D2D1::ColorF(0.0f, 0.0f, 0.0f, 0.0f));
2215
+ {
2216
+ // `overflow: hidden` clips the child here rather than where the blend
2217
+ // lands, which is what lets the result be drawn back over the whole layer:
2218
+ // outside the clip the child is transparent, a blend with nothing on top is
2219
+ // the backdrop, and the result is the layer unchanged there. The clip goes
2220
+ // on with the scroll offset off, as it does in paintChildren, and the
2221
+ // layer's own transform is what that offset was applied to.
2222
+ const D2D1::Matrix3x2F unscrolled =
2223
+ D2D1::Matrix3x2F::Translation(scrollX_, scrollY_ - textVerticalOffset()) * transform;
2224
+ top->SetTransform(unscrolled);
2225
+ const ScopedGeometryClip clip(clipsChildren_ ? top.Get() : nullptr,
2226
+ D2D1::RectF(0.0f, 0.0f, frame_.width, frame_.height),
2227
+ cornerRadii_);
2228
+ top->SetTransform(transform);
2229
+ child->paint(top.Get());
2230
+ }
2231
+ if (FAILED(top->EndDraw()) || FAILED(top->GetBitmap(topBitmap.GetAddressOf()))
2232
+ || !topBitmap) {
2233
+ child->paint(layer);
2234
+ return;
2235
+ }
2236
+
2237
+ const ComPtr<ID2D1BitmapRenderTarget> blended =
2238
+ createOffscreen(layer, frame_.width, frame_.height);
2239
+ ComPtr<ID2D1DeviceContext> blendedContext;
2240
+ ComPtr<ID2D1Effect> blend;
2241
+ ComPtr<ID2D1Bitmap> result;
2242
+ if (!blended || FAILED(blended->QueryInterface(IID_PPV_ARGS(&blendedContext)))
2243
+ || FAILED(blendedContext->CreateEffect(CLSID_D2D1Blend, blend.GetAddressOf()))
2244
+ || !blend) {
2245
+ child->paint(layer);
2246
+ return;
2247
+ }
2248
+ blend->SetInput(0, backdrop.Get());
2249
+ blend->SetInput(1, topBitmap.Get());
2250
+ blend->SetValue(D2D1_BLEND_PROP_MODE, mode.value());
2251
+ blended->BeginDraw();
2252
+ blended->Clear(D2D1::ColorF(0.0f, 0.0f, 0.0f, 0.0f));
2253
+ blendedContext->SetTransform(D2D1::Matrix3x2F::Identity());
2254
+ blendedContext->DrawImage(blend.Get(), D2D1::Point2F(0.0f, 0.0f));
2255
+ if (FAILED(blended->EndDraw()) || FAILED(blended->GetBitmap(result.GetAddressOf()))
2256
+ || !result) {
2257
+ child->paint(layer);
2258
+ return;
2259
+ }
2260
+
2261
+ // Back onto the layer, in its own coordinates rather than the scrolled ones:
2262
+ // the result is already in this view's space, and it is the whole layer --
2263
+ // backdrop included -- which is why the layer is cleared first rather than
2264
+ // drawn over.
2265
+ layer->SetTransform(D2D1::Matrix3x2F::Identity());
2266
+ layer->Clear(D2D1::ColorF(0.0f, 0.0f, 0.0f, 0.0f));
2267
+ layer->DrawBitmap(result.Get(),
2268
+ D2D1::RectF(0.0f, 0.0f, frame_.width, frame_.height),
2269
+ 1.0f,
2270
+ D2D1_BITMAP_INTERPOLATION_MODE_LINEAR,
2271
+ nullptr);
2272
+ layer->SetTransform(transform);
2273
+ }
2274
+
2275
+ // --- Hit testing ------------------------------------------------------------
2276
+
2277
+ namespace {
2278
+
2279
+ // The spelling React Native uses for the prop, which is also CSS's, so the
2280
+ // three hosts' dumps say the same words.
2281
+ const char *pointerEventsName(RnWin32View::PointerEvents mode) {
2282
+ switch (mode) {
2283
+ case RnWin32View::PointerEvents::None:
2284
+ return "none";
2285
+ case RnWin32View::PointerEvents::BoxNone:
2286
+ return "box-none";
2287
+ case RnWin32View::PointerEvents::BoxOnly:
2288
+ return "box-only";
2289
+ case RnWin32View::PointerEvents::Auto:
2290
+ break;
2291
+ }
2292
+ return "auto";
2293
+ }
2294
+
2295
+ } // namespace
2296
+
2297
+ void RnWin32View::setHitSlop(const float insets[4]) {
2298
+ bool any = false;
2299
+ for (int edge = 0; edge < 4; edge++) {
2300
+ hitSlop_[edge] = insets != nullptr ? insets[edge] : 0.0f;
2301
+ if (hitSlop_[edge] != 0.0f) {
2302
+ any = true;
2303
+ }
2304
+ }
2305
+ hasHitSlop_ = any;
2306
+ }
2307
+
2308
+ RnWin32View *hitTest(RnWin32View *root, float x, float y) {
2309
+ // `pointerEvents: none` takes the view and everything inside it out of hit
2310
+ // testing entirely, so the caller's loop carries on to whatever is behind.
2311
+ if (root == nullptr || root->hidden() ||
2312
+ root->pointerEvents() == RnWin32View::PointerEvents::None) {
2313
+ return nullptr;
2314
+ }
2315
+
2316
+ const RnRect &frame = root->frame();
2317
+ // `hitSlop` widens this test and only this test, so a press just outside the
2318
+ // box still lands on it. The slop is in this view's own coordinates, which is
2319
+ // where the point already is: a transform was inverted on the way down.
2320
+ //
2321
+ // The parent's answer is unchanged, so a slop reaching outside the parent is
2322
+ // only reachable where the parent is -- the same bound the other two hosts
2323
+ // have, and the same one iOS has, because each of them widens one view's test
2324
+ // rather than the walk that got here.
2325
+ const float *const slop = root->hitSlop();
2326
+ const float left = root->hasHitSlop() ? -slop[3] : 0.0f;
2327
+ const float top = root->hasHitSlop() ? -slop[0] : 0.0f;
2328
+ const float right = frame.width + (root->hasHitSlop() ? slop[1] : 0.0f);
2329
+ const float bottom = frame.height + (root->hasHitSlop() ? slop[2] : 0.0f);
2330
+ if (x < left || y < top || x >= right || y >= bottom) {
2331
+ return nullptr;
2332
+ }
2333
+
2334
+ // Children are placed in this view's content space, which a scroll offset
2335
+ // shifts. Adding it back here is what makes hit testing follow a scroll with
2336
+ // nothing in this function knowing what a ScrollView is -- the same offset
2337
+ // `paintChildren` subtracts, from the same two fields.
2338
+ const float contentX = x + root->scrollX();
2339
+ const float contentY = y + root->scrollY() - root->textVerticalOffset();
2340
+
2341
+ // Backwards: the last child painted is the topmost, and the topmost is what a
2342
+ // press should land on.
2343
+ //
2344
+ // `box-only` is the one mode that skips this: the box is the target and
2345
+ // nothing inside it is, which is what makes an overlay swallow a press meant
2346
+ // for a button drawn on top of it.
2347
+ if (root->pointerEvents() != RnWin32View::PointerEvents::BoxOnly) {
2348
+ const std::vector<RnWin32View *> ordered = root->childrenInPaintOrder();
2349
+ for (auto it = ordered.rbegin(); it != ordered.rend(); ++it) {
2350
+ RnWin32View *child = *it;
2351
+ float local[6];
2352
+ child->localToParent(local);
2353
+
2354
+ float childX = 0.0f;
2355
+ float childY = 0.0f;
2356
+ if (!invertPoint(local, contentX, contentY, childX, childY)) {
2357
+ continue;
2358
+ }
2359
+ if (RnWin32View *hit = hitTest(child, childX, childY)) {
2360
+ return hit;
2361
+ }
2362
+ }
2363
+ }
2364
+
2365
+ // `box-none` is transparent to a press that misses everything inside it.
2366
+ // Returning null rather than the parent is the whole of it: the caller is
2367
+ // partway through its own list of children, so the press carries on to the
2368
+ // sibling *behind* this view -- which is what the absolutely-positioned
2369
+ // overlay this mode exists for is asking for.
2370
+ if (root->pointerEvents() == RnWin32View::PointerEvents::BoxNone) {
2371
+ return nullptr;
2372
+ }
2373
+
2374
+ // A point inside this view but over none of its children is this view. React
2375
+ // Native's responder system needs a target for every press inside the
2376
+ // surface, and the root is the honest answer for one that missed everything.
2377
+ return root;
2378
+ }
2379
+
2380
+ void RnWin32View::setCursor(const char *name) {
2381
+ cursor_ = name != nullptr ? name : "";
2382
+ }
2383
+
2384
+ std::string cursorNameAt(RnWin32View *root, float x, float y) {
2385
+ // The innermost view under the point, which is the same answer a press gets:
2386
+ // one hit test for both means a cursor cannot disagree with what a click will
2387
+ // do. `pointerEvents: none` is skipped by it, which is right here too -- a
2388
+ // view a press passes through should not be changing the pointer either.
2389
+ RnWin32View *view = hitTest(root, x, y);
2390
+ // And then up, because CSS's cursor inherits: a <Text> inside a button that
2391
+ // asked for `pointer` shows the hand, having asked for nothing itself.
2392
+ while (view != nullptr) {
2393
+ if (!view->cursor().empty()) {
2394
+ return view->cursor();
2395
+ }
2396
+ view = view->parent();
2397
+ }
2398
+ return {};
2399
+ }
2400
+
2401
+ // --- Reporting -------------------------------------------------------------
2402
+
2403
+ std::string RnWin32View::describeTree() const {
2404
+ std::string out;
2405
+ describeInto(out, 0);
2406
+ return out;
2407
+ }
2408
+
2409
+ void RnWin32View::describeInto(std::string &out, int depth) const {
2410
+ for (int i = 0; i < depth; i++) {
2411
+ out += " ";
2412
+ }
2413
+
2414
+ // The y a paragraph moved this view to, when it is inline in one: the frame
2415
+ // is what React Native decided and `textAlignVertical` is the host's own
2416
+ // answer to a box taller than its text, so the two differ there and the other
2417
+ // hosts print the same number.
2418
+ const float dumpY =
2419
+ frame_.y + (parent_ != nullptr ? parent_->textVerticalOffset() : 0.0f);
2420
+ appendFormat(out,
2421
+ "view tag=%d frame=(%g,%g %gx%g)",
2422
+ tag_,
2423
+ static_cast<double>(frame_.x),
2424
+ static_cast<double>(dumpY),
2425
+ static_cast<double>(frame_.width),
2426
+ static_cast<double>(frame_.height));
2427
+
2428
+ if (hasBackgroundColor_) {
2429
+ appendFormat(out,
2430
+ " bg=#%02x%02x%02x%02x",
2431
+ toByte(backgroundColor_[0]),
2432
+ toByte(backgroundColor_[1]),
2433
+ toByte(backgroundColor_[2]),
2434
+ toByte(backgroundColor_[3]));
2435
+ }
2436
+ // Field order matches the GTK and AppKit sides exactly -- bg, opacity, clip,
2437
+ // transform, scroll, then the content fields -- because
2438
+ // scripts/compare_hosts.sh diffs the dumps line by line and a reordering
2439
+ // would read as every line differing.
2440
+ if (opacity_ < 1.0f) {
2441
+ appendFormat(out, " opacity=%g", static_cast<double>(opacity_));
2442
+ }
2443
+ if (clipsChildren_) {
2444
+ out += " clip";
2445
+ }
2446
+ // Whether anything is drawn at all. Without this a view hidden by
2447
+ // `display: none` or by a back face turned away reads exactly like a visible
2448
+ // one, and the only prop in this dump that removes a view entirely was the
2449
+ // only one it could not show.
2450
+ if (hidden_) {
2451
+ out += " hidden";
2452
+ }
2453
+ // Per-corner radii and per-edge borders, in the fields, the order and the
2454
+ // formatting GTK prints them -- RnView.cpp's describe -- so that
2455
+ // scripts/compare_hosts.sh compares what each host actually draws.
2456
+ //
2457
+ // Both were missing here until this host drew them: it printed one circular
2458
+ // radius eight times and no border at all, and printing nothing was what let
2459
+ // the comparison say so rather than hide it. Radii are eight numbers because
2460
+ // React Native's are elliptical: top-left, top-right, bottom-right,
2461
+ // bottom-left, horizontal then vertical.
2462
+ if (hasCornerRadii_) {
2463
+ appendFormat(out,
2464
+ " radii=(%g,%g,%g,%g,%g,%g,%g,%g)",
2465
+ static_cast<double>(cornerRadii_[0]),
2466
+ static_cast<double>(cornerRadii_[1]),
2467
+ static_cast<double>(cornerRadii_[2]),
2468
+ static_cast<double>(cornerRadii_[3]),
2469
+ static_cast<double>(cornerRadii_[4]),
2470
+ static_cast<double>(cornerRadii_[5]),
2471
+ static_cast<double>(cornerRadii_[6]),
2472
+ static_cast<double>(cornerRadii_[7]));
2473
+ }
2474
+ // Top, right, bottom, left, for both.
2475
+ if (hasBorders_) {
2476
+ appendFormat(out,
2477
+ " borderw=(%g,%g,%g,%g)",
2478
+ static_cast<double>(borderWidths_[0]),
2479
+ static_cast<double>(borderWidths_[1]),
2480
+ static_cast<double>(borderWidths_[2]),
2481
+ static_cast<double>(borderWidths_[3]));
2482
+ out += " borderc=(";
2483
+ for (int edge = 0; edge < 4; edge++) {
2484
+ const float *c = &borderColours_[edge * 4];
2485
+ appendFormat(out,
2486
+ "%s#%02x%02x%02x%02x",
2487
+ edge == 0 ? "" : ",",
2488
+ toByte(c[0]),
2489
+ toByte(c[1]),
2490
+ toByte(c[2]),
2491
+ toByte(c[3]));
2492
+ }
2493
+ out += ")";
2494
+ }
2495
+ // Gradients: how many, each one's line and stop count, where the image goes
2496
+ // and the tile it repeats in. Not every stop, which a transition hint can
2497
+ // turn into eleven of: what an end-to-end run needs is that the same gradient
2498
+ // arrived with the same geometry, and the stop fixup itself is asserted in
2499
+ // core's own tests. Spelled exactly as the other two hosts spell it.
2500
+ for (const Gradient &gradient : gradients_) {
2501
+ if (gradient.kind == Gradient::Kind::Radial) {
2502
+ appendFormat(out,
2503
+ " gradient=(radial (%g,%g) %gx%g,%u stops",
2504
+ static_cast<double>(gradient.centreX),
2505
+ static_cast<double>(gradient.centreY),
2506
+ static_cast<double>(gradient.radiusX),
2507
+ static_cast<double>(gradient.radiusY),
2508
+ static_cast<unsigned>(gradient.stops.size()));
2509
+ } else {
2510
+ appendFormat(out,
2511
+ " gradient=((%g,%g)-(%g,%g),%u stops",
2512
+ static_cast<double>(gradient.startX),
2513
+ static_cast<double>(gradient.startY),
2514
+ static_cast<double>(gradient.endX),
2515
+ static_cast<double>(gradient.endY),
2516
+ static_cast<unsigned>(gradient.stops.size()));
2517
+ }
2518
+ // `at=` is the rectangle the image fills and `tile=` is the period, printed
2519
+ // only when it repeats -- so a `no-repeat` background is the line without a
2520
+ // tile. The three background props are invisible in every other line here:
2521
+ // they move and repeat the image without changing the view at all.
2522
+ appendFormat(out,
2523
+ ",at=(%g,%g %gx%g)",
2524
+ static_cast<double>(gradient.area[0]),
2525
+ static_cast<double>(gradient.area[1]),
2526
+ static_cast<double>(gradient.area[2]),
2527
+ static_cast<double>(gradient.area[3]));
2528
+ if (gradient.repeats) {
2529
+ appendFormat(out,
2530
+ ",tile=(%g,%g %gx%g)",
2531
+ static_cast<double>(gradient.tile[0]),
2532
+ static_cast<double>(gradient.tile[1]),
2533
+ static_cast<double>(gradient.tile[2]),
2534
+ static_cast<double>(gradient.tile[3]));
2535
+ }
2536
+ out += ")";
2537
+ }
2538
+
2539
+ // Box shadows, each in full and in the order the app wrote them. Nothing else
2540
+ // in this dump can say a shadow is there, and the numbers are the whole
2541
+ // feature: an offset that went to the wrong axis or a spread read as a blur
2542
+ // still draws a plausible shadow. Spelled exactly as the other two hosts
2543
+ // spell it, `inset ` included, because this line is read by one end-to-end
2544
+ // scenario on all three.
2545
+ for (const BoxShadow &shadow : boxShadows_) {
2546
+ appendFormat(out,
2547
+ " shadow=(%s%g,%g,%g,%g,#%02x%02x%02x%02x)",
2548
+ shadow.inset ? "inset " : "",
2549
+ static_cast<double>(shadow.dx),
2550
+ static_cast<double>(shadow.dy),
2551
+ static_cast<double>(shadow.blur),
2552
+ static_cast<double>(shadow.spread),
2553
+ toByte(shadow.colour[0]),
2554
+ toByte(shadow.colour[1]),
2555
+ toByte(shadow.colour[2]),
2556
+ toByte(shadow.colour[3]));
2557
+ }
2558
+
2559
+ if (hasTransform_) {
2560
+ // The 2D affine part, in the order CSS writes a matrix(): a, b, c, d, tx,
2561
+ // ty. The other two platforms print the same six from their own matrix
2562
+ // types, so a transform is comparable across all three -- without which a
2563
+ // view rotated on one desktop and not on another looks identical here.
2564
+ appendFormat(out,
2565
+ " transform=(%g,%g,%g,%g,%g,%g)",
2566
+ static_cast<double>(transform_[0]),
2567
+ static_cast<double>(transform_[1]),
2568
+ static_cast<double>(transform_[2]),
2569
+ static_cast<double>(transform_[3]),
2570
+ static_cast<double>(transform_[4]),
2571
+ static_cast<double>(transform_[5]));
2572
+ }
2573
+ if (scrollX_ != 0.0f || scrollY_ != 0.0f) {
2574
+ appendFormat(out,
2575
+ " scroll=(%g,%g)",
2576
+ static_cast<double>(scrollX_),
2577
+ static_cast<double>(scrollY_));
2578
+ }
2579
+ // `pagingEnabled`, `snapToInterval`, `snapToOffsets` and the rest, which
2580
+ // decide where a fling ends and so are invisible to a dump taken while
2581
+ // nothing moves. Formatted in core, so all three hosts print the same words.
2582
+ out += scrollSnap_;
2583
+ // The overlay scrollbars, which are otherwise pure paint and so invisible to
2584
+ // every test this project has. Printed only when there is one, so a view that
2585
+ // does not scroll stays as short as it was.
2586
+ if (indicatorVerticalLength_ > 0.0f) {
2587
+ appendFormat(out,
2588
+ " scrollbar-v=(%g,%g)",
2589
+ static_cast<double>(indicatorVerticalOffset_),
2590
+ static_cast<double>(indicatorVerticalLength_));
2591
+ }
2592
+ if (indicatorHorizontalLength_ > 0.0f) {
2593
+ appendFormat(out,
2594
+ " scrollbar-h=(%g,%g)",
2595
+ static_cast<double>(indicatorHorizontalOffset_),
2596
+ static_cast<double>(indicatorHorizontalLength_));
2597
+ }
2598
+ // The thumb's colour, printed only when it is not the default black: a white
2599
+ // one is `indicatorStyle` having arrived, and the colour is the only part of
2600
+ // that a tree dump can see.
2601
+ if (indicatorColour_[0] != 0.0f || indicatorColour_[1] != 0.0f || indicatorColour_[2] != 0.0f) {
2602
+ appendFormat(out,
2603
+ " scrollbar-colour=(%g,%g,%g,%g)",
2604
+ static_cast<double>(indicatorColour_[0]),
2605
+ static_cast<double>(indicatorColour_[1]),
2606
+ static_cast<double>(indicatorColour_[2]),
2607
+ static_cast<double>(indicatorColour_[3]));
2608
+ }
2609
+ // Printed only when it is not the default, like every other field here.
2610
+ // Worth printing at all because it is invisible: a view with
2611
+ // `pointerEvents: none` is drawn exactly like one without, and the only way
2612
+ // the cross-host diff can say the prop arrived on all three is if each one
2613
+ // reports it.
2614
+ if (pointerEvents_ != PointerEvents::Auto) {
2615
+ appendFormat(out, " pe=%s", pointerEventsName(pointerEvents_));
2616
+ }
2617
+
2618
+ if (image_ != nullptr) {
2619
+ // The same `texture=WxH fit=<name>` the other two hosts emit. The fit is
2620
+ // here because it is the only thing about a drawn image that a frame cannot
2621
+ // show: two views the same size holding the same picture are identical in
2622
+ // every other field of this dump and different on screen.
2623
+ appendFormat(out, " texture=%ux%u", image_->width(), image_->height());
2624
+ appendFormat(out, " fit=%s", imageFitName(imageFit_));
2625
+ // That this image moves, which no other line can show: an animated GIF and
2626
+ // its first frame are the same size and the same picture in a snapshot. Not
2627
+ // which frame, deliberately: the three hosts tick on their own clocks, so a
2628
+ // cross-host diff of that would be a race, and each suite asserts the
2629
+ // frames itself.
2630
+ if (imageFrames_.size() > 1) {
2631
+ out += " animated=1";
2632
+ }
2633
+ // And the blur, spelled as the other two hosts spell it. Invisible in this
2634
+ // dump otherwise: a blurred image has the same frame, the same texture and
2635
+ // the same fit as a sharp one.
2636
+ if (imageBlur_ > 0.0f) {
2637
+ appendFormat(out, " blur=%g", static_cast<double>(imageBlur_));
2638
+ }
2639
+ // Printed for the same reason the fit is, and in the same format the other
2640
+ // two hosts use, so the cross-host diff can compare them.
2641
+ if (hasImageTint_) {
2642
+ appendFormat(out,
2643
+ " tint=#%02x%02x%02x%02x",
2644
+ static_cast<unsigned>(imageTint_[0] * 255.0f + 0.5f),
2645
+ static_cast<unsigned>(imageTint_[1] * 255.0f + 0.5f),
2646
+ static_cast<unsigned>(imageTint_[2] * 255.0f + 0.5f),
2647
+ static_cast<unsigned>(imageTint_[3] * 255.0f + 0.5f));
2648
+ }
2649
+ }
2650
+
2651
+ if (textLayout_ != nullptr) {
2652
+ const std::string &text = textLayout_->text();
2653
+ if (!text.empty()) {
2654
+ out += " text=\"";
2655
+ appendEscaped(out, text);
2656
+ out += "\"";
2657
+ }
2658
+ // The edge the text sits against, resolved, and the direction the app asked
2659
+ // for: the two lines the other two hosts print, in the same words, from the
2660
+ // same functions in core. Neither can be seen any other way in a dump -- a
2661
+ // right-aligned paragraph has the same box and the same string as a
2662
+ // left-aligned one, and a right-to-left paragraph of Latin text has both.
2663
+ //
2664
+ // This used to print the resolved direction as a boolean, because that is
2665
+ // what this host keeps, and so it was the one host whose dump could not be
2666
+ // compared with another's. The name the app used is what crosses now.
2667
+ if (textAlignName_ != nullptr) {
2668
+ out += " text-align=";
2669
+ out += textAlignName_;
2670
+ }
2671
+ if (textVerticalAlignName_ != nullptr) {
2672
+ out += " text-valign=";
2673
+ out += textVerticalAlignName_;
2674
+ }
2675
+ // A <Text>'s own padding and border. Invisible in every other line here --
2676
+ // the frame is the box including the padding, and the string is the same
2677
+ // string -- and it decides where the text is drawn and how wide it wraps.
2678
+ if (textInsetLeft_ != 0.0f || textInsetTop_ != 0.0f || textInsetRight_ != 0.0f ||
2679
+ textInsetBottom_ != 0.0f) {
2680
+ appendFormat(out, " text-inset=%g,%g,%g,%g",
2681
+ static_cast<double>(textInsetLeft_),
2682
+ static_cast<double>(textInsetTop_),
2683
+ static_cast<double>(textInsetRight_),
2684
+ static_cast<double>(textInsetBottom_));
2685
+ }
2686
+ // `<Text selectable>`, and what is selected in it. Neither can be seen any
2687
+ // other way in a dump: the prop changes no box and the highlight is a wash
2688
+ // under the glyphs. The range is in this host's own unit -- UTF-16 code
2689
+ // units, where GTK counts bytes -- so an end-to-end scenario compares
2690
+ // "something is selected" rather than the numbers.
2691
+ if (textSelectable_) {
2692
+ out += " selectable";
2693
+ }
2694
+ if (textSelectionLength() > 0) {
2695
+ appendFormat(out, " selection=%d,%d", textSelectionStart(), textSelectionLength());
2696
+ }
2697
+ if (writingDirectionName_ != nullptr) {
2698
+ out += " writing-dir=";
2699
+ out += writingDirectionName_;
2700
+ }
2701
+ // The paragraph's text shadow, spelled as the other two hosts spell it: no
2702
+ // other line can show it, a shadowed paragraph having the same text, the
2703
+ // same colour and the same box. The standard deviation React Native parsed,
2704
+ // which is also what the shadow effect was given.
2705
+ if (textLayout_->hasShadow()) {
2706
+ const float *const colour = textLayout_->shadowColour();
2707
+ appendFormat(out,
2708
+ " text-shadow=(%g,%g,%g,#%02x%02x%02x%02x)",
2709
+ static_cast<double>(textLayout_->shadowDx()),
2710
+ static_cast<double>(textLayout_->shadowDy()),
2711
+ static_cast<double>(textLayout_->shadowStandardDeviation()),
2712
+ static_cast<unsigned>(colour[0] * 255.0f + 0.5f),
2713
+ static_cast<unsigned>(colour[1] * 255.0f + 0.5f),
2714
+ static_cast<unsigned>(colour[2] * 255.0f + 0.5f),
2715
+ static_cast<unsigned>(colour[3] * 255.0f + 0.5f));
2716
+ }
2717
+ }
2718
+
2719
+ // A text field's content lives in its EDIT peer, not in a layout, so it would
2720
+ // otherwise be invisible to every test that reads this tree -- and the dump
2721
+ // would differ from the other two hosts' for a field that was working.
2722
+ if (editablePeer_ != nullptr) {
2723
+ const int length = GetWindowTextLength(editablePeer_);
2724
+ std::wstring wide(static_cast<size_t>(length) + 1, L'\0');
2725
+ GetWindowText(editablePeer_, wide.data(), length + 1);
2726
+ wide.resize(static_cast<size_t>(length));
2727
+
2728
+ out += " editable=\"";
2729
+ appendEscaped(out, narrow(wide));
2730
+ out += "\"";
2731
+
2732
+ // GetFocus is per-thread rather than global, which is the right question
2733
+ // here: this runs on the thread that owns the window, and what is being
2734
+ // asked is whether the field has the keyboard within it.
2735
+ if (GetFocus() == editablePeer_) {
2736
+ out += " focused";
2737
+ }
2738
+ }
2739
+
2740
+ // What a field asked for about spelling. Neither word appears for a field
2741
+ // that said nothing, which is the third state rather than a default; what
2742
+ // each host then did with it is in its own suite and on the support page.
2743
+ // This one can honour neither: a classic EDIT has no spell checker.
2744
+ if (!spellCheck_.empty()) {
2745
+ appendFormat(out, " spellcheck=%s", spellCheck_.c_str());
2746
+ }
2747
+ if (!autoCorrect_.empty()) {
2748
+ appendFormat(out, " autocorrect=%s", autoCorrect_.c_str());
2749
+ }
2750
+ // `autoCapitalize` and `keyboardType`, printed as the app wrote them so the
2751
+ // three dumps compare. This host turns the second into an input scope and the
2752
+ // first into a style bit for `characters` only; see Win32TextInput.cpp.
2753
+ if (!autoCapitalize_.empty()) {
2754
+ appendFormat(out, " autocapitalize=%s", autoCapitalize_.c_str());
2755
+ }
2756
+ if (!keyboardType_.empty()) {
2757
+ appendFormat(out, " keyboard=%s", keyboardType_.c_str());
2758
+ }
2759
+ // `caretHidden` and `contextMenuHidden`, printed only when asked for. Each is
2760
+ // the absence of something -- a blink, a menu -- so there is nothing else for
2761
+ // a test or the cross-host diff to look at.
2762
+ if (caretHidden_) {
2763
+ appendFormat(out, " caret=hidden");
2764
+ }
2765
+ if (contextMenuHidden_) {
2766
+ appendFormat(out, " context-menu=hidden");
2767
+ }
2768
+
2769
+ // How many DevTools highlights this view is drawing. In the dump because they
2770
+ // are otherwise invisible to everything but a screenshot, and because a
2771
+ // command that arrived and drew nothing is exactly the failure worth
2772
+ // catching.
2773
+ if (!highlights_.empty()) {
2774
+ appendFormat(out, " highlights=%d", static_cast<int>(highlights_.size()));
2775
+ }
2776
+
2777
+ // What kind of control this view is, and what state it is in. Written by
2778
+ // core/DesktopControls.h rather than formatted here, for the same reason the
2779
+ // role name below is React Native's vocabulary and not UIA's: three hosts
2780
+ // describing the same switch in three ways is a diff on every line.
2781
+ if (!controlDescription_.empty()) {
2782
+ appendFormat(out, " control=%s", controlDescription_.c_str());
2783
+ }
2784
+
2785
+ // The border's style, when it is not solid. Printed for the same reason the
2786
+ // widths are: a dashed border and a solid one are the same four widths and
2787
+ // the same four colours, and this is the only thing that can say the prop
2788
+ // arrived. Spelled as the other two hosts spell it.
2789
+ if (hasBorders_ && borderStyle_ != LineStyle::Solid) {
2790
+ appendFormat(out,
2791
+ " border-style=%s",
2792
+ borderStyle_ == LineStyle::Dotted ? "dotted" : "dashed");
2793
+ }
2794
+
2795
+ // The cursor the app asked for, which is invisible in a frame: the pointer is
2796
+ // not part of the picture. Printed for the reason `pointerEvents` is, and in
2797
+ // CSS's words rather than Win32's, so the three hosts' lines agree.
2798
+ if (!cursor_.empty()) {
2799
+ appendFormat(out, " cursor=%s", cursor_.c_str());
2800
+ }
2801
+
2802
+ // The blend mode the app asked for, which a frame cannot show unless there is
2803
+ // something beneath it. The keyword rather than Direct2D's mode, so the line
2804
+ // is comparable with the other two hosts', and printed even for a keyword
2805
+ // this host cannot blend: the dump says what was asked for.
2806
+ if (!blendMode_.empty()) {
2807
+ appendFormat(out, " blend=%s", blendMode_.c_str());
2808
+ }
2809
+
2810
+ // The outline, which is invisible in every other line: it is not a border,
2811
+ // and a view with one has the same frame and the same colours without it.
2812
+ // Spelled exactly as the other two hosts spell it, style included only when
2813
+ // it is not solid, because this line is diffed across the three.
2814
+ if (outlineWidth_ > 0.0f) {
2815
+ appendFormat(out,
2816
+ " outline=(%g,%g,#%02x%02x%02x%02x",
2817
+ static_cast<double>(outlineWidth_),
2818
+ static_cast<double>(outlineOffset_),
2819
+ static_cast<unsigned>(outlineColour_[0] * 255.0f + 0.5f),
2820
+ static_cast<unsigned>(outlineColour_[1] * 255.0f + 0.5f),
2821
+ static_cast<unsigned>(outlineColour_[2] * 255.0f + 0.5f),
2822
+ static_cast<unsigned>(outlineColour_[3] * 255.0f + 0.5f));
2823
+ if (outlineStyle_ != LineStyle::Solid) {
2824
+ appendFormat(out, ",%s", outlineStyle_ == LineStyle::Dotted ? "dotted" : "dashed");
2825
+ }
2826
+ out += ")";
2827
+ }
2828
+
2829
+ // `filter`, as the pieces it came to plus what its matrix makes of one probe
2830
+ // colour, which is the format the other two hosts print and the end-to-end
2831
+ // run reads. Sixteen numbers would drown the line; one colour is eight
2832
+ // characters and still fails when a matrix is wrong. The probe is
2833
+ // (1, 0.5, 0.25) so that no two channels can be swapped without the answer
2834
+ // changing.
2835
+ if (!filters_.empty()) {
2836
+ out += " filter=(";
2837
+ bool first = true;
2838
+ if (filters_.hasMatrix) {
2839
+ const float probe[4] = {1.0f, 0.5f, 0.25f, 1.0f};
2840
+ float result[4] = {0.0f, 0.0f, 0.0f, 0.0f};
2841
+ for (int row = 0; row < 4; row++) {
2842
+ result[row] = filters_.offset[row];
2843
+ for (int column = 0; column < 4; column++) {
2844
+ result[row] += filters_.matrix[row * 4 + column] * probe[column];
2845
+ }
2846
+ }
2847
+ const auto byte = [](float value) {
2848
+ const float clamped = value < 0.0f ? 0.0f : (value > 1.0f ? 1.0f : value);
2849
+ return static_cast<unsigned>(clamped * 255.0f + 0.5f);
2850
+ };
2851
+ appendFormat(out,
2852
+ "probe=#%02x%02x%02x%02x",
2853
+ byte(result[0]),
2854
+ byte(result[1]),
2855
+ byte(result[2]),
2856
+ byte(result[3]));
2857
+ first = false;
2858
+ }
2859
+ if (filters_.blurRadius > 0.0f) {
2860
+ appendFormat(out,
2861
+ "%sblur=%g",
2862
+ first ? "" : ",",
2863
+ static_cast<double>(filters_.blurRadius));
2864
+ first = false;
2865
+ }
2866
+ if (filters_.opacity < 1.0f) {
2867
+ appendFormat(out,
2868
+ "%sopacity=%g",
2869
+ first ? "" : ",",
2870
+ static_cast<double>(filters_.opacity));
2871
+ first = false;
2872
+ }
2873
+ // Each drop shadow, with the standard deviation React Native parsed: that
2874
+ // is the number all three hosts are handed, so it is the one a cross-host
2875
+ // diff should compare.
2876
+ for (const FilterShadow &shadow : filters_.shadows) {
2877
+ appendFormat(out,
2878
+ "%sshadow=(%g,%g,%g,#%02x%02x%02x%02x)",
2879
+ first ? "" : ",",
2880
+ static_cast<double>(shadow.dx),
2881
+ static_cast<double>(shadow.dy),
2882
+ static_cast<double>(shadow.standardDeviation),
2883
+ static_cast<unsigned>(shadow.colour[0] * 255.0f + 0.5f),
2884
+ static_cast<unsigned>(shadow.colour[1] * 255.0f + 0.5f),
2885
+ static_cast<unsigned>(shadow.colour[2] * 255.0f + 0.5f),
2886
+ static_cast<unsigned>(shadow.colour[3] * 255.0f + 0.5f));
2887
+ first = false;
2888
+ }
2889
+ out += ")";
2890
+ }
2891
+
2892
+ // `hitSlop`, which is invisible in every other line of this dump: a view with
2893
+ // a bigger target is drawn exactly like one without. Before `role=` because
2894
+ // that is where the other two hosts print it, and this dump is diffed line by
2895
+ // line.
2896
+ if (hasHitSlop_) {
2897
+ appendFormat(out,
2898
+ " hit-slop=(%g,%g,%g,%g)",
2899
+ static_cast<double>(hitSlop_[0]),
2900
+ static_cast<double>(hitSlop_[1]),
2901
+ static_cast<double>(hitSlop_[2]),
2902
+ static_cast<double>(hitSlop_[3]));
2903
+ }
2904
+
2905
+ // The resolved LABELLED_BY relation, by tag. The ids the app wrote are in its
2906
+ // own source; what is worth reporting is that they were resolved, and the
2907
+ // tags are Fabric's, so the three hosts print the same ones.
2908
+ if (!labelledBy_.empty()) {
2909
+ out += " labelled-by=";
2910
+ bool first = true;
2911
+ for (const RnWin32View *label : labelledBy_) {
2912
+ if (label == nullptr) {
2913
+ continue;
2914
+ }
2915
+ appendFormat(out, "%s%d", first ? "" : ",", label->tag());
2916
+ first = false;
2917
+ }
2918
+ }
2919
+
2920
+ // React Native's role name, not UIA's. This dump is compared line by line
2921
+ // across three platforms, and each reporting its own toolkit's vocabulary
2922
+ // would make every accessible view look like a difference. That the *UIA*
2923
+ // control type was really applied is asserted in
2924
+ // tests/test_win32_accessibility.cpp, which is where a platform question
2925
+ // belongs.
2926
+ if (!accessible_.role.empty()) {
2927
+ appendFormat(out, " role=%s", accessible_.role.c_str());
2928
+ }
2929
+ // `testID`, as React Native spelled it: the same division as the role above,
2930
+ // and that UIA really published it as the automation id is asserted in
2931
+ // tests/test_win32_accessibility.cpp.
2932
+ if (!accessible_.testId.empty()) {
2933
+ appendFormat(out, " testid=%s", accessible_.testId.c_str());
2934
+ }
2935
+ // Whether Tab stops here, after `role=` because that is where the other two
2936
+ // hosts print it and this dump is diffed line by line. Whether it is focused
2937
+ // *now* is deliberately not printed: that depends on what the window manager
2938
+ // did when the window opened, which is not a property of the platform and
2939
+ // would make this dump differ between two machines running the same app.
2940
+ if (focusable_) {
2941
+ out += " focusable";
2942
+ }
2943
+ // `accessibilityViewIsModal`, after `focusable` because that is where the
2944
+ // other two hosts print it and this dump is diffed line by line.
2945
+ if (accessible_.modal) {
2946
+ out += " modal";
2947
+ }
2948
+
2949
+ out += "\n";
2950
+
2951
+ for (const RnWin32View *child : children_) {
2952
+ child->describeInto(out, depth + 1);
2953
+ }
2954
+ }
2955
+
2956
+ } // namespace basalt::win32