basalt-win32 0.2.1 → 0.2.3

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 (36) hide show
  1. package/native/CMakeLists.txt +56 -9
  2. package/native/tests/test_win32_accessibility.cpp +77 -0
  3. package/native/tests/test_win32_focus.cpp +26 -0
  4. package/native/tests/test_win32_input.cpp +82 -0
  5. package/native/tests/test_win32_peer_clip.cpp +266 -0
  6. package/native/tests/test_win32_run_loop.cpp +122 -0
  7. package/native/tests/test_win32_textinput.cpp +199 -0
  8. package/native/tests/test_win32_ui_timer.cpp +195 -0
  9. package/native/win32/PlatformServicesWin32.cpp +16 -0
  10. package/native/win32/RnWin32Accessible.cpp +27 -0
  11. package/native/win32/RnWin32Accessible.h +5 -0
  12. package/native/win32/RnWin32View.h +18 -0
  13. package/native/win32/Win32AnimationChoreographer.cpp +15 -32
  14. package/native/win32/Win32AnimationChoreographer.h +9 -6
  15. package/native/win32/Win32AppWindow.h +36 -0
  16. package/native/win32/Win32Cursors.cpp +13 -17
  17. package/native/win32/Win32DropTarget.cpp +4 -1
  18. package/native/win32/Win32MenuBar.cpp +3 -1
  19. package/native/win32/Win32MountingManager.cpp +82 -7
  20. package/native/win32/Win32MountingManager.h +5 -0
  21. package/native/win32/Win32PeerClip.cpp +153 -0
  22. package/native/win32/Win32PeerClip.h +92 -0
  23. package/native/win32/Win32RunLoopObserver.cpp +39 -9
  24. package/native/win32/Win32RunLoopObserver.h +41 -2
  25. package/native/win32/Win32ScrollView.cpp +13 -32
  26. package/native/win32/Win32ScrollView.h +5 -6
  27. package/native/win32/Win32SkiaPeer.h +26 -4
  28. package/native/win32/Win32TextInput.cpp +139 -48
  29. package/native/win32/Win32TextInput.h +28 -8
  30. package/native/win32/Win32UiThread.h +11 -0
  31. package/native/win32/Win32UiTimer.cpp +139 -0
  32. package/native/win32/Win32UiTimer.h +65 -0
  33. package/native/win32/Win32WindowControl.cpp +38 -15
  34. package/native/win32/Win32WindowModule.cpp +8 -13
  35. package/native/win32/main_win32.cpp +123 -96
  36. package/package.json +2 -2
@@ -1,31 +1,17 @@
1
1
  #include "Win32AnimationChoreographer.h"
2
2
 
3
- #include <windows.h>
3
+ #include "Win32UiTimer.h"
4
4
 
5
5
  #include <chrono>
6
6
 
7
7
  namespace basalt {
8
8
  namespace {
9
9
 
10
- // Roughly sixty hertz. SetTimer's resolution is the message queue's -- it
11
- // cannot do better than about ten to sixteen milliseconds and it coalesces --
12
- // so this is an upper bound on the frame rate rather than a promise of one.
13
- // See the header for what would be needed to do it properly.
14
- constexpr UINT kFrameIntervalMs = 16;
15
-
16
- // SetTimer's callback carries the timer id and nothing else, so the
17
- // choreographer has to be found from it. One host means one choreographer, and
18
- // a map would be pretending otherwise.
19
- Win32AnimationChoreographer *&currentChoreographer() {
20
- static Win32AnimationChoreographer *instance = nullptr;
21
- return instance;
22
- }
23
-
24
- void CALLBACK onTimer(HWND, UINT, UINT_PTR, DWORD) {
25
- if (auto *choreographer = currentChoreographer()) {
26
- choreographer->onFrame();
27
- }
28
- }
10
+ // Roughly sixty hertz. The delay is a floor rather than a promise -- the frame
11
+ // is posted to the loop and arrives behind whatever the loop is already
12
+ // holding -- so this is an upper bound on the frame rate. See the header for
13
+ // what would be needed to do it properly.
14
+ constexpr unsigned kFrameIntervalMs = 16;
29
15
 
30
16
  } // namespace
31
17
 
@@ -37,28 +23,25 @@ void Win32AnimationChoreographer::resume() {
37
23
  if (running_.exchange(true)) {
38
24
  return;
39
25
  }
40
- currentChoreographer() = this;
41
- // A timer with no window: the callback is delivered as WM_TIMER to the
42
- // thread's queue and dispatched by the loop, which means it lands on the UI
43
- // thread like everything else and needs no synchronisation of its own.
44
- timer_ = static_cast<std::uintptr_t>(SetTimer(nullptr, 0, kFrameIntervalMs, onTimer));
26
+ // Not `SetTimer`. A sixty-hertz WM_TIMER is exactly the arrangement that
27
+ // stops working in an app that repaints continuously, which is to say in
28
+ // every app with an animation worth running. See win32/Win32UiTimer.h.
29
+ timer_ = scheduleUiTimer(kFrameIntervalMs, true, [this] { onFrame(); });
45
30
  }
46
31
 
47
32
  void Win32AnimationChoreographer::pause() {
48
33
  if (!running_.exchange(false)) {
49
34
  return;
50
35
  }
51
- if (timer_ != 0) {
52
- KillTimer(nullptr, static_cast<UINT_PTR>(timer_));
53
- timer_ = 0;
54
- }
36
+ // Before this returns, nothing of ours is still in flight: `cancelUiTimer`
37
+ // waits out a delivery already on a pool thread, which is what makes the
38
+ // `this` captured above safe to destroy afterwards.
39
+ cancelUiTimer(timer_);
40
+ timer_ = 0;
55
41
  }
56
42
 
57
43
  void Win32AnimationChoreographer::detach() {
58
44
  pause();
59
- if (currentChoreographer() == this) {
60
- currentChoreographer() = nullptr;
61
- }
62
45
  }
63
46
 
64
47
  void Win32AnimationChoreographer::onFrame() {
@@ -12,8 +12,8 @@
12
12
  // own thread, because DwmFlush blocks, and a blocked UI thread is worse than a
13
13
  // slightly wrong frame interval.
14
14
  //
15
- // So this is a multimedia timer at sixty hertz, and it is worth being clear
16
- // that this is a placeholder rather than a design. `docs/BACKLOG.md` already
15
+ // So this is a timer at sixty hertz, and it is worth being clear that this is
16
+ // a placeholder rather than a design. `docs/BACKLOG.md` already
17
17
  // records that worklets and Reanimated run on a sixteen-millisecond timer on
18
18
  // *both* other desktops for a related reason -- each platform's display link
19
19
  // belongs to React Native's own choreographer and pauses when React Native has
@@ -52,10 +52,13 @@ class Win32AnimationChoreographer final : public facebook::react::AnimationChore
52
52
 
53
53
  private:
54
54
  std::atomic<bool> running_{false};
55
- // A UINT_PTR from SetTimer, held as an integer so this header needs no
56
- // windows.h -- which would otherwise reach every translation unit that
57
- // constructs a host.
58
- std::uintptr_t timer_{0};
55
+ // A `basalt::UiTimerId` rather than a `SetTimer` handle, and not for
56
+ // tidiness: WM_TIMER ranks below WM_PAINT, so on this timer an app that
57
+ // repaints continuously -- anything with a `<Canvas>` -- stopped getting
58
+ // animation frames at all. See win32/Win32UiTimer.h. Held as a plain
59
+ // integer so this header needs no windows.h, which would otherwise reach
60
+ // every translation unit that constructs a host.
61
+ std::uint64_t timer_{0};
59
62
  };
60
63
 
61
64
  } // namespace basalt
@@ -0,0 +1,36 @@
1
+ // Which HWND a surface id means.
2
+ //
3
+ // The same seam AppKitAppWindow.h and GtkAppWindow.h describe, written for
4
+ // this toolkit and for the same reason: the registry of open windows is
5
+ // `main_win32.cpp`'s, and everything that resolves one is compiled into the
6
+ // mounting library, which the host links rather than the other way round. So
7
+ // the host installs a lookup at startup and this is what the window module
8
+ // asks.
9
+ //
10
+ // Not installed is the normal case for a test binary: it opens no window, and
11
+ // should not grow a registry to keep compiling. `win32AppWindowFor` falls back
12
+ // to the active window, which is what every caller here did before windows had
13
+ // ids.
14
+
15
+ #pragma once
16
+
17
+ #include <react/renderer/core/ReactPrimitives.h>
18
+
19
+ #include <functional>
20
+
21
+ #include <windows.h>
22
+
23
+ namespace basalt {
24
+
25
+ // The app's own window, by the rule this host has always used: the active
26
+ // window, falling back to the foreground one. UI thread only.
27
+ HWND win32AppWindow();
28
+
29
+ using Win32AppWindowLookup = std::function<HWND(facebook::react::SurfaceId)>;
30
+ void setWin32AppWindowLookup(Win32AppWindowLookup lookup);
31
+
32
+ // The window with this surface id, or the app's own for `kAppWindow` and for
33
+ // an id the lookup does not know. UI thread only.
34
+ HWND win32AppWindowFor(facebook::react::SurfaceId window);
35
+
36
+ } // namespace basalt
@@ -244,20 +244,6 @@ HCURSOR cursorFromArt(const Art &art, int hotX, int hotY) {
244
244
  return cursor;
245
245
  }
246
246
 
247
- // `%SystemRoot%\Cursors\aero_link.cur`, the shell's own shortcut cursor, which
248
- // is the one of the six Windows ships as a file with a name rather than as an
249
- // ordinal inside a DLL. Null when it is not there, and the caller draws its own.
250
- HCURSOR cursorFromSystemFile(const wchar_t *leaf) {
251
- wchar_t path[MAX_PATH]{};
252
- const UINT length = GetWindowsDirectoryW(path, MAX_PATH);
253
- if (length == 0 || length >= MAX_PATH) {
254
- return nullptr;
255
- }
256
- std::wstring full(path);
257
- full += L"\\Cursors\\";
258
- full += leaf;
259
- return LoadCursorFromFileW(full.c_str());
260
- }
261
247
 
262
248
  HCURSOR drawnCursorFor(const std::string &keyword) {
263
249
  if (keyword == "copy") {
@@ -267,9 +253,19 @@ HCURSOR drawnCursorFor(const std::string &keyword) {
267
253
  return cursorFromArt(contextMenuArt(), 0, 0);
268
254
  }
269
255
  if (keyword == "alias") {
270
- if (HCURSOR shipped = cursorFromSystemFile(L"aero_link.cur")) {
271
- return shipped;
272
- }
256
+ // Drawn, like the other five, and not `%SystemRoot%\Cursors\aero_link.cur`.
257
+ //
258
+ // That file was preferred here on the reasoning that a real name beats an
259
+ // unnamed ordinal inside shell32, which is sound and is still why nothing
260
+ // else reaches into the shell. What it missed is what the file contains:
261
+ // on Windows 11 aero_link.cur is "Link Select", the pointing hand -- the
262
+ // same cursor `pointer` already gets from IDC_HAND. So `alias` and
263
+ // `pointer` came back pixel for pixel identical, and CSS means two
264
+ // different things by them.
265
+ //
266
+ // The art below is the shape `alias` is supposed to be: an arrow with the
267
+ // shortcut badge turning up and to the right. It was already written and
268
+ // never ran, because the file always loaded.
273
269
  return cursorFromArt(aliasArt(), 0, 0);
274
270
  }
275
271
  if (keyword == "cell") {
@@ -490,7 +490,10 @@ facebook::react::Tag dropTargetAt(win32::RnWin32View *root,
490
490
  }
491
491
  // Deepest first, which is the rule: a drop target inside another wins.
492
492
  for (auto it = path.rbegin(); it != path.rend(); ++it) {
493
- const std::uint16_t wanted = dropAcceptsFrom((*it)->nativeId());
493
+ // The `nativeID` marker and the view's own declared kinds, which is how a
494
+ // <TextInput acceptDragAndDropTypes> joins this walk without an id.
495
+ const std::uint16_t wanted =
496
+ static_cast<std::uint16_t>(dropAcceptsFrom((*it)->nativeId()) | (*it)->dropAccepts());
494
497
  if (wanted != DropAcceptsNone && (wanted & accepts) != 0) {
495
498
  return static_cast<facebook::react::Tag>((*it)->tag());
496
499
  }
@@ -342,7 +342,9 @@ bool handleMenuCommand(unsigned int command) {
342
342
  PostMessageW(window, WM_CLOSE, 0, 0);
343
343
  return true;
344
344
  case kRoleBase + 12:
345
- setWindowFullScreen(!windowBounds().fullScreen);
345
+ // The app's own window: a menu role acts on the window the menu bar
346
+ // belongs to, which is that one. See core/WindowControl.h.
347
+ setWindowFullScreen(kAppWindow, !windowBounds(kAppWindow).fullScreen);
346
348
  return true;
347
349
  default:
348
350
  return false;
@@ -1,3 +1,6 @@
1
+ #include "AccessibilityRole.h"
2
+ #include "KeyboardFocusable.h"
3
+ #include "ImagePlaceholder.h"
1
4
  #include "Win32MountingManager.h"
2
5
 
3
6
  #include "BackgroundLayers.h"
@@ -15,7 +18,10 @@
15
18
  #include "PlatformServices.h"
16
19
  #include "UIManagerAccess.h"
17
20
 
18
- #ifdef BASALT_HAS_SKIA
21
+ // Either Skia: the 2.x peer in this package or the 3.x one in basalt-skia,
22
+ // which share this header's two functions and are never both linked. See
23
+ // Win32SkiaPeer.h.
24
+ #if defined(BASALT_HAS_SKIA) || BASALT_HAS_SKIA_GRAPHITE
19
25
  #include "Win32SkiaPeer.h"
20
26
  #endif
21
27
 
@@ -339,11 +345,13 @@ void Win32MountingManager::forgetTag(Tag tag) {
339
345
  scrollViews_.remove(tag);
340
346
  textInputs_.remove(tag);
341
347
  imageUris_.erase(tag);
348
+ imagePlaceholders_.erase(tag);
349
+ imageLoaded_.erase(tag);
342
350
  switchValues_.erase(tag);
343
351
  labels_.forget(tag);
344
352
  liveRegions_.forget(tag);
345
353
  liveRegionLabels_.erase(tag);
346
- #ifdef BASALT_HAS_SKIA
354
+ #if defined(BASALT_HAS_SKIA) || BASALT_HAS_SKIA_GRAPHITE
347
355
  // Unregisters the canvas. This is the one moment a view is known to be
348
356
  // finished with, and a canvas left registered is a surface the package will
349
357
  // keep rendering into for a tag nothing will ever draw again.
@@ -360,7 +368,7 @@ void Win32MountingManager::updateView(RnWin32View *view, const ShadowView &shado
360
368
  applyText(view, shadowView);
361
369
  applyImage(view, shadowView);
362
370
  applyAccessibility(view, shadowView);
363
- #ifdef BASALT_HAS_SKIA
371
+ #if defined(BASALT_HAS_SKIA) || BASALT_HAS_SKIA_GRAPHITE
364
372
  // After applyProps, which is what puts nativeID on the view -- a canvas
365
373
  // registers itself by that id and cannot attach before it is there. The size
366
374
  // it reads comes from the shadow view rather than from the frame
@@ -869,6 +877,9 @@ void Win32MountingManager::applyImage(RnWin32View *view, const ShadowView &shado
869
877
  const facebook::react::ImageSource source =
870
878
  props->sources.empty() ? facebook::react::ImageSource{} : props->sources.front();
871
879
  const std::string uri = source.uri;
880
+ // `defaultSource`, drawn until the source lands and kept if it never does.
881
+ // See core/ImagePlaceholder.h for which of the two props this is.
882
+ const std::string placeholder = basalt::imagePlaceholderUri(*props);
872
883
 
873
884
  // `tintColor`, applied before the load rather than in its callback: the tint
874
885
  // is a prop and the image is a loader's answer, and either can arrive first.
@@ -911,6 +922,8 @@ void Win32MountingManager::applyImage(RnWin32View *view, const ShadowView &shado
911
922
  return;
912
923
  }
913
924
  imageUris_[shadowView.tag] = uri;
925
+ imagePlaceholders_[shadowView.tag] = placeholder;
926
+ imageLoaded_.erase(shadowView.tag);
914
927
 
915
928
  if (uri.empty()) {
916
929
  view->setImage(nullptr, fit);
@@ -923,6 +936,38 @@ void Win32MountingManager::applyImage(RnWin32View *view, const ShadowView &shado
923
936
  // the change did not take.
924
937
  view->setImage(nullptr, fit);
925
938
 
939
+ // Then the placeholder, if the app named one, so the blank box above is as
940
+ // short as the loader is slow. A bundled asset answers from the cache on
941
+ // this thread, which makes it the same frame.
942
+ //
943
+ // Guarded on the real image not having arrived: both loads are asynchronous
944
+ // and a slow placeholder must not paint over the image it stood in for.
945
+ if (!placeholder.empty() && uri != placeholder) {
946
+ const Tag placeholderTag = shadowView.tag;
947
+ imageLoader_->load(
948
+ placeholder,
949
+ [this, placeholderTag, fit, placeholder](std::shared_ptr<win32::RnWin32Image> image,
950
+ const std::string &) {
951
+ if (image == nullptr || imageLoaded_.count(placeholderTag) != 0) {
952
+ return;
953
+ }
954
+ const auto still = imagePlaceholders_.find(placeholderTag);
955
+ if (still == imagePlaceholders_.end() || still->second != placeholder) {
956
+ return;
957
+ }
958
+ RnWin32View *target = viewForTag(placeholderTag);
959
+ if (target == nullptr) {
960
+ return;
961
+ }
962
+ target->setImage(image, fit);
963
+ // The same reason the load callback below calls it: the bytes
964
+ // arrived on their own clock and only the host has the HWND.
965
+ if (onDidMount_) {
966
+ onDidMount_();
967
+ }
968
+ });
969
+ }
970
+
926
971
  // `<Image>`'s load events, which this host emitted none of until 2026-10-10:
927
972
  // `onLoadStart`, `onProgress`, `onLoad`, `onError` and `onLoadEnd` all
928
973
  // reached JavaScript on the other two hosts and nothing here ever dispatched
@@ -984,7 +1029,20 @@ void Win32MountingManager::applyImage(RnWin32View *view, const ShadowView &shado
984
1029
  const bool stillWanted =
985
1030
  target != nullptr && current != imageUris_.end() && current->second == uri;
986
1031
  if (stillWanted) {
987
- target->setImage(image, fit);
1032
+ // A failed load leaves the placeholder where it is rather than
1033
+ // clearing to nothing, which is `RCTImageComponentView`'s behaviour:
1034
+ // a broken remote image shows what the app chose to show instead of
1035
+ // an empty box.
1036
+ const auto standing = imagePlaceholders_.find(tag);
1037
+ const bool keepPlaceholder = image == nullptr &&
1038
+ standing != imagePlaceholders_.end() &&
1039
+ !standing->second.empty();
1040
+ if (image != nullptr) {
1041
+ imageLoaded_.insert(tag);
1042
+ }
1043
+ if (!keepPlaceholder) {
1044
+ target->setImage(image, fit);
1045
+ }
988
1046
  applyAnimation(target, uri);
989
1047
  // The screen is stale and no transaction is coming: the bytes arrived
990
1048
  // on their own clock, after the mount that asked for them. On GTK and
@@ -1041,8 +1099,22 @@ win32::RnAccessibleFlag toFlag(bool value) {
1041
1099
  std::string effectiveRole(const ShadowView &shadowView) {
1042
1100
  if (const auto props =
1043
1101
  std::dynamic_pointer_cast<const facebook::react::AccessibilityProps>(shadowView.props)) {
1044
- if (!props->accessibilityRole.empty()) {
1045
- return props->accessibilityRole;
1102
+ const std::string asked = basalt::accessibilityRoleName(*props);
1103
+ if (!asked.empty()) {
1104
+ return asked;
1105
+ }
1106
+ }
1107
+ // Then the paragraph's own fragments, which is where a nested <Text>'s role
1108
+ // lands: `TextAttributes` carries one per run, and a paragraph every fragment
1109
+ // of which agrees *is* that role. Before the inference below, so a paragraph
1110
+ // whose runs all say "header" is a header rather than generic text. See
1111
+ // core/AccessibilityRole.h for why agreement and not the first one.
1112
+ if (const auto state =
1113
+ std::dynamic_pointer_cast<const facebook::react::ConcreteState<facebook::react::ParagraphState>>(
1114
+ shadowView.state)) {
1115
+ const std::string fragments = basalt::fragmentRoleName(state->getData().attributedString);
1116
+ if (!fragments.empty()) {
1117
+ return fragments;
1046
1118
  }
1047
1119
  }
1048
1120
  if (shadowView.componentName != nullptr) {
@@ -1082,6 +1154,9 @@ void Win32MountingManager::applyAccessibility(RnWin32View *view, const ShadowVie
1082
1154
  info.testId = props->testId;
1083
1155
  // `accessibilityViewIsModal`, which UIA calls a dialog.
1084
1156
  info.modal = props->accessibilityViewIsModal;
1157
+ // `accessibilityLanguage`, as the BCP-47 tag the app wrote. The conversion
1158
+ // to the LCID UIA wants happens where the culture property is answered.
1159
+ info.language = props->accessibilityLanguage;
1085
1160
 
1086
1161
  const auto &state = props->accessibilityState;
1087
1162
  if (state.has_value()) {
@@ -1137,7 +1212,7 @@ void Win32MountingManager::applyAccessibility(RnWin32View *view, const ShadowVie
1137
1212
  // that cannot be operated rather than stopping on one that does nothing.
1138
1213
  const basalt::ControlState control = basalt::controlStateOf(shadowView);
1139
1214
  const bool isControl = control.kind == basalt::ControlKind::Switch && !control.disabled;
1140
- view->setFocusable((props->accessible || isControl) && !props->accessibilityElementsHidden);
1215
+ view->setFocusable(basalt::keyboardFocusable(*props, isControl));
1141
1216
  }
1142
1217
 
1143
1218
  void Win32MountingManager::applyLayoutMetrics(RnWin32View *view, const ShadowView &shadowView) {
@@ -34,6 +34,7 @@
34
34
  #include <functional>
35
35
  #include <string>
36
36
  #include <unordered_map>
37
+ #include <unordered_set>
37
38
 
38
39
  namespace basalt {
39
40
 
@@ -190,6 +191,10 @@ class Win32MountingManager final : public facebook::react::IMountingManager,
190
191
  // changed only layout does not restart the load and make the image flicker
191
192
  // whenever its parent resizes. Both other platforms keep the same map.
192
193
  std::unordered_map<facebook::react::Tag, std::string> imageUris_;
194
+ // `defaultSource` per view, and which views have their real image. See
195
+ // core/ImagePlaceholder.h.
196
+ std::unordered_map<facebook::react::Tag, std::string> imagePlaceholders_;
197
+ std::unordered_set<facebook::react::Tag> imageLoaded_;
193
198
 
194
199
  // Scroll offsets, the unthrottled state write-back, the wheel and the
195
200
  // commands. Constructed with a lookup into this object's own emitter registry
@@ -0,0 +1,153 @@
1
+ #include "Win32PeerClip.h"
2
+
3
+ #include "RnWin32View.h"
4
+
5
+ #include <algorithm>
6
+ #include <cmath>
7
+
8
+ namespace basalt::win32 {
9
+
10
+ namespace {
11
+
12
+ RECT boxOf(double x, double y, double width, double height) {
13
+ RECT rect{};
14
+ rect.left = static_cast<LONG>(std::lround(x));
15
+ rect.top = static_cast<LONG>(std::lround(y));
16
+ rect.right = rect.left + static_cast<LONG>(std::lround(width));
17
+ rect.bottom = rect.top + static_cast<LONG>(std::lround(height));
18
+ return rect;
19
+ }
20
+
21
+ bool isEmpty(const RECT &rect) {
22
+ return rect.right <= rect.left || rect.bottom <= rect.top;
23
+ }
24
+
25
+ RECT intersect(const RECT &a, const RECT &b) {
26
+ RECT out{};
27
+ out.left = std::max(a.left, b.left);
28
+ out.top = std::max(a.top, b.top);
29
+ out.right = std::min(a.right, b.right);
30
+ out.bottom = std::min(a.bottom, b.bottom);
31
+ if (isEmpty(out)) {
32
+ return RECT{0, 0, 0, 0};
33
+ }
34
+ return out;
35
+ }
36
+
37
+ // Whether this view puts something over the whole of its own box.
38
+ //
39
+ // A background colour with any alpha at all, and nothing else -- see the
40
+ // header for why the bar is "not transparent" rather than "opaque", and why a
41
+ // transformed view is left alone instead of being approximated by its bounding
42
+ // box.
43
+ bool covers(const RnWin32View *view) {
44
+ if (!view->hasBackgroundColor() || view->hasTransform()) {
45
+ return false;
46
+ }
47
+ if (view->opacity() <= 0.0f) {
48
+ return false;
49
+ }
50
+ return view->backgroundColor()[3] > 0.0f;
51
+ }
52
+
53
+ struct Walk {
54
+ const RnWin32View *target{nullptr};
55
+ PeerClip *out{nullptr};
56
+ // Set when the target has been reached, which is what divides the tree into
57
+ // "painted before it", which cannot cover it, and "painted after it", which
58
+ // can. One flag is enough because painting order is a single sequence.
59
+ bool seen{false};
60
+ };
61
+
62
+ // `origin` is where this view's parent puts its children, already scrolled.
63
+ void visit(RnWin32View *view, double originX, double originY, const RECT &clip, Walk &walk) {
64
+ if (view == nullptr || view->hidden()) {
65
+ return;
66
+ }
67
+
68
+ const double x = originX + view->frame().x;
69
+ const double y = originY + view->frame().y;
70
+ const RECT box = boxOf(x, y, view->frame().width, view->frame().height);
71
+
72
+ if (view == walk.target) {
73
+ const RECT visible = intersect(box, clip);
74
+ if (isEmpty(visible)) {
75
+ return;
76
+ }
77
+ walk.out->visible = true;
78
+ walk.out->bounds = box;
79
+ walk.out->clip = visible;
80
+ // Its own subtree is not walked: a child of a `<TextInput>` is inside the
81
+ // field rather than over it, and the field has none in any real tree.
82
+ walk.seen = true;
83
+ return;
84
+ }
85
+
86
+ if (walk.seen && covers(view)) {
87
+ const RECT over = intersect(box, clip);
88
+ if (!isEmpty(over)) {
89
+ walk.out->occluders.push_back(over);
90
+ }
91
+ }
92
+
93
+ RECT childClip = clip;
94
+ if (view->clipsChildren()) {
95
+ childClip = intersect(clip, box);
96
+ if (isEmpty(childClip)) {
97
+ // Nothing inside can be seen, and nothing inside can cover anything
98
+ // either -- but the target may still be in there, and if it is then it is
99
+ // off screen, which is what not finding it already means.
100
+ return;
101
+ }
102
+ }
103
+
104
+ const double childX = x - view->scrollX();
105
+ const double childY = y - view->scrollY();
106
+ for (RnWin32View *child : view->childrenInPaintOrder()) {
107
+ visit(child, childX, childY, childClip, walk);
108
+ }
109
+ }
110
+
111
+ } // namespace
112
+
113
+ PeerClip peerClip(RnWin32View *view, RnWin32View *root) {
114
+ PeerClip result{};
115
+ if (view == nullptr || root == nullptr) {
116
+ return result;
117
+ }
118
+
119
+ // The root's own frame is not part of these coordinates: the surface root is
120
+ // the client area, and a peer's position is measured from its top-left. So
121
+ // the walk starts inside it rather than at it, which also means a `view` that
122
+ // *is* the root is not a field and comes back invisible.
123
+ if (root->hidden()) {
124
+ return result;
125
+ }
126
+
127
+ // Unbounded unless the root says otherwise, which is the rule every other
128
+ // ancestor is under. Clipping to the client area regardless would be defen-
129
+ // sible and is not done here: a surface that has never been laid out has a
130
+ // zero frame, and "every field is invisible" is a worse answer than "a field
131
+ // outside the window is placed outside the window", which USER32 handles.
132
+ constexpr LONG kUnbounded = 1 << 28;
133
+ RECT clip{-kUnbounded, -kUnbounded, kUnbounded, kUnbounded};
134
+ if (root->clipsChildren()) {
135
+ clip = boxOf(0.0, 0.0, root->frame().width, root->frame().height);
136
+ if (isEmpty(clip)) {
137
+ return result;
138
+ }
139
+ }
140
+ Walk walk{view, &result, false};
141
+ const double childX = -static_cast<double>(root->scrollX());
142
+ const double childY = -static_cast<double>(root->scrollY());
143
+ for (RnWin32View *child : root->childrenInPaintOrder()) {
144
+ visit(child, childX, childY, clip, walk);
145
+ }
146
+
147
+ if (!result.visible) {
148
+ return PeerClip{};
149
+ }
150
+ return result;
151
+ }
152
+
153
+ } // namespace basalt::win32
@@ -0,0 +1,92 @@
1
+ // Where a child-window peer is allowed to paint.
2
+ //
3
+ // A `<TextInput>` on this host is a real EDIT control, so it is a child window
4
+ // rather than something Direct2D draws. That buys the IME, the clipboard and
5
+ // every binding a Windows user expects, and it costs the one thing every other
6
+ // view gets for free: a place in the painting order. A child window is drawn by
7
+ // USER32 after the parent's client area, so it sits above everything the React
8
+ // tree drew, whatever the tree says. Two things follow, and a person sees both:
9
+ //
10
+ // - A field in a `<ScrollView>` does not clip to it. Scroll the list until
11
+ // the field is half out and the half that should be gone goes on being
12
+ // drawn over the list's neighbour.
13
+ // - A field cannot be covered. Open a modal over a pane that has one and the
14
+ // field floats on top of the modal, at full brightness, still editable.
15
+ //
16
+ // Both are the same missing fact -- which pixels of the field are still the
17
+ // field's -- and Windows has somewhere to put it: `SetWindowRgn`. A window with
18
+ // a region is displayed only inside it, and clicks outside it fall through to
19
+ // the parent. So the job is to work the region out from the view tree, which is
20
+ // what this file is: pure geometry over `RnWin32View`, with no window, no
21
+ // control and no React Native in it, so that it can be tested with none of
22
+ // them.
23
+ //
24
+ // ## What counts as covering it
25
+ //
26
+ // Clipping is exact: a clipping ancestor's box is a rectangle and the
27
+ // intersection of rectangles is the answer.
28
+ //
29
+ // Occlusion is not, and cannot be. The right behaviour for a field under
30
+ // kino's modal scrim -- `#000000A0` over the whole window -- is for the field
31
+ // to be *dimmed*, and a child window cannot be dimmed by something painted into
32
+ // its parent: the two are composited by USER32, in that order, with no blend to
33
+ // take part in. The choice is therefore between showing the field undimmed and
34
+ // hiding it, and hiding it is the better wrong answer: an undimmed field over a
35
+ // modal is the bug people report, while a field that goes quiet under a scrim
36
+ // reads as part of the modal.
37
+ //
38
+ // So anything painted after the field that is not fully transparent counts as
39
+ // covering it. That is deliberately more eager than "opaque": a 63%-black
40
+ // backdrop blends to something no child window can match, so there is nothing
41
+ // to be gained by letting it show through at 100%.
42
+ //
43
+ // What does *not* count: a view with no background at all, which is every
44
+ // layout wrapper in a real tree and the reason this rule produces no false
45
+ // positives in practice, and a transformed view, whose box is not a rectangle
46
+ // in these coordinates and which is therefore left alone rather than guessed
47
+ // at. A `<Text>` drawn over a field is also left alone -- glyphs are not a box.
48
+ //
49
+ // ## Transforms
50
+ //
51
+ // Ignored throughout, as `Win32TextInputManager` already ignores them when it
52
+ // places the peer: a window cannot be rotated, so a transformed field's peer
53
+ // sits where the untransformed one would, and clipping it to a rotated
54
+ // ancestor's bounding box would only move the error around.
55
+
56
+ #pragma once
57
+
58
+ #include <windows.h>
59
+
60
+ #include <vector>
61
+
62
+ namespace basalt::win32 {
63
+
64
+ class RnWin32View;
65
+
66
+ // Where `view`'s peer may paint, all in the surface root's coordinates.
67
+ struct PeerClip {
68
+ // False when the view is not on screen at all: hidden, detached, or scrolled
69
+ // entirely out of a clipping ancestor. Nothing else in the struct means
70
+ // anything then.
71
+ bool visible{false};
72
+
73
+ // The view's own box, unclipped. This is where the control goes -- a control
74
+ // narrowed to its visible part would lay its text out in the wrong width.
75
+ RECT bounds{};
76
+
77
+ // `bounds` narrowed by every clipping ancestor. Never empty when `visible`.
78
+ RECT clip{};
79
+
80
+ // Boxes painted after the view that cover part of it, already narrowed to
81
+ // their own clipping ancestors. Each is taken out of the region.
82
+ std::vector<RECT> occluders{};
83
+ };
84
+
85
+ // Walks the tree from `root` once, in painting order, and answers for `view`.
86
+ //
87
+ // `root` is the surface root, whose coordinates are the window's client
88
+ // coordinates on this host. A view that is not under `root` comes back
89
+ // invisible, which is what a peer between a Remove and its Delete looks like.
90
+ PeerClip peerClip(RnWin32View *view, RnWin32View *root);
91
+
92
+ } // namespace basalt::win32