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.
- package/native/CMakeLists.txt +56 -9
- package/native/tests/test_win32_accessibility.cpp +77 -0
- package/native/tests/test_win32_focus.cpp +26 -0
- package/native/tests/test_win32_input.cpp +82 -0
- package/native/tests/test_win32_peer_clip.cpp +266 -0
- package/native/tests/test_win32_run_loop.cpp +122 -0
- package/native/tests/test_win32_textinput.cpp +199 -0
- package/native/tests/test_win32_ui_timer.cpp +195 -0
- package/native/win32/PlatformServicesWin32.cpp +16 -0
- package/native/win32/RnWin32Accessible.cpp +27 -0
- package/native/win32/RnWin32Accessible.h +5 -0
- package/native/win32/RnWin32View.h +18 -0
- package/native/win32/Win32AnimationChoreographer.cpp +15 -32
- package/native/win32/Win32AnimationChoreographer.h +9 -6
- package/native/win32/Win32AppWindow.h +36 -0
- package/native/win32/Win32Cursors.cpp +13 -17
- package/native/win32/Win32DropTarget.cpp +4 -1
- package/native/win32/Win32MenuBar.cpp +3 -1
- package/native/win32/Win32MountingManager.cpp +82 -7
- package/native/win32/Win32MountingManager.h +5 -0
- package/native/win32/Win32PeerClip.cpp +153 -0
- package/native/win32/Win32PeerClip.h +92 -0
- package/native/win32/Win32RunLoopObserver.cpp +39 -9
- package/native/win32/Win32RunLoopObserver.h +41 -2
- package/native/win32/Win32ScrollView.cpp +13 -32
- package/native/win32/Win32ScrollView.h +5 -6
- package/native/win32/Win32SkiaPeer.h +26 -4
- package/native/win32/Win32TextInput.cpp +139 -48
- package/native/win32/Win32TextInput.h +28 -8
- package/native/win32/Win32UiThread.h +11 -0
- package/native/win32/Win32UiTimer.cpp +139 -0
- package/native/win32/Win32UiTimer.h +65 -0
- package/native/win32/Win32WindowControl.cpp +38 -15
- package/native/win32/Win32WindowModule.cpp +8 -13
- package/native/win32/main_win32.cpp +123 -96
- 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};
|