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
@@ -4,28 +4,49 @@
4
4
 
5
5
  #include <react/utils/RunLoopObserverManager.h>
6
6
 
7
+ #include <chrono>
8
+
7
9
  namespace basalt {
8
10
 
9
- int runMessageLoopWithBeat(
10
- std::shared_ptr<facebook::react::RunLoopObserverManager> manager) {
11
+ int runMessageLoop(const std::function<void()> &beat) {
11
12
  MSG message{};
13
+
12
14
  for (;;) {
13
- // Drain everything queued before beating. Beating between each message
14
- // would be correct but wasteful, and beating *after* the queue is empty is
15
- // what "BeforeWaiting" means -- the frame has settled, and whatever the
16
- // messages produced is now worth flushing to JavaScript in one go.
15
+ // Drain what is queued before beating. Beating between each message would
16
+ // be correct but wasteful, and beating *after* the queue is empty is what
17
+ // "BeforeWaiting" means -- the frame has settled, and whatever the messages
18
+ // produced is now worth flushing to JavaScript in one go.
19
+ //
20
+ // Bounded, though: "after the queue is empty" is a moment that never comes
21
+ // in an app that paints continuously, and an event waiting for it waits
22
+ // forever. See the header for what that cost -- a button that usually
23
+ // worked.
24
+ auto since = std::chrono::steady_clock::now();
25
+ int drained = 0;
17
26
  while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
18
27
  if (message.message == WM_QUIT) {
19
28
  return static_cast<int>(message.wParam);
20
29
  }
21
30
  TranslateMessage(&message);
22
31
  DispatchMessageW(&message);
23
- }
24
32
 
25
- if (manager != nullptr) {
26
- manager->onRender();
33
+ // The count is checked first because it is free, and the clock only
34
+ // every `kBeatEveryMessages` messages, so a busy drain does not spend
35
+ // itself in `steady_clock::now`.
36
+ if (++drained < kBeatEveryMessages) {
37
+ continue;
38
+ }
39
+ drained = 0;
40
+ const auto now = std::chrono::steady_clock::now();
41
+ if (now - since < std::chrono::milliseconds(kBeatEveryMs)) {
42
+ continue;
43
+ }
44
+ since = now;
45
+ beat();
27
46
  }
28
47
 
48
+ beat();
49
+
29
50
  // Block until something arrives. MsgWaitForMultipleObjectsEx rather than
30
51
  // GetMessage, because the beat above may itself have posted work -- a mount
31
52
  // marshalled from the JS thread arrives as a posted message -- and
@@ -45,4 +66,13 @@ int runMessageLoopWithBeat(
45
66
  }
46
67
  }
47
68
 
69
+ int runMessageLoopWithBeat(
70
+ std::shared_ptr<facebook::react::RunLoopObserverManager> manager) {
71
+ return runMessageLoop([&manager] {
72
+ if (manager != nullptr) {
73
+ manager->onRender();
74
+ }
75
+ });
76
+ }
77
+
48
78
  } // namespace basalt
@@ -24,9 +24,32 @@
24
24
  //
25
25
  // So this is a loop rather than an observer, and the host calls it instead of
26
26
  // writing its own.
27
+ //
28
+ // ## "Until it comes up empty" is not enough on its own
29
+ //
30
+ // That rule assumes the queue eventually comes up empty, and in a real app it
31
+ // does not. kino's editor, whose preview is a Skia `<Canvas>`, posts work from
32
+ // the JavaScript thread around 250 times a second and invalidates its window
33
+ // around 120 -- so `PeekMessage` keeps answering, the drain keeps draining,
34
+ // and the beat below it waits for a gap that never comes. The queue is a
35
+ // treadmill and the beat is at the end of it.
36
+ //
37
+ // What that looks like from outside is the worst kind of bug: a button that
38
+ // usually works. The touch was hit-tested, the emitter was found, the event
39
+ // was enqueued -- and then sat in the queue until some accident of timing let
40
+ // the loop reach the bottom. Measured on that app, one scripted tap in three
41
+ // did nothing at all, and a real click, which lands later in a run and under
42
+ // more load, did nothing far more often than that.
43
+ //
44
+ // So the drain is bounded: the beat also happens every `kBeatEveryMs` and
45
+ // every `kBeatEveryMessages`, whichever comes first. Neither number is a
46
+ // tuning parameter to get right -- they are an upper bound on how long an
47
+ // event may sit, and the cost of beating early is one pass over an empty
48
+ // queue.
27
49
 
28
50
  #pragma once
29
51
 
52
+ #include <functional>
30
53
  #include <memory>
31
54
 
32
55
  namespace facebook::react {
@@ -35,8 +58,24 @@ class RunLoopObserverManager;
35
58
 
36
59
  namespace basalt {
37
60
 
38
- // Runs until WM_QUIT, inducing the beat each time the queue empties. Returns
39
- // the exit code from WM_QUIT.
61
+ // How long an event may wait, and how many messages may go by, before the
62
+ // beat happens whether or not the queue has emptied. Eight milliseconds is
63
+ // half a frame at sixty hertz: an event enqueued at the worst moment still
64
+ // reaches JavaScript inside the frame it belongs to.
65
+ inline constexpr unsigned kBeatEveryMs = 8;
66
+ inline constexpr int kBeatEveryMessages = 64;
67
+
68
+ // Runs until WM_QUIT, calling `beat` each time the queue empties -- and, on a
69
+ // queue that does not empty, at least every `kBeatEveryMs`. Returns the exit
70
+ // code from WM_QUIT.
71
+ //
72
+ // The beat is a parameter rather than the manager itself because the bound
73
+ // above is the whole point of this file and a test needs to be able to count
74
+ // beats. `beat` may post WM_QUIT, which is how a test ends the loop.
75
+ int runMessageLoop(const std::function<void()> &beat);
76
+
77
+ // The host's own entry point: the loop above, beating React Native's event
78
+ // queue. A null manager runs the loop with nothing to beat.
40
79
  int runMessageLoopWithBeat(std::shared_ptr<facebook::react::RunLoopObserverManager> manager);
41
80
 
42
81
  } // namespace basalt
@@ -6,6 +6,7 @@
6
6
  #include <utility>
7
7
 
8
8
  #include "PlatformServices.h"
9
+ #include "Win32UiTimer.h"
9
10
 
10
11
  #include <react/renderer/components/scrollview/ScrollEvent.h>
11
12
  #include <react/renderer/components/scrollview/ScrollViewEventEmitter.h>
@@ -437,34 +438,6 @@ void Win32ScrollViewManager::endWheelDrag(Tag tag, std::uint64_t generation) {
437
438
  // Offset, events and state
438
439
  // ---------------------------------------------------------------------------
439
440
 
440
- namespace {
441
-
442
- // Which manager and view a timer is animating. A TIMERPROC carries no state, so
443
- // the id is the key -- and the map is the reason `stopAnimation` must run before
444
- // an entry goes, exactly as the display link does on AppKit.
445
- std::unordered_map<UINT_PTR, std::pair<Win32ScrollViewManager *, facebook::react::Tag>> &
446
- animations() {
447
- static std::unordered_map<UINT_PTR, std::pair<Win32ScrollViewManager *, facebook::react::Tag>>
448
- value;
449
- return value;
450
- }
451
-
452
- } // namespace
453
-
454
- void CALLBACK Win32ScrollViewManager::onAnimationTimer(HWND /*hwnd*/,
455
- UINT /*message*/,
456
- UINT_PTR id,
457
- DWORD now) {
458
- const auto it = animations().find(id);
459
- if (it == animations().end()) {
460
- KillTimer(nullptr, id);
461
- return;
462
- }
463
- Win32ScrollViewManager *manager = it->second.first;
464
- const facebook::react::Tag tag = it->second.second;
465
- manager->advanceAnimation(tag, static_cast<double>(now) / 1000.0);
466
- }
467
-
468
441
  void Win32ScrollViewManager::scrollTowards(Entry &entry, double x, double y, bool animated) {
469
442
  stopAnimation(entry);
470
443
  if (!animated || !entry.animation.start(entry.offsetX, entry.offsetY, x, y)) {
@@ -474,14 +447,21 @@ void Win32ScrollViewManager::scrollTowards(Entry &entry, double x, double y, boo
474
447
  // Sixteen milliseconds: this host's choreographer runs on a timer of the same
475
448
  // period, and matching it keeps a scroll and an animation stepping together
476
449
  // rather than beating against each other.
477
- entry.animationTimer = SetTimer(nullptr, 0, 16, onAnimationTimer);
450
+ //
451
+ // The callback carries the tag rather than being looked up from a timer id,
452
+ // which is what a TIMERPROC forced and what the map here used to be for.
453
+ const facebook::react::Tag tag = entry.tag;
454
+ entry.animationTimer = scheduleUiTimer(16, true, [this, tag] {
455
+ advanceAnimation(tag, static_cast<double>(GetTickCount64()) / 1000.0);
456
+ });
478
457
  if (entry.animationTimer == 0) {
458
+ // No loop to animate on -- the unit tests, which install none. Land on the
459
+ // target rather than sit half-way to it forever.
479
460
  entry.animation.stop();
480
461
  applyOffset(entry, x, y, true);
481
462
  return;
482
463
  }
483
464
  entry.animationLastMillis = 0;
484
- animations()[entry.animationTimer] = {this, entry.tag};
485
465
  }
486
466
 
487
467
  bool Win32ScrollViewManager::settleOnSnapPoint(Entry &entry, double velocityY) {
@@ -500,8 +480,9 @@ bool Win32ScrollViewManager::settleOnSnapPoint(Entry &entry, double velocityY) {
500
480
  void Win32ScrollViewManager::stopAnimation(Entry &entry) {
501
481
  entry.animation.stop();
502
482
  if (entry.animationTimer != 0) {
503
- KillTimer(nullptr, entry.animationTimer);
504
- animations().erase(entry.animationTimer);
483
+ // Called from inside the animation's own callback when the scroll
484
+ // finishes, which `cancelUiTimer` is written to accept.
485
+ cancelUiTimer(entry.animationTimer);
505
486
  entry.animationTimer = 0;
506
487
  }
507
488
  }
@@ -153,11 +153,13 @@ class Win32ScrollViewManager {
153
153
 
154
154
  // An animated `scrollTo`. Windows models no fling -- a wheel supplies no
155
155
  // velocity -- but an animated scroll is given its target, so the argument
156
- // that rules out momentum does not rule this out. Driven by a thread timer,
157
- // which needs no window: the scroll manager has a tag, not an HWND.
156
+ // that rules out momentum does not rule this out. Driven by a
157
+ // win32/Win32UiTimer.h timer rather than by `SetTimer`, for the reason
158
+ // that header gives: a WM_TIMER at sixty hertz stops arriving in an app
159
+ // that repaints continuously, which is to say mid-scroll.
158
160
  ScrollSnapConfig snap;
159
161
  ScrollAnimation animation;
160
- UINT_PTR animationTimer{0};
162
+ std::uint64_t animationTimer{0};
161
163
  unsigned long long animationLastMillis{0};
162
164
 
163
165
  std::shared_ptr<const facebook::react::ScrollViewShadowNode::ConcreteState> state;
@@ -236,9 +238,6 @@ class Win32ScrollViewManager {
236
238
  void stopAnimation(Entry &entry);
237
239
  void advanceAnimation(facebook::react::Tag tag, double seconds);
238
240
 
239
- // The timer procedure cannot carry a pointer, so it looks the animation up by
240
- // timer id. Static because `SetTimer` calls a plain function.
241
- static void CALLBACK onAnimationTimer(HWND hwnd, UINT message, UINT_PTR id, DWORD now);
242
241
  void emitScrollEvent(Entry &entry, const char *which);
243
242
  void writeStateOffset(const Entry &entry);
244
243
 
@@ -1,4 +1,25 @@
1
- // The Win32 half of a `<Canvas>`: a raster surface drawn in the paint walk.
1
+ // The Win32 half of a `<Canvas>`: a surface drawn in the paint walk.
2
+ //
3
+ // ## Two implementations, one pair of functions
4
+ //
5
+ // Both generations of react-native-skia answer to the two functions at the
6
+ // bottom of this file, and they are never both linked: the 2.x one is
7
+ // `Win32SkiaPeer.cpp` beside this header, compiled when core's cmake/Skia.cmake
8
+ // found a 2.x install, and the 3.x one is basalt-skia's
9
+ // `win32/BasaltSkiaCanvasWin32.cpp`, compiled when it found a 3.x one.
10
+ // cmake/Skia.cmake refuses a 3.x install by version, so exactly one definition
11
+ // of each exists. Win32MountingManager.cpp calls them under a guard that
12
+ // covers either, the way the AppKit one does -- see AppKitSkiaPeer.h, which is
13
+ // this arrangement on the other host.
14
+ //
15
+ // They differ in the component they answer to, which is 3.x's rename: a 2.x
16
+ // `<Canvas>` mounts a `SkiaPictureView` and a 3.x one a `SkiaView`. Both
17
+ // descriptors are registered on all three hosts.
18
+ //
19
+ // What follows describes the 2.x one. The 3.x one reaches the same place by a
20
+ // different route -- it renders on the GPU through Dawn and D3D12 and reads
21
+ // the frame back, rather than rasterising on the CPU -- and for the same
22
+ // reason, which is the next paragraph. BasaltSkiaCanvasWin32.h has its half.
2
23
  //
3
24
  // The other two hosts give the package a layer of its own -- a CAMetalLayer on
4
25
  // macOS, a GL surface on GTK -- and let it present straight to the compositor.
@@ -39,9 +60,10 @@ namespace win32 {
39
60
  class RnWin32View;
40
61
  }
41
62
 
42
- // Gives `view` a Skia canvas if `shadowView` is a SkiaPictureView, updating its
43
- // size if it already has one. False when the component is something else, so
44
- // the caller can treat this as a filter rather than asking twice.
63
+ // Gives `view` a Skia canvas if `shadowView` is a canvas view -- a
64
+ // SkiaPictureView on 2.x, a SkiaView on 3.x -- updating its size if it
65
+ // already has one. False when the component is something else, so the caller
66
+ // can treat this as a filter rather than asking twice.
45
67
  //
46
68
  // `requestRepaint` is how a picture pushed from JavaScript reaches the screen.
47
69
  // Windows repaints on demand, so rendering a frame into the surface changes
@@ -1,7 +1,9 @@
1
1
  #include "Win32TextInput.h"
2
2
 
3
3
  #include "DirectWriteLayout.h"
4
+ #include "DragAndDrop.h"
4
5
  #include "TextChecking.h"
6
+ #include "Win32PeerClip.h"
5
7
  #include "Win32InputScopes.h"
6
8
  #include "Win32Strings.h"
7
9
 
@@ -64,61 +66,37 @@ COLORREF toColorRef(const float components[4]) {
64
66
  return RGB(channel(components[0]), channel(components[1]), channel(components[2]));
65
67
  }
66
68
 
67
- // A view's rectangle in the surface root's coordinates, and whether any of it is
68
- // still visible.
69
+ // Where a peer is placed and what of it is still the peer's: see
70
+ // win32/Win32PeerClip.h, which is the whole of the geometry. It lives there
71
+ // rather than here so that it can be tested against a view tree with no window
72
+ // and no React Native, the same split Win32TitleBarLayout made.
69
73
  //
70
74
  // Translation only: an EDIT is a window and a window cannot be rotated, so a
71
75
  // transformed field's peer sits where the untransformed one would. That is a
72
76
  // real difference from the painted view behind it, and there is no cheap fix --
73
77
  // see the header.
74
- //
75
- // Visibility is the other half. A child window is not clipped by anything in
76
- // the React tree, so a field scrolled out of its list would otherwise go on
77
- // being drawn over the list's neighbours. Any clipping ancestor that the
78
- // rectangle has left entirely hides it.
79
- bool peerRect(RnWin32View *view, RnWin32View *root, RECT &out) {
80
- if (view == nullptr || root == nullptr) {
81
- return false;
82
- }
83
78
 
84
- double x = 0.0;
85
- double y = 0.0;
86
- const double width = view->frame().width;
87
- const double height = view->frame().height;
79
+ RECT intersectRects(const RECT &a, const RECT &b) {
80
+ return RECT{std::max(a.left, b.left),
81
+ std::max(a.top, b.top),
82
+ std::min(a.right, b.right),
83
+ std::min(a.bottom, b.bottom)};
84
+ }
88
85
 
89
- for (RnWin32View *current = view; current != nullptr; current = current->parent()) {
90
- if (current->hidden()) {
91
- return false;
92
- }
93
- if (current == root) {
94
- break;
95
- }
96
- x += current->frame().x;
97
- y += current->frame().y;
86
+ RECT offsetBy(const RECT &rect, LONG dx, LONG dy) {
87
+ return RECT{rect.left + dx, rect.top + dy, rect.right + dx, rect.bottom + dy};
88
+ }
98
89
 
99
- RnWin32View *parent = current->parent();
100
- if (parent == nullptr) {
101
- // Detached: between a Remove and its Delete, or never inserted. Not on
102
- // screen either way.
90
+ bool sameShape(const std::vector<RECT> &a, const std::vector<RECT> &b) {
91
+ if (a.size() != b.size()) {
92
+ return false;
93
+ }
94
+ for (size_t i = 0; i < a.size(); i++) {
95
+ if (a[i].left != b[i].left || a[i].top != b[i].top || a[i].right != b[i].right ||
96
+ a[i].bottom != b[i].bottom) {
103
97
  return false;
104
98
  }
105
- x -= parent->scrollX();
106
- y -= parent->scrollY();
107
-
108
- if (parent->clipsChildren()) {
109
- // In the parent's own coordinates the box is 0..width, 0..height, and
110
- // (x, y) is where this view now sits relative to it.
111
- if (x + width <= 0 || y + height <= 0 || x >= parent->frame().width ||
112
- y >= parent->frame().height) {
113
- return false;
114
- }
115
- }
116
99
  }
117
-
118
- out.left = static_cast<LONG>(std::lround(x));
119
- out.top = static_cast<LONG>(std::lround(y));
120
- out.right = out.left + static_cast<LONG>(std::lround(width));
121
- out.bottom = out.top + static_cast<LONG>(std::lround(height));
122
100
  return true;
123
101
  }
124
102
 
@@ -157,8 +135,29 @@ void Win32TextInputManager::update(RnWin32View *view, const ShadowView &shadowVi
157
135
  // that still behaves as one line. So a field that changes the prop has its
158
136
  // peer rebuilt, which is rare enough to be worth the simplicity: React Native
159
137
  // apps choose multiline per field rather than toggling it.
138
+ // `acceptDragAndDropTypes`: the kinds this field will take on a drop, which
139
+ // makes it a drop target in the sense core/DragAndDrop.h means. Android
140
+ // implements the prop as a filter over the platform's own drop handling and
141
+ // this is that filter: a list naming only text refuses a dragged file, so
142
+ // the cursor says no before the drop rather than the app rejecting it after.
143
+ //
144
+ // Cleared when the prop goes away, which is the case a field that stops
145
+ // accepting must not keep accepting.
146
+ if (view != nullptr) {
147
+ view->setDropAccepts(props != nullptr && props->acceptDragAndDropTypes.has_value()
148
+ ? dropAcceptsFromTypes(*props->acceptDragAndDropTypes)
149
+ : DropAcceptsNone);
150
+ }
151
+
160
152
  const bool multiline = props != nullptr && props->multiline;
161
- if (entry.control != nullptr && multiline != entry.multiline) {
153
+ // `scrollEnabled`, the other creation-time style bit. React Native documents
154
+ // it as multiline-only -- a single line scrolls sideways through
155
+ // `ES_AUTOHSCROLL` and the prop says nothing about that -- so it is only
156
+ // compared for a multiline field, and a single-line one that sets it does
157
+ // not get its peer rebuilt for nothing.
158
+ const bool scrollEnabled = props == nullptr || props->traits.scrollEnabled;
159
+ const bool scrollChanged = multiline && scrollEnabled != entry.scrollEnabled;
160
+ if (entry.control != nullptr && (multiline != entry.multiline || scrollChanged)) {
162
161
  const bool hadFocus = GetFocus() == entry.control;
163
162
  destroyPeer(entry);
164
163
  entry.view = view;
@@ -184,9 +183,18 @@ void Win32TextInputManager::update(RnWin32View *view, const ShadowView &shadowVi
184
183
  // scroll is what lets the caret leave the bottom of the box. No `WS_VSCROLL`
185
184
  // on purpose -- a scrollbar inside a styled field is a Win32 scrollbar
186
185
  // drawn over a React Native background, and neither other host shows one.
186
+ //
187
+ // `scrollEnabled: false` is that vertical scroll withheld, which is what
188
+ // the prop asks for: the field stops following the caret past the bottom
189
+ // of the box and the text below is simply not shown. An EDIT without
190
+ // `ES_AUTOVSCROLL` still accepts the text -- it is a display decision and
191
+ // not a limit, which is the same distinction `maxLength` is on the other
192
+ // side of.
187
193
  entry.multiline = multiline;
194
+ entry.scrollEnabled = scrollEnabled;
188
195
  const DWORD style = WS_CHILD | WS_CLIPSIBLINGS | ES_LEFT
189
- | (multiline ? (ES_MULTILINE | ES_AUTOVSCROLL) : ES_AUTOHSCROLL);
196
+ | (multiline ? (ES_MULTILINE | (scrollEnabled ? ES_AUTOVSCROLL : 0u))
197
+ : ES_AUTOHSCROLL);
190
198
  entry.control = CreateWindowEx(0,
191
199
  L"EDIT",
192
200
  L"",
@@ -596,6 +604,9 @@ void Win32TextInputManager::destroyPeer(Entry &entry) {
596
604
  DestroyWindow(entry.control);
597
605
  entry.control = nullptr;
598
606
  }
607
+ // The window took the region with it, and a rebuilt peer -- which is what a
608
+ // field switching to multiline gets -- starts without one.
609
+ entry.regionShape.clear();
599
610
  if (entry.font != nullptr) {
600
611
  DeleteObject(entry.font);
601
612
  entry.font = nullptr;
@@ -630,11 +641,12 @@ void Win32TextInputManager::syncBounds(RnWin32View *root) {
630
641
  if (entry.control == nullptr) {
631
642
  continue;
632
643
  }
633
- RECT rect{};
634
- if (!peerRect(entry.view, root, rect)) {
644
+ const win32::PeerClip placement = win32::peerClip(entry.view, root);
645
+ if (!placement.visible) {
635
646
  ShowWindow(entry.control, SW_HIDE);
636
647
  continue;
637
648
  }
649
+ RECT rect = placement.bounds;
638
650
 
639
651
  // Inside the content insets, so that `paddingHorizontal` on a field means
640
652
  // what it means on a <View>. GTK allocates its GtkText inside the same
@@ -673,6 +685,74 @@ void Win32TextInputManager::syncBounds(RnWin32View *root) {
673
685
  continue;
674
686
  }
675
687
 
688
+ // What of the control is still the control's, in its own coordinates: the
689
+ // part of it inside every clipping ancestor, less everything painted over
690
+ // it. See Win32PeerClip.h -- this is the half that turns those rectangles
691
+ // into the one thing USER32 understands, which is a window region.
692
+ //
693
+ // The first piece is what is left of the control, and each one after it is
694
+ // taken out.
695
+ std::vector<RECT> shape;
696
+ const RECT visible = offsetBy(intersectRects(placement.clip, rect), -rect.left, -rect.top);
697
+ if (visible.right > visible.left && visible.bottom > visible.top) {
698
+ shape.push_back(visible);
699
+ for (const RECT &occluder : placement.occluders) {
700
+ const RECT over = offsetBy(intersectRects(occluder, rect), -rect.left, -rect.top);
701
+ if (over.right > over.left && over.bottom > over.top) {
702
+ shape.push_back(over);
703
+ }
704
+ }
705
+ }
706
+
707
+ // Whole and unobstructed is the common case and gets no region at all:
708
+ // `SetWindowRgn` redraws the control, and this runs after every wheel
709
+ // notch, so a field in a list being scrolled would otherwise be told to
710
+ // redraw sixty times a second for a shape that has not changed.
711
+ HRGN region = nullptr;
712
+ const bool whole = shape.size() == 1 && shape[0].left == 0 && shape[0].top == 0 &&
713
+ shape[0].right == rect.right - rect.left && shape[0].bottom == rect.bottom - rect.top;
714
+ if (whole) {
715
+ shape.clear();
716
+ } else if (!shape.empty()) {
717
+ // The system owns the region once it is handed over, so the pieces taken
718
+ // out of it are deleted here and the result is not.
719
+ region = CreateRectRgn(shape[0].left, shape[0].top, shape[0].right, shape[0].bottom);
720
+ for (size_t i = 1; i < shape.size(); i++) {
721
+ HRGN piece = CreateRectRgn(shape[i].left, shape[i].top, shape[i].right, shape[i].bottom);
722
+ CombineRgn(region, region, piece, RGN_DIFF);
723
+ DeleteObject(piece);
724
+ }
725
+ RECT box{};
726
+ if (GetRgnBox(region, &box) == NULLREGION) {
727
+ // The pieces swallowed it: the field is wholly behind something rather
728
+ // than partly, which is what a modal over a pane looks like.
729
+ DeleteObject(region);
730
+ region = nullptr;
731
+ shape.clear();
732
+ ShowWindow(entry.control, SW_HIDE);
733
+ entry.regionShape.clear();
734
+ SetWindowRgn(entry.control, nullptr, FALSE);
735
+ // And it does not keep the keyboard. A field left focused behind a
736
+ // modal swallows every key the modal wanted, Escape included, so the
737
+ // sheet that covered it cannot be closed -- the same bug as the field
738
+ // being visible over the modal, one layer further in. The question does
739
+ // not arise on the other two desktops: a field there is a widget, and a
740
+ // widget that is not drawn is not in the focus chain.
741
+ if (GetFocus() == entry.control) {
742
+ moveFocusToHost();
743
+ }
744
+ continue;
745
+ }
746
+ } else {
747
+ // Out of sight behind a clipping ancestor, which `placement.visible`
748
+ // catches when it is complete and this catches when the control's own
749
+ // strip is what fell outside.
750
+ ShowWindow(entry.control, SW_HIDE);
751
+ entry.regionShape.clear();
752
+ SetWindowRgn(entry.control, nullptr, FALSE);
753
+ continue;
754
+ }
755
+
676
756
  SetWindowPos(entry.control,
677
757
  nullptr,
678
758
  rect.left,
@@ -680,6 +760,17 @@ void Win32TextInputManager::syncBounds(RnWin32View *root) {
680
760
  rect.right - rect.left,
681
761
  rect.bottom - rect.top,
682
762
  SWP_NOZORDER | SWP_NOACTIVATE | SWP_SHOWWINDOW);
763
+
764
+ if (sameShape(shape, entry.regionShape)) {
765
+ if (region != nullptr) {
766
+ DeleteObject(region);
767
+ }
768
+ continue;
769
+ }
770
+ entry.regionShape = shape;
771
+ // A null region removes the one the control had, which is what a field
772
+ // that has come back into the clear needs.
773
+ SetWindowRgn(entry.control, region, TRUE);
683
774
  }
684
775
  }
685
776
 
@@ -29,11 +29,13 @@
29
29
  // mutation arrives at all. `syncBounds` is that, and the host calls it after
30
30
  // every transaction and every wheel.
31
31
  //
32
- // - **It is always on top.** Child windows paint above whatever Direct2D drew,
33
- // in an order the React tree has no say in. A field cannot be covered by a
34
- // later sibling, and it does not clip to a scrolled ancestor -- it is hidden
35
- // when it leaves the ancestor's box instead, which is close enough to look
36
- // right and is not the same thing.
32
+ // - **It would be on top of everything.** Child windows paint above whatever
33
+ // Direct2D drew, in an order the React tree has no say in, so a field
34
+ // neither clips to a scrolled ancestor nor can be covered by a later
35
+ // sibling. `SetWindowRgn` is where the missing order goes, and working the
36
+ // region out from the tree is `Win32PeerClip.h`; `syncBounds` installs it.
37
+ // What is still not possible is a transform, because a window cannot be
38
+ // rotated -- see docs/backlog/platform-windows.md.
37
39
  //
38
40
  // - **Its events arrive at the parent.** EN_CHANGE, EN_SETFOCUS and
39
41
  // EN_KILLFOCUS are WM_COMMAND notifications to the host window, and the
@@ -110,9 +112,11 @@ class Win32TextInputManager {
110
112
  const std::string &name,
111
113
  const folly::dynamic &args);
112
114
 
113
- // Moves every peer to where its view now is, in client coordinates, and hides
114
- // the ones that have gone out of sight. Called after each transaction and
115
- // each scroll: a child window does not follow a view that has no widget.
115
+ // Moves every peer to where its view now is, in client coordinates, clips it
116
+ // to what the tree leaves of it, and hides the ones that have gone out of
117
+ // sight or behind something. Called after each transaction and each scroll:
118
+ // a child window does not follow a view that has no widget, and the region
119
+ // it is clipped to changes with every one of them. See Win32PeerClip.h.
116
120
  void syncBounds(win32::RnWin32View *root);
117
121
 
118
122
  // WM_COMMAND from the host: EN_CHANGE, EN_SETFOCUS, EN_KILLFOCUS. True when
@@ -260,6 +264,11 @@ class Win32TextInputManager {
260
264
  // created and cannot be added afterwards, so this is what a change in the
261
265
  // prop is compared against: a field that switches has its peer rebuilt.
262
266
  bool multiline{false};
267
+ // `scrollEnabled`, which is part of the same creation-time decision as
268
+ // `multiline`: it is the `ES_AUTOVSCROLL` style bit, read when the window
269
+ // is made. Remembered so a change rebuilds the peer the way a change of
270
+ // `multiline` does.
271
+ bool scrollEnabled{true};
263
272
 
264
273
  // Yoga resolves border and padding into contentInsets, so the same twelve
265
274
  // points mean the same thing in a field as in a <View>. Applied by placing
@@ -273,6 +282,17 @@ class Win32TextInputManager {
273
282
  // is set to so that the text sits in the middle of the field.
274
283
  LONG lineHeight{0};
275
284
 
285
+ // The window region last installed, as the rectangles it was built from in
286
+ // the control's own coordinates: the visible box first, then each piece
287
+ // taken out of it. Empty means no region, which is a whole unobstructed
288
+ // field -- the common case, and the one worth not touching.
289
+ //
290
+ // Held because `SetWindowRgn` redraws the control, and `syncBounds` runs
291
+ // after every transaction and every wheel notch: a field in a list being
292
+ // scrolled would be told to redraw on every one of them for a region that
293
+ // has not changed. See `syncBounds` and Win32PeerClip.h.
294
+ std::vector<RECT> regionShape;
295
+
276
296
  // The input scope last installed for `keyboardType`, and whether one has
277
297
  // been. `SetInputScope` replaces the window's whole scope list, so it is
278
298
  // called on a change rather than on every props update -- a controlled
@@ -24,4 +24,15 @@ void installUiThread();
24
24
  // Whether `postToUiThread` will actually marshal, rather than running inline.
25
25
  bool hasUiThread();
26
26
 
27
+ // Takes it down again, and runs anything still queued on the calling thread so
28
+ // that nothing posted is lost. For tests, and this is the one that needs it:
29
+ // react-native-skia 3.x records a frame on a thread pool of its own and asks
30
+ // the UI thread to present it, and the producer holds a lock across that ask.
31
+ // Run inline -- which is what posting with no window installed means on a
32
+ // worker, exactly as the note above warns -- it re-enters the producer on the
33
+ // thread that already holds the lock, and the suite dies with no message. So
34
+ // the canvas test installs a real loop, pumps it, and puts things back for the
35
+ // tests after it, which are written against the inline behaviour.
36
+ void uninstallUiThread();
37
+
27
38
  } // namespace basalt