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
@@ -0,0 +1,122 @@
1
+ // The message loop, and the one thing it has to get right that is not
2
+ // obvious. See win32/Win32RunLoopObserver.h.
3
+
4
+ #include "TestHarness.h"
5
+
6
+ #include "Win32RunLoopObserver.h"
7
+
8
+ #include <windows.h>
9
+
10
+ #include <chrono>
11
+
12
+ namespace {
13
+
14
+ constexpr UINT kFlood = WM_APP + 11;
15
+
16
+ // A message-only window whose only message reposts itself, so the queue is
17
+ // never observed empty. `flooding` ends it, since a message still in the
18
+ // queue when the loop stops would be pumped by whatever runs next.
19
+ bool gFlooding = true;
20
+ long gFlooded = 0;
21
+
22
+ LRESULT CALLBACK floodProc(HWND hwnd, UINT message, WPARAM w, LPARAM l) {
23
+ if (message == kFlood) {
24
+ gFlooded++;
25
+ // A ceiling, so that a loop which never beats fails rather than hangs:
26
+ // the beat is what stops the flood and posts WM_QUIT, and a regression
27
+ // here is precisely a beat that never happens.
28
+ if (gFlooded > 50000) {
29
+ gFlooding = false;
30
+ }
31
+ if (gFlooding) {
32
+ PostMessageW(hwnd, kFlood, 0, 0);
33
+ }
34
+ return 0;
35
+ }
36
+ return DefWindowProcW(hwnd, message, w, l);
37
+ }
38
+
39
+ HWND makeFloodWindow() {
40
+ WNDCLASSEXW windowClass{};
41
+ windowClass.cbSize = sizeof(windowClass);
42
+ windowClass.lpfnWndProc = floodProc;
43
+ windowClass.hInstance = GetModuleHandleW(nullptr);
44
+ windowClass.lpszClassName = L"BasaltRunLoopFloodTest";
45
+ RegisterClassExW(&windowClass);
46
+ return CreateWindowExW(0,
47
+ L"BasaltRunLoopFloodTest",
48
+ L"",
49
+ 0,
50
+ 0,
51
+ 0,
52
+ 0,
53
+ 0,
54
+ HWND_MESSAGE,
55
+ nullptr,
56
+ GetModuleHandleW(nullptr),
57
+ nullptr);
58
+ }
59
+
60
+ void dropFloodWindow(HWND window) {
61
+ gFlooding = false;
62
+ DestroyWindow(window);
63
+ UnregisterClassW(L"BasaltRunLoopFloodTest", GetModuleHandleW(nullptr));
64
+ // Whatever is left of the flood, so the next test's pump does not inherit
65
+ // it.
66
+ MSG message;
67
+ while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
68
+ }
69
+ }
70
+
71
+ } // namespace
72
+
73
+ TEST(run_loop_beats_once_when_the_queue_empties) {
74
+ int beats = 0;
75
+ PostMessageW(nullptr, WM_NULL, 0, 0);
76
+ const int code = basalt::runMessageLoop([&beats] {
77
+ beats++;
78
+ PostQuitMessage(7);
79
+ });
80
+ EXPECT_EQ(code, 7);
81
+ EXPECT_EQ(beats, 1);
82
+ }
83
+
84
+ TEST(run_loop_beats_on_a_queue_that_never_empties) {
85
+ // The regression this file exists for.
86
+ //
87
+ // The loop drains before it beats, which is what "BeforeWaiting" means and
88
+ // what React Native's event queue is flushed by. An app that paints
89
+ // continuously -- kino, whose preview is a Skia `<Canvas>`: 120 WM_PAINT
90
+ // and 250 posted work items a second while idle -- never lets the drain
91
+ // finish, so the beat never ran and events sat in the queue. What that
92
+ // looked like from outside was a button that usually worked: one scripted
93
+ // tap in three did nothing, and a real click rather more often than that.
94
+ //
95
+ // Without the bound in the loop this test does not fail slowly. It never
96
+ // beats at all, so it never posts WM_QUIT, and the deadline below is the
97
+ // only thing that ends it.
98
+ gFlooding = true;
99
+ gFlooded = 0;
100
+ HWND window = makeFloodWindow();
101
+ EXPECT(window != nullptr);
102
+ PostMessageW(window, kFlood, 0, 0);
103
+
104
+ int beats = 0;
105
+ const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
106
+ basalt::runMessageLoop([&] {
107
+ beats++;
108
+ // Several, so this is about a loop that keeps beating rather than one
109
+ // that beat once on the way in.
110
+ if (beats >= 4 || std::chrono::steady_clock::now() > deadline) {
111
+ gFlooding = false;
112
+ PostQuitMessage(0);
113
+ }
114
+ });
115
+
116
+ EXPECT(beats >= 4);
117
+ // ...and the flood really was running, so the beats above happened in spite
118
+ // of it rather than after it ran out.
119
+ EXPECT(gFlooded > 100);
120
+
121
+ dropFloodWindow(window);
122
+ }
@@ -33,6 +33,8 @@
33
33
 
34
34
  #include <cstdint>
35
35
  #include <memory>
36
+ #include <optional>
37
+ #include <sstream>
36
38
  #include <string>
37
39
  #include <utility>
38
40
  #include <vector>
@@ -177,6 +179,9 @@ struct FieldOptions {
177
179
  unsigned int placeholderTextColor{0};
178
180
  unsigned int selectionColor{0};
179
181
  unsigned int cursorColor{0};
182
+ // `scrollEnabled`, which defaults to true the way the trait does. Sent only
183
+ // when false so the other tests' raw props stay as they were.
184
+ bool scrollEnabled{true};
180
185
  };
181
186
 
182
187
  ShadowView makeField(Tag tag,
@@ -194,6 +199,9 @@ ShadowView makeField(Tag tag,
194
199
  "clearTextOnFocus", options.clearTextOnFocus)(
195
200
  "selectTextOnFocus", options.selectTextOnFocus)("caretHidden", options.caretHidden)(
196
201
  "contextMenuHidden", options.contextMenuHidden);
202
+ if (!options.scrollEnabled) {
203
+ raw["scrollEnabled"] = false;
204
+ }
197
205
  if (!options.submitBehavior.empty()) {
198
206
  raw["submitBehavior"] = options.submitBehavior;
199
207
  }
@@ -326,6 +334,34 @@ RECT rectOf(HWND control) {
326
334
  return rect;
327
335
  }
328
336
 
337
+ // The window region installed on a control, as the box around it, in the
338
+ // control's own coordinates. Absent when the control has no region at all,
339
+ // which is what a whole unobstructed field gets.
340
+ //
341
+ // An empty box -- `0,0 0,0` -- is a region with nothing in it, which is a
342
+ // field that is entirely behind something.
343
+ std::optional<RECT> regionBoxOf(HWND control) {
344
+ HRGN region = CreateRectRgn(0, 0, 0, 0);
345
+ const int kind = GetWindowRgn(control, region);
346
+ std::optional<RECT> box;
347
+ // GDI's `ERROR` is zero, and the name is not reachable here: this build
348
+ // defines GLOG_NO_ABBREVIATED_SEVERITIES, which is what keeps glog from
349
+ // fighting wingdi.h over it, and wingdi.h's own definition loses to it.
350
+ if (kind != 0) {
351
+ RECT rect{0, 0, 0, 0};
352
+ GetRgnBox(region, &rect);
353
+ box = rect;
354
+ }
355
+ DeleteObject(region);
356
+ return box;
357
+ }
358
+
359
+ std::string describeRect(const RECT &rect) {
360
+ std::ostringstream out;
361
+ out << rect.left << "," << rect.top << " " << rect.right << "," << rect.bottom;
362
+ return out.str();
363
+ }
364
+
329
365
  } // namespace
330
366
 
331
367
  // A mounted field gets a real control, and the host's window is its parent.
@@ -576,6 +612,84 @@ TEST(win32_a_peer_scrolled_out_of_view_is_hidden) {
576
612
  manager->destroySurfaceRoot(kSurfaceId);
577
613
  }
578
614
 
615
+ // Half out of its list is half drawn, which hiding it entirely was never the
616
+ // same as. A window region is what says so to USER32; see Win32PeerClip.h.
617
+ //
618
+ // The field is multiline so that the control fills its box exactly -- a
619
+ // single-line peer is a strip the height of one line, centred, and these
620
+ // numbers would then be the font's.
621
+ TEST(win32_a_peer_half_out_of_its_list_is_clipped_to_it) {
622
+ auto manager = makeManager();
623
+ RnWin32View *root = manager->createSurfaceRoot(kSurfaceId);
624
+ root->setFrame(0, 0, 800, 600);
625
+ mount(*manager, kSurfaceId, makeView(10, 0, 0, 400, 100));
626
+ manager->viewForTag(10)->setClipsChildren(true);
627
+ mount(*manager, 10, makeField(11, 0, 60, 200, 80, {.multiline = true}));
628
+ manager->syncTextInputBounds(root);
629
+
630
+ HWND control = peerOf(manager->viewForTag(11));
631
+ EXPECT_EQ(describeRect(rectOf(control)), std::string("0,60 200,140"));
632
+ // The list ends at y=100, so the bottom 40 of the field is not the field's.
633
+ const auto clipped = regionBoxOf(control);
634
+ EXPECT(clipped.has_value());
635
+ EXPECT_EQ(describeRect(clipped.value_or(RECT{})), std::string("0,0 200,40"));
636
+
637
+ // Scrolled until it is wholly inside, the region goes away rather than
638
+ // becoming the whole control: SetWindowRgn redraws, and a field in a list
639
+ // being scrolled would otherwise be told to on every notch.
640
+ manager->viewForTag(10)->setScrollOffset(0, 60);
641
+ manager->syncTextInputBounds(root);
642
+ EXPECT(!regionBoxOf(control).has_value());
643
+
644
+ manager->destroySurfaceRoot(kSurfaceId);
645
+ }
646
+
647
+ // A field cannot be covered by anything the tree paints after it, because the
648
+ // peer is a child window and USER32 draws those last. Open a modal over a pane
649
+ // with a field in it and the field floats on top of the modal, still editable.
650
+ // Taking the modal's box out of the region is what stops that.
651
+ TEST(win32_a_peer_under_something_painted_later_is_cut_away) {
652
+ auto manager = makeManager();
653
+ RnWin32View *root = manager->createSurfaceRoot(kSurfaceId);
654
+ root->setFrame(0, 0, 800, 600);
655
+ // The backdrop is mounted first and the field second, and both go in at
656
+ // index 0 -- so the children end up [field, backdrop] and the backdrop is
657
+ // painted after, which is where an overlay sits in a React tree.
658
+ mount(*manager, kSurfaceId, makeView(10, 0, 0, 800, 600));
659
+ mount(*manager, kSurfaceId, makeField(11, 0, 0, 200, 80, {.multiline = true}));
660
+ manager->syncTextInputBounds(root);
661
+
662
+ HWND control = peerOf(manager->viewForTag(11));
663
+ // A layout wrapper with no background of its own covers nothing, which is
664
+ // most of a real tree and the reason this rule is quiet in one.
665
+ EXPECT(!regionBoxOf(control).has_value());
666
+
667
+ // kino's modal scrim: #000000A0 over the whole window. Translucent counts --
668
+ // a child window cannot be dimmed by paint, so the choice is between showing
669
+ // it undimmed and hiding it, and hiding it is the better wrong answer.
670
+ manager->viewForTag(10)->setBackgroundColor(0.0f, 0.0f, 0.0f, 0.63f, true);
671
+ manager->syncTextInputBounds(root);
672
+ // Wholly behind it is hidden rather than given a region with nothing in it:
673
+ // the two look the same and only one of them stops Windows thinking there is
674
+ // a window there.
675
+ EXPECT(!IsWindowVisible(control));
676
+ EXPECT(!regionBoxOf(control).has_value());
677
+
678
+ // Half over it leaves the other half.
679
+ manager->viewForTag(10)->setFrame(0, 40, 800, 600);
680
+ manager->syncTextInputBounds(root);
681
+ const auto half = regionBoxOf(control);
682
+ EXPECT(half.has_value());
683
+ EXPECT_EQ(describeRect(half.value_or(RECT{})), std::string("0,0 200,40"));
684
+
685
+ // And once the modal closes the region goes with it.
686
+ manager->viewForTag(10)->setBackgroundColor(0.0f, 0.0f, 0.0f, 0.0f, false);
687
+ manager->syncTextInputBounds(root);
688
+ EXPECT(!regionBoxOf(control).has_value());
689
+
690
+ manager->destroySurfaceRoot(kSurfaceId);
691
+ }
692
+
579
693
  // focus, blur and setTextAndSelection, which arrive through dispatchCommand.
580
694
  TEST(win32_the_commands_reach_the_control) {
581
695
  auto manager = makeManager();
@@ -781,6 +895,91 @@ TEST(win32_a_multiline_field_is_a_multiline_control) {
781
895
  manager->destroySurfaceRoot(kSurfaceId);
782
896
  }
783
897
 
898
+ // `scrollEnabled: false`, which is that vertical scroll withheld. A
899
+ // creation-time style bit like `ES_MULTILINE` beside it, so the assertions are
900
+ // the same shape.
901
+ TEST(win32_a_field_that_disables_scrolling_has_no_vertical_scroll) {
902
+ auto manager = makeManager();
903
+ RnWin32View *root = manager->createSurfaceRoot(kSurfaceId);
904
+ root->setFrame(0, 0, 800, 600);
905
+ mount(*manager,
906
+ kSurfaceId,
907
+ makeField(10, 20, 30, 200, 120,
908
+ {.text = "", .multiline = true, .scrollEnabled = false}));
909
+ manager->syncTextInputBounds(root);
910
+
911
+ HWND control = peerOf(manager->viewForTag(10));
912
+ EXPECT(control != nullptr);
913
+ if (control == nullptr) {
914
+ return;
915
+ }
916
+ const LONG_PTR style = GetWindowLongPtr(control, GWL_STYLE);
917
+ // Still a multiline control: the prop is about scrolling and nothing else.
918
+ EXPECT((style & ES_MULTILINE) != 0);
919
+ EXPECT((style & ES_AUTOVSCROLL) == 0);
920
+ // And it still takes the text, which is the half that makes this a display
921
+ // decision rather than a limit.
922
+ type(control, L"a\rb");
923
+ EXPECT_EQ(textOf(control), std::string("a\r\nb"));
924
+
925
+ manager->destroySurfaceRoot(kSurfaceId);
926
+ }
927
+
928
+ // Only for a multiline field. A single line scrolls sideways through
929
+ // `ES_AUTOHSCROLL`, which React Native's prop says nothing about, so a field
930
+ // that sets it there is unchanged -- and in particular does not lose the
931
+ // horizontal scroll that lets the caret leave the right-hand edge.
932
+ TEST(win32_a_single_line_field_ignores_the_scroll_prop) {
933
+ auto manager = makeManager();
934
+ RnWin32View *root = manager->createSurfaceRoot(kSurfaceId);
935
+ root->setFrame(0, 0, 800, 600);
936
+ mount(*manager, kSurfaceId, makeField(10, 20, 30, 200, 44, {.scrollEnabled = false}));
937
+ manager->syncTextInputBounds(root);
938
+
939
+ HWND control = peerOf(manager->viewForTag(10));
940
+ EXPECT(control != nullptr);
941
+ if (control == nullptr) {
942
+ return;
943
+ }
944
+ EXPECT((GetWindowLongPtr(control, GWL_STYLE) & ES_AUTOHSCROLL) != 0);
945
+
946
+ manager->destroySurfaceRoot(kSurfaceId);
947
+ }
948
+
949
+ // The style cannot be added to a live control, so a field that changes the prop
950
+ // has its peer rebuilt -- the same rule `multiline` follows, and the text comes
951
+ // across for the same reason.
952
+ TEST(win32_changing_scroll_enabled_rebuilds_the_peer) {
953
+ auto manager = makeManager();
954
+ RnWin32View *root = manager->createSurfaceRoot(kSurfaceId);
955
+ root->setFrame(0, 0, 800, 600);
956
+ const ShadowView scrolling =
957
+ makeField(10, 20, 30, 200, 120, {.text = "wrapped", .multiline = true});
958
+ mount(*manager, kSurfaceId, scrolling);
959
+ manager->syncTextInputBounds(root);
960
+
961
+ HWND before = peerOf(manager->viewForTag(10));
962
+ EXPECT(before != nullptr);
963
+ EXPECT(before != nullptr && (GetWindowLongPtr(before, GWL_STYLE) & ES_AUTOVSCROLL) != 0);
964
+
965
+ update(*manager,
966
+ scrolling,
967
+ makeField(10, 20, 30, 200, 120,
968
+ {.text = "wrapped", .multiline = true, .scrollEnabled = false}));
969
+ manager->syncTextInputBounds(root);
970
+
971
+ HWND after = peerOf(manager->viewForTag(10));
972
+ EXPECT(after != nullptr);
973
+ if (after == nullptr) {
974
+ return;
975
+ }
976
+ EXPECT(after != before);
977
+ EXPECT((GetWindowLongPtr(after, GWL_STYLE) & ES_AUTOVSCROLL) == 0);
978
+ EXPECT_EQ(textOf(after), std::string("wrapped"));
979
+
980
+ manager->destroySurfaceRoot(kSurfaceId);
981
+ }
982
+
784
983
  // A single-line field still swallows Enter, which is the other half of the same
785
984
  // rule and the test that was here before this prop existed.
786
985
  TEST(win32_a_single_line_field_is_not_multiline) {
@@ -0,0 +1,195 @@
1
+ // Timers that arrive, including under the one condition that broke the ones
2
+ // before them. See win32/Win32UiTimer.h.
3
+
4
+ #include "TestHarness.h"
5
+
6
+ #include "Win32UiThread.h"
7
+ #include "Win32UiTimer.h"
8
+
9
+ #include <windows.h>
10
+
11
+ #include <chrono>
12
+ #include <functional>
13
+ #include <thread>
14
+
15
+ namespace {
16
+
17
+ // A real message loop for the duration of one test. Without one there is
18
+ // nothing to post to, `scheduleUiTimer` declines, and every assertion below
19
+ // would be about the declining rather than about the timer.
20
+ struct ScopedUiThread {
21
+ ScopedUiThread() { basalt::installUiThread(); }
22
+ ~ScopedUiThread() { basalt::uninstallUiThread(); }
23
+ ScopedUiThread(const ScopedUiThread &) = delete;
24
+ ScopedUiThread &operator=(const ScopedUiThread &) = delete;
25
+ };
26
+
27
+ // Pumps until `ready`, or until the deadline. Returns whether it came true.
28
+ //
29
+ // The inner drain is bounded rather than "until the queue is empty", which
30
+ // the real loop can afford and this cannot: one test here feeds the queue a
31
+ // message that reposts itself, and an unbounded drain never comes back out.
32
+ bool pumpUntil(const std::function<bool()> &ready) {
33
+ const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
34
+ while (std::chrono::steady_clock::now() < deadline) {
35
+ MSG message;
36
+ for (int drained = 0; drained < 64; drained++) {
37
+ if (!PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
38
+ break;
39
+ }
40
+ TranslateMessage(&message);
41
+ DispatchMessageW(&message);
42
+ }
43
+ if (ready()) {
44
+ return true;
45
+ }
46
+ std::this_thread::sleep_for(std::chrono::milliseconds(1));
47
+ }
48
+ return false;
49
+ }
50
+
51
+ } // namespace
52
+
53
+ TEST(ui_timer_fires_once_after_its_delay) {
54
+ ScopedUiThread loop;
55
+ int fired = 0;
56
+ const basalt::UiTimerId timer = basalt::scheduleUiTimer(10, false, [&fired] { fired++; });
57
+ EXPECT(timer != 0);
58
+ EXPECT(pumpUntil([&fired] { return fired > 0; }));
59
+ // And exactly once: a one-shot that repeated would be found by nothing else
60
+ // here, because every other assertion is satisfied by the first call.
61
+ const auto until = std::chrono::steady_clock::now() + std::chrono::milliseconds(120);
62
+ pumpUntil([&until] { return std::chrono::steady_clock::now() > until; });
63
+ EXPECT_EQ(fired, 1);
64
+ basalt::cancelUiTimer(timer);
65
+ }
66
+
67
+ TEST(ui_timer_repeats_until_it_is_cancelled) {
68
+ ScopedUiThread loop;
69
+ int fired = 0;
70
+ const basalt::UiTimerId timer = basalt::scheduleUiTimer(5, true, [&fired] { fired++; });
71
+ EXPECT(timer != 0);
72
+ EXPECT(pumpUntil([&fired] { return fired >= 3; }));
73
+ basalt::cancelUiTimer(timer);
74
+ const int afterCancel = fired;
75
+ const auto until = std::chrono::steady_clock::now() + std::chrono::milliseconds(80);
76
+ pumpUntil([&until] { return std::chrono::steady_clock::now() > until; });
77
+ EXPECT_EQ(fired, afterCancel);
78
+ }
79
+
80
+ TEST(ui_timer_can_be_cancelled_from_inside_its_own_callback) {
81
+ // How a repeating animation ends: the last frame stops the thing that
82
+ // delivered it. `cancelUiTimer` waits out a pool-thread delivery, so doing
83
+ // this on the wrong thread would deadlock rather than misbehave.
84
+ ScopedUiThread loop;
85
+ int fired = 0;
86
+ basalt::UiTimerId timer = 0;
87
+ timer = basalt::scheduleUiTimer(5, true, [&fired, &timer] {
88
+ fired++;
89
+ if (fired == 2) {
90
+ basalt::cancelUiTimer(timer);
91
+ }
92
+ });
93
+ EXPECT(pumpUntil([&fired] { return fired >= 2; }));
94
+ const auto until = std::chrono::steady_clock::now() + std::chrono::milliseconds(80);
95
+ pumpUntil([&until] { return std::chrono::steady_clock::now() > until; });
96
+ EXPECT_EQ(fired, 2);
97
+ }
98
+
99
+ TEST(ui_timer_arrives_on_a_queue_that_never_empties) {
100
+ // The whole reason this file exists.
101
+ //
102
+ // `WM_TIMER` is not posted. It is *synthesised* by `PeekMessage`, and only
103
+ // once the queue holds nothing else -- below posted messages, below input,
104
+ // below `WM_PAINT`. A loop that always has something to do therefore never
105
+ // gives a `WM_TIMER` the gap it needs, and every `SetTimer` in the host
106
+ // simply stopped. That is the ordinary condition of a real app: measured on
107
+ // kino, whose preview is a Skia `<Canvas>`, 120 `WM_PAINT` and 250 posted
108
+ // work items a second while idle, against six `WM_TIMER` in twenty-five
109
+ // seconds.
110
+ //
111
+ // Reproduced here with a message that reposts itself, which is the same
112
+ // queue condition as the painting without needing a window on screen.
113
+ ScopedUiThread loop;
114
+
115
+ constexpr UINT kFlood = WM_APP + 7;
116
+ static long flooded = 0;
117
+ static long ticked = 0;
118
+ // Cleared before the window goes, so the self-reposting message stops
119
+ // rather than outliving this test in the queue the next one pumps.
120
+ static bool flooding = true;
121
+ flooded = 0;
122
+ ticked = 0;
123
+ flooding = true;
124
+
125
+ WNDCLASSEXW windowClass{};
126
+ windowClass.cbSize = sizeof(windowClass);
127
+ windowClass.lpfnWndProc = [](HWND hwnd, UINT message, WPARAM w, LPARAM l) -> LRESULT {
128
+ if (message == kFlood) {
129
+ flooded++;
130
+ // Posted again before this one is finished with, so the queue is never
131
+ // observed empty. Which is the condition under test.
132
+ if (flooding) {
133
+ PostMessageW(hwnd, kFlood, 0, 0);
134
+ }
135
+ return 0;
136
+ }
137
+ if (message == WM_TIMER) {
138
+ ticked++;
139
+ return 0;
140
+ }
141
+ return DefWindowProcW(hwnd, message, w, l);
142
+ };
143
+ windowClass.hInstance = GetModuleHandleW(nullptr);
144
+ windowClass.lpszClassName = L"BasaltUiTimerBusyTest";
145
+ RegisterClassExW(&windowClass);
146
+ HWND window = CreateWindowExW(0,
147
+ L"BasaltUiTimerBusyTest",
148
+ L"",
149
+ 0,
150
+ 0,
151
+ 0,
152
+ 0,
153
+ 0,
154
+ HWND_MESSAGE,
155
+ nullptr,
156
+ GetModuleHandleW(nullptr),
157
+ nullptr);
158
+ EXPECT(window != nullptr);
159
+ PostMessageW(window, kFlood, 0, 0);
160
+
161
+ // The platform's own timer, for the comparison. Ten milliseconds, so a
162
+ // working one would fire many times over what follows.
163
+ SetTimer(window, 1, 10, nullptr);
164
+
165
+ int fired = 0;
166
+ const basalt::UiTimerId timer = basalt::scheduleUiTimer(10, false, [&fired] { fired++; });
167
+ EXPECT(pumpUntil([&fired] { return fired > 0; }));
168
+
169
+ // The flood really did run, so the pass above means the timer arrived
170
+ // through a busy queue rather than through a quiet one.
171
+ EXPECT(flooded > 100);
172
+ // ...and the comparison. If this ever starts firing, Windows has changed
173
+ // its message priorities and this whole file is worth re-reading rather
174
+ // than the assertion being worth relaxing.
175
+ EXPECT_EQ(static_cast<int>(ticked), 0);
176
+
177
+ flooding = false;
178
+ KillTimer(window, 1);
179
+ basalt::cancelUiTimer(timer);
180
+ DestroyWindow(window);
181
+ UnregisterClassW(L"BasaltUiTimerBusyTest", GetModuleHandleW(nullptr));
182
+ }
183
+
184
+ TEST(ui_timer_declines_when_there_is_no_loop_to_post_to) {
185
+ // No ScopedUiThread. Delivering here would mean running the callback on a
186
+ // thread pool, which is how the rest of this suite -- written against
187
+ // inline posting on one thread -- would start failing at random.
188
+ int fired = 0;
189
+ EXPECT_EQ(static_cast<int>(basalt::scheduleUiTimer(1, false, [&fired] { fired++; })), 0);
190
+ std::this_thread::sleep_for(std::chrono::milliseconds(30));
191
+ EXPECT_EQ(fired, 0);
192
+ // And cancelling nothing is allowed, which is what lets a caller keep one
193
+ // id in a field and cancel it without asking whether it ever started.
194
+ basalt::cancelUiTimer(0);
195
+ }
@@ -116,6 +116,22 @@ bool hasUiThread() {
116
116
  return uiThread().window != nullptr;
117
117
  }
118
118
 
119
+ void uninstallUiThread() {
120
+ HWND window = uiThread().window;
121
+ if (window == nullptr) {
122
+ return;
123
+ }
124
+ // Cleared before the window is destroyed, so that anything posted from
125
+ // another thread between these two lines runs inline rather than through a
126
+ // PostMessage to a handle that is going away.
127
+ uiThread().window = nullptr;
128
+ uiThread().threadId = std::thread::id{};
129
+ DestroyWindow(window);
130
+ // And whatever was queued and never pumped, which is now nobody's: running
131
+ // it here is what the no-window path would have done with it anyway.
132
+ drainPendingWork();
133
+ }
134
+
119
135
  bool isUiThread() {
120
136
  // With no UI thread installed there is only one thread that matters, so
121
137
  // saying yes is both true and what keeps the tests from marshalling into a
@@ -165,6 +165,33 @@ class AccessibleProvider final : public IRawElementProviderSimple {
165
165
  }
166
166
  return S_OK;
167
167
 
168
+ case UIA_CulturePropertyId: {
169
+ // React Native's `accessibilityLanguage`, which UIA takes as an LCID
170
+ // and not as the BCP-47 tag every other platform speaks: 1036 where
171
+ // the app wrote "fr-FR". `LocaleNameToLCID` is the documented
172
+ // conversion and the reason this property can be answered at all.
173
+ //
174
+ // `LOCALE_ALLOW_NEUTRAL_NAMES` so a tag with no region -- "fr", which
175
+ // is what an app writes when it means the language and not a country
176
+ // -- converts rather than failing. Without it the function rejects
177
+ // every neutral name, which is a large share of what apps write.
178
+ //
179
+ // Left unset when the tag does not convert, which is the honest answer
180
+ // for a locale this Windows does not know: zero is a valid LCID
181
+ // meaning the system default, so returning the failure would claim the
182
+ // app asked for the system language when it asked for something else.
183
+ if (info_.language.empty()) {
184
+ return S_OK;
185
+ }
186
+ const LCID lcid =
187
+ LocaleNameToLCID(widen(info_.language).c_str(), LOCALE_ALLOW_NEUTRAL_NAMES);
188
+ if (lcid != 0) {
189
+ value->vt = VT_I4;
190
+ value->lVal = static_cast<long>(lcid);
191
+ }
192
+ return S_OK;
193
+ }
194
+
168
195
  case UIA_HelpTextPropertyId:
169
196
  // React Native's `accessibilityHint` is "what happens if you do this",
170
197
  // which is what UIA calls help text. AppKit puts it in the
@@ -80,6 +80,11 @@ struct RnAccessibleInfo {
80
80
  // calls an automation id. Not a label -- nothing reads it aloud -- and not
81
81
  // `nativeID`, which is how an app names a view to itself.
82
82
  std::string testId;
83
+ // `accessibilityLanguage`, as React Native gave it: a BCP-47 tag such as
84
+ // "fr-CA". UIA wants a culture *identifier* rather than a tag, so the
85
+ // conversion happens where the property is answered and not here -- the tag
86
+ // is what the app wrote and what the other hosts publish verbatim.
87
+ std::string language;
83
88
  // `accessibilityViewIsModal`. UIA calls this a dialog: a client that honours
84
89
  // `IsDialog` stops offering what is behind the element, which is the same
85
90
  // behaviour ARIA's `aria-modal` and AppKit's `accessibilityModal` ask for.
@@ -160,6 +160,13 @@ class RnWin32View {
160
160
  // as transparent black: a view with no background does not paint at all.
161
161
  void setBackgroundColor(float red, float green, float blue, float alpha, bool hasColor);
162
162
  bool hasBackgroundColor() const { return hasBackgroundColor_; }
163
+ // The four components, in the order they were set. Only meaningful when
164
+ // `hasBackgroundColor()`; a view without one has never written them.
165
+ //
166
+ // Read by Win32PeerClip, which has to know whether a view painted over a
167
+ // `<TextInput>` covers it, and that is the alpha. Painting itself does not
168
+ // need the accessor -- it is a member and reads the array directly.
169
+ const float *backgroundColor() const { return backgroundColor_; }
163
170
 
164
171
  void setOpacity(float opacity);
165
172
  float opacity() const { return opacity_; }
@@ -424,6 +431,16 @@ class RnWin32View {
424
431
  void setNativeId(std::string nativeId) { nativeId_ = std::move(nativeId); }
425
432
  const std::string &nativeId() const { return nativeId_; }
426
433
 
434
+ // What this view will take on a drop, over and above what its `nativeID`
435
+ // says: a `DropAccepts` mask from core/DragAndDrop.h.
436
+ //
437
+ // A second source because a `<TextInput>` declares it with a real prop,
438
+ // `acceptDragAndDropTypes`, where a plain `<View>` has only the `nativeID`
439
+ // marker this platform invented. The drop walk reads both, so a field can
440
+ // accept a drop without being given a magic id.
441
+ void setDropAccepts(std::uint16_t accepts) { dropAccepts_ = accepts; }
442
+ std::uint16_t dropAccepts() const { return dropAccepts_; }
443
+
427
444
  // --- Content ---------------------------------------------------------------
428
445
 
429
446
  // The paragraph this view draws, or null for a view that draws none.
@@ -798,6 +815,7 @@ class RnWin32View {
798
815
  bool hasBorders_ = false;
799
816
  HWND editablePeer_ = nullptr;
800
817
  std::string nativeId_;
818
+ std::uint16_t dropAccepts_{0};
801
819
  int zIndex_ = 0;
802
820
  PointerEvents pointerEvents_ = PointerEvents::Auto;
803
821
  float hitSlop_[4] = {0.0f, 0.0f, 0.0f, 0.0f};