basalt-win32 0.2.2 → 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.
@@ -330,6 +330,83 @@ TEST(accessibility_a_plain_view_claims_no_dialog) {
330
330
  EXPECT_EQ(propertyType(provider, UIA_IsDialogPropertyId), VT_EMPTY);
331
331
  }
332
332
 
333
+ // ---------------------------------------------------------------------------
334
+ // `accessibilityLanguage`, which is the one accessibility prop where this
335
+ // platform does not take the value the app wrote.
336
+ //
337
+ // Every other host publishes the BCP-47 tag verbatim -- AppKit as the
338
+ // `AXLanguage` attribute, and GTK not at all, having no accessible language
339
+ // property. UIA's culture property is an LCID, a number, so the tag has to be
340
+ // converted; `LocaleNameToLCID` is the conversion and these pin that the
341
+ // provider does it rather than answering with the string or with zero.
342
+ //
343
+ // The expected numbers are Windows' own and not invented here: 1036 is fr-FR
344
+ // and 12 is the neutral French. Asserted as `LocaleNameToLCID` reports them,
345
+ // because a hard-coded constant that disagreed with the running system would
346
+ // be testing the wrong thing.
347
+
348
+ TEST(accessibility_a_language_becomes_a_culture_id) {
349
+ RnAccessibleInfo info = withRole("text");
350
+ info.label = "Bonjour";
351
+ info.language = "fr-FR";
352
+
353
+ auto provider = providerFor(info);
354
+ EXPECT(provider != nullptr);
355
+ EXPECT_EQ(propertyType(provider, UIA_CulturePropertyId), VT_I4);
356
+ EXPECT_EQ(longProperty(provider, UIA_CulturePropertyId),
357
+ static_cast<long>(LocaleNameToLCID(L"fr-FR", LOCALE_ALLOW_NEUTRAL_NAMES)));
358
+ // Not the system's, which is what a provider that ignored the prop and
359
+ // answered zero would be claiming.
360
+ EXPECT(longProperty(provider, UIA_CulturePropertyId) != 0);
361
+ }
362
+
363
+ // A tag with no region is what an app writes when it means the language rather
364
+ // than a country, and it is the case `LOCALE_ALLOW_NEUTRAL_NAMES` exists for:
365
+ // without the flag this answers nothing for a large share of what apps write.
366
+ TEST(accessibility_a_language_without_a_region_still_converts) {
367
+ RnAccessibleInfo info = withRole("text");
368
+ info.language = "fr";
369
+
370
+ auto provider = providerFor(info);
371
+ EXPECT(provider != nullptr);
372
+ EXPECT_EQ(propertyType(provider, UIA_CulturePropertyId), VT_I4);
373
+ EXPECT_EQ(longProperty(provider, UIA_CulturePropertyId),
374
+ static_cast<long>(LocaleNameToLCID(L"fr", LOCALE_ALLOW_NEUTRAL_NAMES)));
375
+ }
376
+
377
+ // Unset rather than zero, for the reason the dialog property is unset rather
378
+ // than false: zero is a valid LCID meaning the system default, so answering it
379
+ // would say the app asked for the system language when it asked for nothing.
380
+ TEST(accessibility_no_language_leaves_the_culture_unset) {
381
+ auto provider = providerFor(withRole("text"));
382
+ EXPECT(provider != nullptr);
383
+ EXPECT_EQ(propertyType(provider, UIA_CulturePropertyId), VT_EMPTY);
384
+ }
385
+
386
+ // And the same for a tag this Windows cannot resolve, which is the branch that
387
+ // would otherwise publish zero and call it the app's choice.
388
+ //
389
+ // Over-long rather than merely wrong, and that is deliberate: Windows accepts
390
+ // far more than the tags it has data for -- a well-formed name it does not
391
+ // know can come back as `LOCALE_CUSTOM_UNSPECIFIED` rather than as a failure,
392
+ // and which names those are differs by Windows version. A name longer than
393
+ // `LOCALE_NAME_MAX_LENGTH` is the one input documented to fail everywhere, so
394
+ // it is what pins the branch without the assertion depending on the runner's
395
+ // locale data.
396
+ TEST(accessibility_an_unresolvable_language_leaves_the_culture_unset) {
397
+ RnAccessibleInfo info = withRole("text");
398
+ info.language = std::string(LOCALE_NAME_MAX_LENGTH + 10, 'x');
399
+ // The premise, asserted rather than assumed: if some future Windows resolved
400
+ // this, the test below would be checking nothing.
401
+ EXPECT_EQ(LocaleNameToLCID(basalt::win32::widen(info.language).c_str(),
402
+ LOCALE_ALLOW_NEUTRAL_NAMES),
403
+ static_cast<LCID>(0));
404
+
405
+ auto provider = providerFor(info);
406
+ EXPECT(provider != nullptr);
407
+ EXPECT_EQ(propertyType(provider, UIA_CulturePropertyId), VT_EMPTY);
408
+ }
409
+
333
410
  // ---------------------------------------------------------------------------
334
411
  // `accessibilityLabelledBy`, which is the one accessibility prop that is a
335
412
  // relation between two views rather than a value on one.
@@ -90,6 +90,32 @@ TEST(win32_focus_accessible_views_are_the_ones_tab_stops_on) {
90
90
  manager.destroySurfaceRoot(kSurfaceId);
91
91
  }
92
92
 
93
+ // `accessibilityRespondsToUserInteraction`, which React Native documents as
94
+ // blocking the keyboard while leaving the view accessible. On a desktop that
95
+ // is the Tab order; core/KeyboardFocusable.h decides it for all three hosts
96
+ // and this is the wiring.
97
+ TEST(win32_focus_a_view_that_refuses_interaction_is_not_a_tab_stop) {
98
+ Win32MountingManager manager;
99
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
100
+ root->setFrame(0, 0, 400, 400);
101
+
102
+ ShadowView quiet = makeAccessibleView(10, 0, true);
103
+ auto props = std::make_shared<ViewProps>(*std::static_pointer_cast<const ViewProps>(quiet.props));
104
+ props->accessibilityRespondsToUserInteraction = false;
105
+ quiet.props = props;
106
+
107
+ mountAll(manager, {quiet, makeAccessibleView(11, 50, true)});
108
+
109
+ EXPECT(!manager.viewForTag(10)->focusable());
110
+ // The view beside it said nothing, and the prop defaults to true: one view
111
+ // refusing the keyboard must not take its neighbours with it.
112
+ EXPECT(manager.viewForTag(11)->focusable());
113
+ // And the dump says so, which is what the cross-host comparison reads.
114
+ EXPECT(root->describeTree().find("focusable") != std::string::npos);
115
+
116
+ manager.destroySurfaceRoot(kSurfaceId);
117
+ }
118
+
93
119
  TEST(win32_focus_tab_visits_focusable_views_in_tree_order_and_wraps) {
94
120
  Win32MountingManager manager;
95
121
  RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
@@ -179,6 +179,9 @@ struct FieldOptions {
179
179
  unsigned int placeholderTextColor{0};
180
180
  unsigned int selectionColor{0};
181
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};
182
185
  };
183
186
 
184
187
  ShadowView makeField(Tag tag,
@@ -196,6 +199,9 @@ ShadowView makeField(Tag tag,
196
199
  "clearTextOnFocus", options.clearTextOnFocus)(
197
200
  "selectTextOnFocus", options.selectTextOnFocus)("caretHidden", options.caretHidden)(
198
201
  "contextMenuHidden", options.contextMenuHidden);
202
+ if (!options.scrollEnabled) {
203
+ raw["scrollEnabled"] = false;
204
+ }
199
205
  if (!options.submitBehavior.empty()) {
200
206
  raw["submitBehavior"] = options.submitBehavior;
201
207
  }
@@ -889,6 +895,91 @@ TEST(win32_a_multiline_field_is_a_multiline_control) {
889
895
  manager->destroySurfaceRoot(kSurfaceId);
890
896
  }
891
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
+
892
983
  // A single-line field still swallows Enter, which is the other half of the same
893
984
  // rule and the test that was here before this prop existed.
894
985
  TEST(win32_a_single_line_field_is_not_multiline) {
@@ -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.
@@ -431,6 +431,16 @@ class RnWin32View {
431
431
  void setNativeId(std::string nativeId) { nativeId_ = std::move(nativeId); }
432
432
  const std::string &nativeId() const { return nativeId_; }
433
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
+
434
444
  // --- Content ---------------------------------------------------------------
435
445
 
436
446
  // The paragraph this view draws, or null for a view that draws none.
@@ -805,6 +815,7 @@ class RnWin32View {
805
815
  bool hasBorders_ = false;
806
816
  HWND editablePeer_ = nullptr;
807
817
  std::string nativeId_;
818
+ std::uint16_t dropAccepts_{0};
808
819
  int zIndex_ = 0;
809
820
  PointerEvents pointerEvents_ = PointerEvents::Auto;
810
821
  float hitSlop_[4] = {0.0f, 0.0f, 0.0f, 0.0f};
@@ -0,0 +1,36 @@
1
+ // Which HWND a surface id means.
2
+ //
3
+ // The same seam AppKitAppWindow.h and GtkAppWindow.h describe, written for
4
+ // this toolkit and for the same reason: the registry of open windows is
5
+ // `main_win32.cpp`'s, and everything that resolves one is compiled into the
6
+ // mounting library, which the host links rather than the other way round. So
7
+ // the host installs a lookup at startup and this is what the window module
8
+ // asks.
9
+ //
10
+ // Not installed is the normal case for a test binary: it opens no window, and
11
+ // should not grow a registry to keep compiling. `win32AppWindowFor` falls back
12
+ // to the active window, which is what every caller here did before windows had
13
+ // ids.
14
+
15
+ #pragma once
16
+
17
+ #include <react/renderer/core/ReactPrimitives.h>
18
+
19
+ #include <functional>
20
+
21
+ #include <windows.h>
22
+
23
+ namespace basalt {
24
+
25
+ // The app's own window, by the rule this host has always used: the active
26
+ // window, falling back to the foreground one. UI thread only.
27
+ HWND win32AppWindow();
28
+
29
+ using Win32AppWindowLookup = std::function<HWND(facebook::react::SurfaceId)>;
30
+ void setWin32AppWindowLookup(Win32AppWindowLookup lookup);
31
+
32
+ // The window with this surface id, or the app's own for `kAppWindow` and for
33
+ // an id the lookup does not know. UI thread only.
34
+ HWND win32AppWindowFor(facebook::react::SurfaceId window);
35
+
36
+ } // namespace basalt
@@ -490,7 +490,10 @@ facebook::react::Tag dropTargetAt(win32::RnWin32View *root,
490
490
  }
491
491
  // Deepest first, which is the rule: a drop target inside another wins.
492
492
  for (auto it = path.rbegin(); it != path.rend(); ++it) {
493
- const std::uint16_t wanted = dropAcceptsFrom((*it)->nativeId());
493
+ // The `nativeID` marker and the view's own declared kinds, which is how a
494
+ // <TextInput acceptDragAndDropTypes> joins this walk without an id.
495
+ const std::uint16_t wanted =
496
+ static_cast<std::uint16_t>(dropAcceptsFrom((*it)->nativeId()) | (*it)->dropAccepts());
494
497
  if (wanted != DropAcceptsNone && (wanted & accepts) != 0) {
495
498
  return static_cast<facebook::react::Tag>((*it)->tag());
496
499
  }
@@ -342,7 +342,9 @@ bool handleMenuCommand(unsigned int command) {
342
342
  PostMessageW(window, WM_CLOSE, 0, 0);
343
343
  return true;
344
344
  case kRoleBase + 12:
345
- setWindowFullScreen(!windowBounds().fullScreen);
345
+ // The app's own window: a menu role acts on the window the menu bar
346
+ // belongs to, which is that one. See core/WindowControl.h.
347
+ setWindowFullScreen(kAppWindow, !windowBounds(kAppWindow).fullScreen);
346
348
  return true;
347
349
  default:
348
350
  return false;
@@ -1,3 +1,6 @@
1
+ #include "AccessibilityRole.h"
2
+ #include "KeyboardFocusable.h"
3
+ #include "ImagePlaceholder.h"
1
4
  #include "Win32MountingManager.h"
2
5
 
3
6
  #include "BackgroundLayers.h"
@@ -342,6 +345,8 @@ void Win32MountingManager::forgetTag(Tag tag) {
342
345
  scrollViews_.remove(tag);
343
346
  textInputs_.remove(tag);
344
347
  imageUris_.erase(tag);
348
+ imagePlaceholders_.erase(tag);
349
+ imageLoaded_.erase(tag);
345
350
  switchValues_.erase(tag);
346
351
  labels_.forget(tag);
347
352
  liveRegions_.forget(tag);
@@ -872,6 +877,9 @@ void Win32MountingManager::applyImage(RnWin32View *view, const ShadowView &shado
872
877
  const facebook::react::ImageSource source =
873
878
  props->sources.empty() ? facebook::react::ImageSource{} : props->sources.front();
874
879
  const std::string uri = source.uri;
880
+ // `defaultSource`, drawn until the source lands and kept if it never does.
881
+ // See core/ImagePlaceholder.h for which of the two props this is.
882
+ const std::string placeholder = basalt::imagePlaceholderUri(*props);
875
883
 
876
884
  // `tintColor`, applied before the load rather than in its callback: the tint
877
885
  // is a prop and the image is a loader's answer, and either can arrive first.
@@ -914,6 +922,8 @@ void Win32MountingManager::applyImage(RnWin32View *view, const ShadowView &shado
914
922
  return;
915
923
  }
916
924
  imageUris_[shadowView.tag] = uri;
925
+ imagePlaceholders_[shadowView.tag] = placeholder;
926
+ imageLoaded_.erase(shadowView.tag);
917
927
 
918
928
  if (uri.empty()) {
919
929
  view->setImage(nullptr, fit);
@@ -926,6 +936,38 @@ void Win32MountingManager::applyImage(RnWin32View *view, const ShadowView &shado
926
936
  // the change did not take.
927
937
  view->setImage(nullptr, fit);
928
938
 
939
+ // Then the placeholder, if the app named one, so the blank box above is as
940
+ // short as the loader is slow. A bundled asset answers from the cache on
941
+ // this thread, which makes it the same frame.
942
+ //
943
+ // Guarded on the real image not having arrived: both loads are asynchronous
944
+ // and a slow placeholder must not paint over the image it stood in for.
945
+ if (!placeholder.empty() && uri != placeholder) {
946
+ const Tag placeholderTag = shadowView.tag;
947
+ imageLoader_->load(
948
+ placeholder,
949
+ [this, placeholderTag, fit, placeholder](std::shared_ptr<win32::RnWin32Image> image,
950
+ const std::string &) {
951
+ if (image == nullptr || imageLoaded_.count(placeholderTag) != 0) {
952
+ return;
953
+ }
954
+ const auto still = imagePlaceholders_.find(placeholderTag);
955
+ if (still == imagePlaceholders_.end() || still->second != placeholder) {
956
+ return;
957
+ }
958
+ RnWin32View *target = viewForTag(placeholderTag);
959
+ if (target == nullptr) {
960
+ return;
961
+ }
962
+ target->setImage(image, fit);
963
+ // The same reason the load callback below calls it: the bytes
964
+ // arrived on their own clock and only the host has the HWND.
965
+ if (onDidMount_) {
966
+ onDidMount_();
967
+ }
968
+ });
969
+ }
970
+
929
971
  // `<Image>`'s load events, which this host emitted none of until 2026-10-10:
930
972
  // `onLoadStart`, `onProgress`, `onLoad`, `onError` and `onLoadEnd` all
931
973
  // reached JavaScript on the other two hosts and nothing here ever dispatched
@@ -987,7 +1029,20 @@ void Win32MountingManager::applyImage(RnWin32View *view, const ShadowView &shado
987
1029
  const bool stillWanted =
988
1030
  target != nullptr && current != imageUris_.end() && current->second == uri;
989
1031
  if (stillWanted) {
990
- target->setImage(image, fit);
1032
+ // A failed load leaves the placeholder where it is rather than
1033
+ // clearing to nothing, which is `RCTImageComponentView`'s behaviour:
1034
+ // a broken remote image shows what the app chose to show instead of
1035
+ // an empty box.
1036
+ const auto standing = imagePlaceholders_.find(tag);
1037
+ const bool keepPlaceholder = image == nullptr &&
1038
+ standing != imagePlaceholders_.end() &&
1039
+ !standing->second.empty();
1040
+ if (image != nullptr) {
1041
+ imageLoaded_.insert(tag);
1042
+ }
1043
+ if (!keepPlaceholder) {
1044
+ target->setImage(image, fit);
1045
+ }
991
1046
  applyAnimation(target, uri);
992
1047
  // The screen is stale and no transaction is coming: the bytes arrived
993
1048
  // on their own clock, after the mount that asked for them. On GTK and
@@ -1044,8 +1099,22 @@ win32::RnAccessibleFlag toFlag(bool value) {
1044
1099
  std::string effectiveRole(const ShadowView &shadowView) {
1045
1100
  if (const auto props =
1046
1101
  std::dynamic_pointer_cast<const facebook::react::AccessibilityProps>(shadowView.props)) {
1047
- if (!props->accessibilityRole.empty()) {
1048
- return props->accessibilityRole;
1102
+ const std::string asked = basalt::accessibilityRoleName(*props);
1103
+ if (!asked.empty()) {
1104
+ return asked;
1105
+ }
1106
+ }
1107
+ // Then the paragraph's own fragments, which is where a nested <Text>'s role
1108
+ // lands: `TextAttributes` carries one per run, and a paragraph every fragment
1109
+ // of which agrees *is* that role. Before the inference below, so a paragraph
1110
+ // whose runs all say "header" is a header rather than generic text. See
1111
+ // core/AccessibilityRole.h for why agreement and not the first one.
1112
+ if (const auto state =
1113
+ std::dynamic_pointer_cast<const facebook::react::ConcreteState<facebook::react::ParagraphState>>(
1114
+ shadowView.state)) {
1115
+ const std::string fragments = basalt::fragmentRoleName(state->getData().attributedString);
1116
+ if (!fragments.empty()) {
1117
+ return fragments;
1049
1118
  }
1050
1119
  }
1051
1120
  if (shadowView.componentName != nullptr) {
@@ -1085,6 +1154,9 @@ void Win32MountingManager::applyAccessibility(RnWin32View *view, const ShadowVie
1085
1154
  info.testId = props->testId;
1086
1155
  // `accessibilityViewIsModal`, which UIA calls a dialog.
1087
1156
  info.modal = props->accessibilityViewIsModal;
1157
+ // `accessibilityLanguage`, as the BCP-47 tag the app wrote. The conversion
1158
+ // to the LCID UIA wants happens where the culture property is answered.
1159
+ info.language = props->accessibilityLanguage;
1088
1160
 
1089
1161
  const auto &state = props->accessibilityState;
1090
1162
  if (state.has_value()) {
@@ -1140,7 +1212,7 @@ void Win32MountingManager::applyAccessibility(RnWin32View *view, const ShadowVie
1140
1212
  // that cannot be operated rather than stopping on one that does nothing.
1141
1213
  const basalt::ControlState control = basalt::controlStateOf(shadowView);
1142
1214
  const bool isControl = control.kind == basalt::ControlKind::Switch && !control.disabled;
1143
- view->setFocusable((props->accessible || isControl) && !props->accessibilityElementsHidden);
1215
+ view->setFocusable(basalt::keyboardFocusable(*props, isControl));
1144
1216
  }
1145
1217
 
1146
1218
  void Win32MountingManager::applyLayoutMetrics(RnWin32View *view, const ShadowView &shadowView) {
@@ -34,6 +34,7 @@
34
34
  #include <functional>
35
35
  #include <string>
36
36
  #include <unordered_map>
37
+ #include <unordered_set>
37
38
 
38
39
  namespace basalt {
39
40
 
@@ -190,6 +191,10 @@ class Win32MountingManager final : public facebook::react::IMountingManager,
190
191
  // changed only layout does not restart the load and make the image flicker
191
192
  // whenever its parent resizes. Both other platforms keep the same map.
192
193
  std::unordered_map<facebook::react::Tag, std::string> imageUris_;
194
+ // `defaultSource` per view, and which views have their real image. See
195
+ // core/ImagePlaceholder.h.
196
+ std::unordered_map<facebook::react::Tag, std::string> imagePlaceholders_;
197
+ std::unordered_set<facebook::react::Tag> imageLoaded_;
193
198
 
194
199
  // Scroll offsets, the unthrottled state write-back, the wheel and the
195
200
  // commands. Constructed with a lookup into this object's own emitter registry
@@ -1,6 +1,7 @@
1
1
  #include "Win32TextInput.h"
2
2
 
3
3
  #include "DirectWriteLayout.h"
4
+ #include "DragAndDrop.h"
4
5
  #include "TextChecking.h"
5
6
  #include "Win32PeerClip.h"
6
7
  #include "Win32InputScopes.h"
@@ -134,8 +135,29 @@ void Win32TextInputManager::update(RnWin32View *view, const ShadowView &shadowVi
134
135
  // that still behaves as one line. So a field that changes the prop has its
135
136
  // peer rebuilt, which is rare enough to be worth the simplicity: React Native
136
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
+
137
152
  const bool multiline = props != nullptr && props->multiline;
138
- 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)) {
139
161
  const bool hadFocus = GetFocus() == entry.control;
140
162
  destroyPeer(entry);
141
163
  entry.view = view;
@@ -161,9 +183,18 @@ void Win32TextInputManager::update(RnWin32View *view, const ShadowView &shadowVi
161
183
  // scroll is what lets the caret leave the bottom of the box. No `WS_VSCROLL`
162
184
  // on purpose -- a scrollbar inside a styled field is a Win32 scrollbar
163
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.
164
193
  entry.multiline = multiline;
194
+ entry.scrollEnabled = scrollEnabled;
165
195
  const DWORD style = WS_CHILD | WS_CLIPSIBLINGS | ES_LEFT
166
- | (multiline ? (ES_MULTILINE | ES_AUTOVSCROLL) : ES_AUTOHSCROLL);
196
+ | (multiline ? (ES_MULTILINE | (scrollEnabled ? ES_AUTOVSCROLL : 0u))
197
+ : ES_AUTOHSCROLL);
167
198
  entry.control = CreateWindowEx(0,
168
199
  L"EDIT",
169
200
  L"",
@@ -264,6 +264,11 @@ class Win32TextInputManager {
264
264
  // created and cannot be added afterwards, so this is what a change in the
265
265
  // prop is compared against: a field that switches has its peer rebuilt.
266
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};
267
272
 
268
273
  // Yoga resolves border and padding into contentInsets, so the same twelve
269
274
  // points mean the same thing in a field as in a <View>. Applied by placing
@@ -21,17 +21,40 @@
21
21
  // scale factor. GetDpiForWindow would answer for the window's monitor only.
22
22
  #include <shellscalingapi.h>
23
23
 
24
+ #include "Win32AppWindow.h"
25
+
24
26
  namespace basalt {
25
27
 
26
28
  namespace {
27
29
 
30
+ // The window a call means, which is the lookup's answer where there is one.
31
+ HWND window(facebook::react::SurfaceId id) { return win32AppWindowFor(id); }
32
+
33
+ Win32AppWindowLookup &lookup() {
34
+ static Win32AppWindowLookup value;
35
+ return value;
36
+ }
37
+
38
+ } // namespace
28
39
 
29
- HWND window() {
40
+ HWND win32AppWindow() {
30
41
  HWND active = GetActiveWindow();
31
42
  return active != nullptr ? active : GetForegroundWindow();
32
43
  }
33
44
 
34
- } // namespace
45
+ void setWin32AppWindowLookup(Win32AppWindowLookup value) { lookup() = std::move(value); }
46
+
47
+ HWND win32AppWindowFor(facebook::react::SurfaceId id) {
48
+ if (id != kAppWindow && lookup()) {
49
+ if (HWND found = lookup()(id)) {
50
+ return found;
51
+ }
52
+ // An id nobody knows is the app's own window rather than nothing: a window
53
+ // that closed between a render and the call after it is the ordinary way
54
+ // to get here. See AppKitAppWindow.h, which says the same.
55
+ }
56
+ return win32AppWindow();
57
+ }
35
58
 
36
59
  // Every monitor, through the callback API that is the only way to ask.
37
60
  //
@@ -99,9 +122,9 @@ PointerPosition pointerPosition() {
99
122
  return where;
100
123
  }
101
124
 
102
- WindowBounds windowBounds() {
125
+ WindowBounds windowBounds(facebook::react::SurfaceId id) {
103
126
  WindowBounds bounds;
104
- HWND target = window();
127
+ HWND target = window(id);
105
128
  if (target == nullptr) {
106
129
  return bounds;
107
130
  }
@@ -138,8 +161,8 @@ WindowBounds windowBounds() {
138
161
  return bounds;
139
162
  }
140
163
 
141
- void setWindowSize(double width, double height) {
142
- HWND target = window();
164
+ void setWindowSize(facebook::react::SurfaceId id, double width, double height) {
165
+ HWND target = window(id);
143
166
  if (target == nullptr || width <= 0.0 || height <= 0.0) {
144
167
  return;
145
168
  }
@@ -160,8 +183,8 @@ void setWindowSize(double width, double height) {
160
183
  SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
161
184
  }
162
185
 
163
- void setWindowPosition(double x, double y) {
164
- HWND target = window();
186
+ void setWindowPosition(facebook::react::SurfaceId id, double x, double y) {
187
+ HWND target = window(id);
165
188
  if (target == nullptr) {
166
189
  return;
167
190
  }
@@ -174,8 +197,8 @@ void setWindowPosition(double x, double y) {
174
197
  SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
175
198
  }
176
199
 
177
- void centerWindow() {
178
- HWND target = window();
200
+ void centerWindow(facebook::react::SurfaceId id) {
201
+ HWND target = window(id);
179
202
  if (target == nullptr) {
180
203
  return;
181
204
  }
@@ -200,8 +223,8 @@ void centerWindow() {
200
223
  SetWindowPos(target, nullptr, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
201
224
  }
202
225
 
203
- void setWindowFullScreen(bool fullScreen) {
204
- HWND target = window();
226
+ void setWindowFullScreen(facebook::react::SurfaceId id, bool fullScreen) {
227
+ HWND target = window(id);
205
228
  if (target == nullptr) {
206
229
  return;
207
230
  }
@@ -252,7 +275,7 @@ void setWindowFullScreen(bool fullScreen) {
252
275
  }
253
276
 
254
277
  void applyWindowSizeLimits() {
255
- HWND target = window();
278
+ HWND target = win32AppWindow();
256
279
  if (target == nullptr) {
257
280
  return;
258
281
  }
@@ -275,7 +298,7 @@ void applyWindowSizeLimits() {
275
298
  }
276
299
 
277
300
  void setWindowResizable(bool resizable) {
278
- HWND target = window();
301
+ HWND target = win32AppWindow();
279
302
  if (target == nullptr) {
280
303
  return;
281
304
  }
@@ -303,7 +326,7 @@ void setWindowResizable(bool resizable) {
303
326
  }
304
327
 
305
328
  void setWindowAlwaysOnTop(bool alwaysOnTop) {
306
- HWND target = window();
329
+ HWND target = win32AppWindow();
307
330
  if (target == nullptr) {
308
331
  return;
309
332
  }
@@ -96,24 +96,19 @@ Win32WindowModule::Win32WindowModule(std::shared_ptr<facebook::react::CallInvoke
96
96
  // The window's geometry, the same way the title bar's metrics go out: a
97
97
  // device event, so `useWindow()` can hand an app live bounds rather than a
98
98
  // snapshot it has to remember to refresh.
99
- setWindowBoundsListener([this](const WindowBounds &bounds) {
100
- emitDeviceEvent(kWindowBoundsEvent, [bounds](Runtime &runtime, std::vector<Value> &args) {
101
- Object object(runtime);
102
- object.setProperty(runtime, "x", bounds.x);
103
- object.setProperty(runtime, "y", bounds.y);
104
- object.setProperty(runtime, "width", bounds.width);
105
- object.setProperty(runtime, "height", bounds.height);
106
- object.setProperty(runtime, "fullScreen", bounds.fullScreen);
107
- object.setProperty(runtime, "maximized", bounds.maximized);
108
- args.emplace_back(Value(runtime, object));
109
- });
110
- });
99
+ setWindowBoundsListener(
100
+ [this](facebook::react::SurfaceId window, const WindowBounds &bounds) {
101
+ emitDeviceEvent(kWindowBoundsEvent,
102
+ [window, bounds](Runtime &runtime, std::vector<Value> &args) {
103
+ args.emplace_back(windowBoundsPayload(runtime, window, bounds));
104
+ });
105
+ });
111
106
 
112
107
  // And ask for the bounds once, now that something is listening. The host
113
108
  // primes the cache when its window appears, but this module is built lazily
114
109
  // -- the first time JavaScript asks for it -- so an app that mounts before
115
110
  // then would read a window of no size and never be told otherwise.
116
- postToUiThread([] { notifyWindowBoundsChanged(); });
111
+ postToUiThread([] { notifyWindowBoundsChanged(resolveWindow(kAppWindow)); });
117
112
  }
118
113
 
119
114
  Win32WindowModule::~Win32WindowModule() {
@@ -65,6 +65,7 @@
65
65
  #endif
66
66
  #include "Win32Packaging.h"
67
67
  #include "PointerButtons.h"
68
+ #include "Win32AppWindow.h"
68
69
  #include "WindowControl.h"
69
70
  #include "SystemLocale.h"
70
71
  #include "WindowHost.h"
@@ -1457,7 +1458,12 @@ LRESULT CALLBACK hostProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam)
1457
1458
  // it needs its own case: WM_SIZE would never arrive, and `center()` would
1458
1459
  // look like it had done nothing.
1459
1460
  case WM_MOVE:
1460
- basalt::notifyWindowBoundsChanged();
1461
+ // Which window moved. `self` is null for the messages CreateWindowEx
1462
+ // sends before the window is in the list, and a move among those is not
1463
+ // one an app can see.
1464
+ if (self != nullptr) {
1465
+ basalt::notifyWindowBoundsChanged(self->surfaceId);
1466
+ }
1461
1467
  return 0;
1462
1468
 
1463
1469
  // `AppState`: whether this application is the one in front. Windows sends
@@ -1505,8 +1511,8 @@ LRESULT CALLBACK hostProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam)
1505
1511
  }
1506
1512
  // The window moved or was resized, which `useWindow()` hands an app as live
1507
1513
  // bounds. Here rather than in a watcher of its own: this is already the one
1508
- // place that learns it.
1509
- basalt::notifyWindowBoundsChanged();
1514
+ // place that learns it, and it already knows which window it was.
1515
+ basalt::notifyWindowBoundsChanged(self->surfaceId);
1510
1516
  // The inspector covers the main window, so it resizes with it -- and
1511
1517
  // only with it: an error is about the app rather than about a window.
1512
1518
  if (gHost.logBoxRoot != nullptr && self->surfaceId == kSurfaceId) {
@@ -2141,11 +2147,14 @@ facebook::react::SurfaceId openHostWindow(const NewWindowOptions &options) {
2141
2147
 
2142
2148
  gHost.reactHost->startSurface(surfaceId,
2143
2149
  options.component,
2144
- options.props,
2150
+ windowRootProps(options, surfaceId),
2145
2151
  constraintsFor(width, height),
2146
2152
  layoutContextFor(gHost.scaleFactor));
2147
2153
  ShowWindow(made->window, SW_SHOW);
2148
2154
  UpdateWindow(made->window);
2155
+ // Prime this window's bounds the way the main window's are primed at
2156
+ // startup; see the call beside that one.
2157
+ basalt::notifyWindowBoundsChanged(surfaceId);
2149
2158
  std::fprintf(stderr,
2150
2159
  "opened window %d for module %s\n",
2151
2160
  static_cast<int>(surfaceId),
@@ -2369,10 +2378,18 @@ int main(int argc, char **argv) {
2369
2378
  // being visible.
2370
2379
  gHost.mountingManager->setOnDidMount(syncPeersAndRepaint);
2371
2380
 
2381
+ // How the window module finds a window by id, and which one a call that
2382
+ // names none means. See Win32AppWindow.h.
2383
+ basalt::setWin32AppWindowLookup([](facebook::react::SurfaceId surfaceId) -> HWND {
2384
+ HostWindow *found = gHost.windowFor(surfaceId);
2385
+ return found == nullptr ? nullptr : found->window;
2386
+ });
2387
+ basalt::setAppWindowId(kSurfaceId);
2388
+
2372
2389
  // Prime the bounds cache, which `getBounds()` answers from: without this an
2373
2390
  // app's first render sees a window of no size, and only a later resize
2374
2391
  // corrects it. See core/WindowBoundsCache.cpp.
2375
- basalt::notifyWindowBoundsChanged();
2392
+ basalt::notifyWindowBoundsChanged(kSurfaceId);
2376
2393
 
2377
2394
  // And the display list, for the same reason and off the same seam: an app
2378
2395
  // asking which screens exist before anything has been plugged or unplugged
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basalt-win32",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "The Windows view layer for basalt-core: a Win32 and Direct2D view layer, a Fabric mounting manager, and a host. Everything behind them is in basalt-core.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,6 +22,6 @@
22
22
  "react-native": "*"
23
23
  },
24
24
  "dependencies": {
25
- "basalt-core": "0.2.2"
25
+ "basalt-core": "0.2.3"
26
26
  }
27
27
  }