basalt-core 0.0.0-rc5 → 0.1.0

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 (143) hide show
  1. package/dist/cli/basalt.d.ts.map +1 -1
  2. package/dist/cli/basalt.js +18 -1
  3. package/dist/cli/basalt.js.map +1 -1
  4. package/dist/cli/bundle-cli.js +0 -0
  5. package/dist/cli/packageApp.d.ts.map +1 -1
  6. package/dist/cli/packageApp.js +7 -1
  7. package/dist/cli/packageApp.js.map +1 -1
  8. package/dist/metro-config.d.ts.map +1 -1
  9. package/dist/metro-config.js +53 -5
  10. package/dist/metro-config.js.map +1 -1
  11. package/dist/src/KeyHandler.d.ts +1 -1
  12. package/dist/src/KeyHandler.d.ts.map +1 -1
  13. package/dist/src/KeyHandler.js.map +1 -1
  14. package/dist/src/index.d.ts +5 -0
  15. package/dist/src/index.d.ts.map +1 -1
  16. package/dist/src/overrides/Alert.d.ts +1 -1
  17. package/dist/src/overrides/Alert.js +1 -1
  18. package/dist/src/overrides/BaseViewConfig.d.ts +10 -3
  19. package/dist/src/overrides/BaseViewConfig.d.ts.map +1 -1
  20. package/dist/src/overrides/BaseViewConfig.js +21 -3
  21. package/dist/src/overrides/BaseViewConfig.js.map +1 -1
  22. package/dist/src/overrides/Button.d.ts +81 -0
  23. package/dist/src/overrides/Button.d.ts.map +1 -0
  24. package/dist/src/overrides/Button.js +109 -0
  25. package/dist/src/overrides/Button.js.map +1 -0
  26. package/dist/src/overrides/ScrollView.d.ts +25 -2
  27. package/dist/src/overrides/ScrollView.d.ts.map +1 -1
  28. package/dist/src/overrides/ScrollView.js +64 -7
  29. package/dist/src/overrides/ScrollView.js.map +1 -1
  30. package/dist/src/overrides/Settings.d.ts +81 -0
  31. package/dist/src/overrides/Settings.d.ts.map +1 -0
  32. package/dist/src/overrides/Settings.js +120 -0
  33. package/dist/src/overrides/Settings.js.map +1 -0
  34. package/dist/src/overrides/processDecelerationRate.d.ts +20 -0
  35. package/dist/src/overrides/processDecelerationRate.d.ts.map +1 -0
  36. package/dist/src/overrides/processDecelerationRate.js +31 -0
  37. package/dist/src/overrides/processDecelerationRate.js.map +1 -0
  38. package/dist/src/useContextMenu.d.ts +57 -6
  39. package/dist/src/useContextMenu.d.ts.map +1 -1
  40. package/dist/src/useContextMenu.js +34 -1
  41. package/dist/src/useContextMenu.js.map +1 -1
  42. package/dist/tsconfig.tsbuildinfo +1 -1
  43. package/native/CMakeLists.txt +51 -0
  44. package/native/core/ActionSheet.cpp +75 -0
  45. package/native/core/ActionSheet.h +90 -0
  46. package/native/core/AppStateHost.cpp +76 -0
  47. package/native/core/AppStateHost.h +68 -0
  48. package/native/core/BackgroundLayers.h +282 -0
  49. package/native/core/BlendModes.h +67 -0
  50. package/native/core/CoreModules.cpp +380 -11
  51. package/native/core/CoreModules.h +140 -4
  52. package/native/core/CrashHandler.cpp +246 -0
  53. package/native/core/CrashHandler.h +65 -0
  54. package/native/core/CursorNames.h +110 -0
  55. package/native/core/DevMenu.cpp +6 -5
  56. package/native/core/DeviceMetrics.cpp +87 -0
  57. package/native/core/DeviceMetrics.h +80 -0
  58. package/native/core/Filters.h +336 -0
  59. package/native/core/FontFitting.h +183 -0
  60. package/native/core/FontScaling.h +153 -0
  61. package/native/core/FontVariants.h +148 -0
  62. package/native/core/Gradients.h +523 -0
  63. package/native/core/ImageAnimation.h +109 -0
  64. package/native/core/ImageBytes.cpp +28 -1
  65. package/native/core/ImageBytes.h +17 -1
  66. package/native/core/ImageProgress.h +85 -0
  67. package/native/core/LabelRegistry.h +189 -0
  68. package/native/core/LegacyShadow.h +90 -0
  69. package/native/core/LiveRegions.h +114 -0
  70. package/native/core/MenuModule.cpp +35 -4
  71. package/native/core/MountingWalk.h +125 -2
  72. package/native/core/PlatformServices.h +187 -3
  73. package/native/core/SafeAreaComponent.cpp +115 -0
  74. package/native/core/SafeAreaComponent.h +105 -0
  75. package/native/core/ScreensComponent.cpp +201 -0
  76. package/native/core/ScreensComponent.h +216 -0
  77. package/native/core/ScrollBounds.cpp +8 -0
  78. package/native/core/ScrollBounds.h +14 -0
  79. package/native/core/ScrollIndicator.h +67 -0
  80. package/native/core/ScrollMomentum.cpp +24 -0
  81. package/native/core/ScrollMomentum.h +14 -0
  82. package/native/core/ScrollSnap.cpp +121 -12
  83. package/native/core/ScrollSnap.h +45 -0
  84. package/native/core/ScrollVisiblePosition.cpp +68 -0
  85. package/native/core/ScrollVisiblePosition.h +101 -0
  86. package/native/core/SettingsStore.cpp +199 -0
  87. package/native/core/SettingsStore.h +127 -0
  88. package/native/core/ShadowShape.h +38 -0
  89. package/native/core/SystemLocale.cpp +103 -0
  90. package/native/core/SystemLocale.h +86 -0
  91. package/native/core/TestDialog.cpp +45 -6
  92. package/native/core/TestDialog.h +15 -0
  93. package/native/core/TextAlignments.h +240 -0
  94. package/native/core/TextChecking.h +133 -0
  95. package/native/core/TextColors.h +83 -0
  96. package/native/core/TextDecorations.h +101 -0
  97. package/native/core/TextDirection.cpp +242 -0
  98. package/native/core/TextDirection.h +88 -0
  99. package/native/core/TextHighlight.h +30 -0
  100. package/native/core/TextSelection.cpp +97 -0
  101. package/native/core/TextSelection.h +161 -0
  102. package/native/core/TextShadows.h +96 -0
  103. package/native/core/TextVerticalAlign.h +33 -0
  104. package/native/core/UIManagerAccess.cpp +36 -0
  105. package/native/core/UIManagerAccess.h +28 -0
  106. package/native/core/WindowBoundsCache.cpp +58 -1
  107. package/native/core/WindowControl.h +24 -0
  108. package/native/core/WritingDirections.h +52 -0
  109. package/native/core/portability_probe.cpp +7 -0
  110. package/native/tests/RenamedFontFile.h +161 -0
  111. package/native/tests/TestHarness.cpp +18 -0
  112. package/native/tests/test_action_sheet.cpp +157 -0
  113. package/native/tests/test_app_state.cpp +60 -0
  114. package/native/tests/test_background_layers.cpp +281 -0
  115. package/native/tests/test_blend_modes.cpp +79 -0
  116. package/native/tests/test_blobs.cpp +1 -1
  117. package/native/tests/test_device_metrics.cpp +126 -0
  118. package/native/tests/test_filters.cpp +271 -0
  119. package/native/tests/test_font_fitting.cpp +172 -0
  120. package/native/tests/test_font_scaling.cpp +127 -0
  121. package/native/tests/test_font_variants.cpp +84 -0
  122. package/native/tests/test_gradients.cpp +390 -0
  123. package/native/tests/test_image_animation.cpp +105 -0
  124. package/native/tests/test_image_progress.cpp +94 -0
  125. package/native/tests/test_label_registry.cpp +262 -0
  126. package/native/tests/test_legacy_shadow.cpp +165 -0
  127. package/native/tests/test_live_regions.cpp +108 -0
  128. package/native/tests/test_menu_entries.cpp +192 -0
  129. package/native/tests/test_momentum.cpp +45 -0
  130. package/native/tests/test_scroll_bounds.cpp +42 -0
  131. package/native/tests/test_scroll_indicator.cpp +61 -0
  132. package/native/tests/test_scroll_snap.cpp +129 -0
  133. package/native/tests/test_scroll_visible_position.cpp +121 -0
  134. package/native/tests/test_settings_store.cpp +257 -0
  135. package/native/tests/test_shadow_shape.cpp +47 -0
  136. package/native/tests/test_system_locale.cpp +36 -0
  137. package/native/tests/test_text_alignment.cpp +299 -0
  138. package/native/tests/test_text_checking.cpp +31 -0
  139. package/native/tests/test_text_colors.cpp +92 -0
  140. package/native/tests/test_text_decorations.cpp +100 -0
  141. package/native/tests/test_text_selection.cpp +168 -0
  142. package/native/tests/test_text_shadows.cpp +132 -0
  143. package/package.json +4 -2
@@ -34,10 +34,15 @@ bool boolProperty(Runtime &runtime, const Object &item, const char *name, bool f
34
34
  return value.isBool() ? value.asBool() : fallback;
35
35
  }
36
36
 
37
- // A context menu's entries, which are a flatter thing than the application
38
- // menu's: one level, no roles, no submenus. That is not a simplification -- a
39
- // popup menu on all three desktops is a list, and the nesting an application
40
- // menu has is what a menu *bar* is for.
37
+ // A context menu's entries. Nested, and roles too: NSMenu, GMenu and an HMENU
38
+ // all nest, and the reason this was a flat list was never the platforms. It was
39
+ // that `showMenu` answers with an index and a tree has no single position -- see
40
+ // the note on `walkMenuEntries`, which is what settled it.
41
+ //
42
+ // A role an entry names and this desktop cannot perform is dropped here rather
43
+ // than shown, which is the application menu's policy for the same reason: an
44
+ // item that does nothing is worse than one that is not there. Linux drops most
45
+ // of them.
41
46
  std::vector<MenuEntry> entriesFrom(Runtime &runtime, const Value &value) {
42
47
  std::vector<MenuEntry> entries;
43
48
  if (!value.isObject() || !value.asObject(runtime).isArray(runtime)) {
@@ -62,6 +67,32 @@ std::vector<MenuEntry> entriesFrom(Runtime &runtime, const Value &value) {
62
67
  entry.label = stringProperty(runtime, item, "label");
63
68
  entry.enabled = boolProperty(runtime, item, "enabled", true);
64
69
  entry.shortcut = stringProperty(runtime, item, "shortcut");
70
+ entry.role = stringProperty(runtime, item, "role");
71
+
72
+ // `type` in Electron's spelling, because that is the vocabulary a desktop
73
+ // developer already has, and the roles above are Electron's for the same
74
+ // reason. `checked` on its own is read as a checkbox: it is the common case
75
+ // and making it need two words to work would be a trap.
76
+ const std::string type = stringProperty(runtime, item, "type");
77
+ const bool hasChecked = item.hasProperty(runtime, "checked");
78
+ if (type == "radio") {
79
+ entry.mark = MenuEntry::Mark::Radio;
80
+ } else if (type == "checkbox" || hasChecked) {
81
+ entry.mark = MenuEntry::Mark::Check;
82
+ }
83
+ if (entry.mark != MenuEntry::Mark::None) {
84
+ entry.checked = boolProperty(runtime, item, "checked", false);
85
+ }
86
+
87
+ const Value nested = item.getProperty(runtime, "submenu");
88
+ if (nested.isObject() && nested.asObject(runtime).isArray(runtime)) {
89
+ entry.submenu = entriesFrom(runtime, nested);
90
+ }
91
+ // Nothing is filtered here, deliberately. An entry naming a role this
92
+ // desktop cannot perform is not drawn -- `menuEntryShown` says so and each
93
+ // host skips it -- but it keeps its place in the vector, because the index
94
+ // that comes back is an index into the list JavaScript passed. Dropping it
95
+ // here would renumber everything after it onto the wrong handler.
65
96
  entries.push_back(std::move(entry));
66
97
  }
67
98
  return entries;
@@ -52,6 +52,7 @@
52
52
 
53
53
  #include <algorithm>
54
54
  #include <cassert>
55
+ #include <atomic>
55
56
  #include <cstdint>
56
57
  #include <memory>
57
58
  #include <string_view>
@@ -85,6 +86,13 @@ class MountingWalk {
85
86
  // types, and because a view can sit detached between a Remove and its Delete
86
87
  // while still needing to deliver a cancel.
87
88
  facebook::react::EventEmitter::Shared eventEmitterForTag(Tag tag) const {
89
+ // Nothing, once an instance teardown has been announced: every emitter in
90
+ // the map points into a Scheduler that is going away, and handing one out
91
+ // is a call into freed memory. The map itself is emptied on this thread --
92
+ // see `invalidatePendingMounts`.
93
+ if (emittersInvalid_.load(std::memory_order_acquire)) {
94
+ return nullptr;
95
+ }
88
96
  const auto it = eventEmitters_.find(tag);
89
97
  return it == eventEmitters_.end() ? nullptr : it->second;
90
98
  }
@@ -243,6 +251,11 @@ class MountingWalk {
243
251
  // core/TestSettle.h.
244
252
  noteMountApplied();
245
253
 
254
+ // Before anything is walked: an instance teardown may have announced itself
255
+ // from another thread while this transaction sat on the queue, and the
256
+ // emitters it left behind point into a Scheduler that is gone.
257
+ dropInvalidatedEmitters();
258
+
246
259
  for (const auto &mutation : mutations) {
247
260
  switch (mutation.type) {
248
261
  case facebook::react::ShadowViewMutation::Create:
@@ -273,12 +286,92 @@ class MountingWalk {
273
286
  resolveRefreshControls();
274
287
  }
275
288
 
289
+ // --- A mount that outlived the instance that made it ----------------------
290
+ //
291
+ // `executeMount` is called on the JS thread and every host queues the
292
+ // transaction to its UI thread, so one can still be waiting there when the
293
+ // React instance that produced it is torn down. A reload does exactly that,
294
+ // and `ReactHost::reloadReactInstance` does it on a detached thread: it stops
295
+ // the surfaces, destroys the Scheduler and the SurfaceManager, and builds new
296
+ // ones, while the mounting manager survives. Applying a queued transaction
297
+ // after that walks mutations whose event emitters point into an instance that
298
+ // is gone, and the host dies in its own UI thread with nothing to say.
299
+ //
300
+ // So a pending mount carries these two and drops itself when either says it is
301
+ // stale. `weak_ptr` rather than testing `use_count()`: several mounts can be
302
+ // queued at once, and each one's own copy would otherwise look like the
303
+ // manager still holding its.
304
+ std::weak_ptr<bool> mountGuard() const {
305
+ return alive_;
306
+ }
307
+
308
+ std::uint64_t mountEpoch() const {
309
+ return mountEpoch_.load(std::memory_order_relaxed);
310
+ }
311
+
312
+ // Called by a host when React Native hands it a null scheduler task executor,
313
+ // which is what `destroyReactInstance` does and the only notice a mounting
314
+ // manager gets that the instance is going away.
315
+ void invalidatePendingMounts() {
316
+ mountEpoch_.fetch_add(1, std::memory_order_relaxed);
317
+ // The emitters are the part that points into the dead instance. The views
318
+ // stay: the surfaces restart with the same ids, the host owns their roots,
319
+ // and the next transaction replaces what is under them. An event arriving in
320
+ // the gap now finds no emitter and does nothing, which is the right answer
321
+ // and was previously a call into freed memory.
322
+ //
323
+ // **A flag rather than the clear itself, and that is the whole of a crash
324
+ // this project chased twice.** This function runs on whatever thread
325
+ // `destroyReactInstance` is on, which is not the main one, while a mount
326
+ // queued earlier may be draining on the main thread -- and `eventEmitters_`
327
+ // is a `std::unordered_map`. Clearing it from under `destroy()`'s own
328
+ // `erase` is a plain data race, and what it produces is a wild pointer
329
+ // inside the hash table's `find`: `host exited -11` on CI's Mac, twice,
330
+ // with `MountingWalk::destroy` and an `unordered_map::erase` in the stack.
331
+ // backlog/testing.md carries both occurrences.
332
+ //
333
+ // So the flag goes up atomically, nothing hands an emitter out from this
334
+ // moment on -- which is what the clear was for -- and the map is emptied by
335
+ // the main thread the next time it walks a transaction or releases its
336
+ // views.
337
+ emittersInvalid_.store(true, std::memory_order_release);
338
+ }
339
+
340
+ // How many emitters are still remembered, whatever the flag above says.
341
+ //
342
+ // Only the suite asks, and it asks because the thing worth asserting about
343
+ // the teardown is *which thread* empties the map: an announcement that
344
+ // cleared it itself would be the data race this project chased twice, and
345
+ // `eventEmitterForTag` cannot tell the two apart -- it answers nothing either
346
+ // way. See `a_teardown_leaves_the_clearing_to_the_main_thread`.
347
+ std::size_t rememberedEmitterCount() const {
348
+ return eventEmitters_.size();
349
+ }
350
+
276
351
  protected:
277
352
  // Not virtual and not public: this is a mixin, never a base pointer.
278
353
  ~MountingWalk() = default;
279
354
 
355
+ // Empties the emitter map if a teardown asked for it, on the thread that owns
356
+ // it. The one place the map is cleared, and the reason it is not cleared where
357
+ // the asking happens: see `invalidatePendingMounts`.
358
+ void dropInvalidatedEmitters() {
359
+ if (emittersInvalid_.exchange(false, std::memory_order_acq_rel)) {
360
+ eventEmitters_.clear();
361
+ }
362
+ }
363
+
280
364
  // Platforms call this from their own destructor. See the note at the top.
281
365
  void releaseAllViews() {
366
+ dropInvalidatedEmitters();
367
+ // Same ordering as a Delete, and for the same reason: a side table holding a
368
+ // view has to be told before the view goes, not after. This path is a host's
369
+ // own destructor, so getting it wrong is a crash on the way out -- the kind
370
+ // that looks like a flaky test rather than a bug.
371
+ for (auto &[tag, view] : registry_) {
372
+ (void)view;
373
+ platform().forgetTag(tag);
374
+ }
282
375
  for (auto &[tag, view] : registry_) {
283
376
  (void)tag;
284
377
  platform().destroyView(view);
@@ -370,9 +463,17 @@ class MountingWalk {
370
463
  // in a later transaction.
371
464
  void create(const ShadowView &shadowView) {
372
465
  ViewRef view = platform().createView(shadowView);
466
+ // The emitter before the props, which is not the obvious order and is the
467
+ // one an apply needs: `updateView` is where a platform acts on what it was
468
+ // given, and acting on some props means sending an event at once.
469
+ // `<Image onLoadStart>` is the case that found this -- it fires when a load
470
+ // begins, a load begins on the mount that first carries the source, and
471
+ // `eventEmitterForTag` answered null for every one of them because the map
472
+ // was filled two lines further down. The event went nowhere, silently, on
473
+ // all three hosts.
474
+ rememberEventEmitter(shadowView);
373
475
  platform().updateView(view, shadowView);
374
476
  registry_[shadowView.tag] = view;
375
- rememberEventEmitter(shadowView);
376
477
  }
377
478
 
378
479
  void destroy(Tag tag) {
@@ -381,6 +482,14 @@ class MountingWalk {
381
482
  LOG(WARNING) << "Delete for unknown tag " << tag;
382
483
  return;
383
484
  }
485
+ // The platform lets go of the tag *before* the view is released, which is
486
+ // the ordering every per-tag side table needs: a scroll controller to
487
+ // detach, a text peer to drop, a canvas to unregister. Each of those takes
488
+ // the view it remembers, and after `destroyView` that pointer can be freed
489
+ // memory -- GTK's managers were checking `RN_IS_VIEW` to find out, which
490
+ // cannot work and crashed once it was asked on a poisoned object. See
491
+ // GtkFocus.cpp's destructor for the one that bit.
492
+ platform().forgetTag(tag);
384
493
  platform().destroyView(it->second);
385
494
  registry_.erase(it);
386
495
  eventEmitters_.erase(tag);
@@ -391,7 +500,6 @@ class MountingWalk {
391
500
  pullToRefresh_.forget(tag);
392
501
  modalViews_.erase(tag);
393
502
  modalStack_.erase(std::remove(modalStack_.begin(), modalStack_.end(), tag), modalStack_.end());
394
- platform().forgetTag(tag);
395
503
  }
396
504
 
397
505
  void insert(const facebook::react::ShadowViewMutation &mutation) {
@@ -516,6 +624,21 @@ class MountingWalk {
516
624
  std::uint64_t overlayGeneration_{0};
517
625
  std::shared_ptr<bool> alive_{std::make_shared<bool>(true)};
518
626
 
627
+ // Set when the React instance is torn down and cleared by the main thread;
628
+ // see invalidatePendingMounts and dropInvalidatedEmitters.
629
+ std::atomic<bool> emittersInvalid_{false};
630
+
631
+ // Bumped when the React instance is torn down; see invalidatePendingMounts.
632
+ //
633
+ // Atomic because the two ends are different threads: `destroyReactInstance`
634
+ // runs on a detached thread and bumps this, while `applyPendingMount` reads it
635
+ // on the main thread to decide whether to drop itself. A plain integer there is
636
+ // a data race on the guard that exists to prevent a use-after-free, which is
637
+ // the wrong place to have one. Relaxed ordering is enough: the only question
638
+ // asked of it is whether the value still equals the one a mount was queued
639
+ // with, and a mount that reads a stale epoch is dropped by the next one.
640
+ std::atomic<std::uint64_t> mountEpoch_{0};
641
+
519
642
  // The main thread, recorded at construction. `executeMount` arrives on the JS
520
643
  // thread and marshals here; `applyMutations` asserts it got there.
521
644
  std::thread::id mainThreadId_;
@@ -11,15 +11,31 @@
11
11
 
12
12
  #pragma once
13
13
 
14
+ #include <cstddef>
14
15
  #include <functional>
15
16
  #include <string>
17
+ #include <utility>
16
18
  #include <vector>
17
19
 
18
20
  namespace basalt {
19
21
 
20
22
  // --- Clipboard ---------------------------------------------------------------
21
23
 
22
- // The clipboard's text, or empty if it holds none. Called on the main thread.
24
+ // The clipboard's text, or empty if it holds none.
25
+ //
26
+ // **Called from the JavaScript thread**, by DesktopClipboardModule, which is
27
+ // where `Clipboard.getString()` and `setString()` arrive. This comment used to
28
+ // say "on the main thread", which was what the seam wanted rather than what it
29
+ // got: the GTK implementation believed it and called GDK from whichever thread
30
+ // asked, which deadlocked a CI run on 2026-10-08. See
31
+ // PlatformServicesGtk.cpp's clipboard section and backlog/testing.md.
32
+ //
33
+ // So each implementation answers for its own toolkit's threading rule. GTK hands
34
+ // the write to its main thread; Windows posts through a message-only window for
35
+ // other work and its clipboard calls are per-thread by design; macOS uses
36
+ // NSPasteboard, which is documented as not thread-safe and makes no blocking
37
+ // round trip against the main thread. backlog/host-wiring.md carries what is
38
+ // left.
23
39
  std::string clipboardText();
24
40
  void setClipboardText(const std::string &text);
25
41
 
@@ -105,6 +121,17 @@ void showFileDialog(const FileDialogRequest &request, FileDialogCallback onDone)
105
121
 
106
122
  // --- Menus --------------------------------------------------------------------
107
123
 
124
+ // Whether this desktop can perform `role` in a popup menu.
125
+ //
126
+ // False for every role a platform has no implementation for: Linux and Windows
127
+ // both lack `about`, neither having a platform about panel to ask for. Declared
128
+ // here rather than below because MenuEntry asks it.
129
+ bool menuRoleSupported(const std::string &role);
130
+
131
+ // Performs `role` now, on whatever currently has focus. Called on the UI thread,
132
+ // immediately before the chosen index is reported.
133
+ void performMenuRole(const std::string &role);
134
+
108
135
  // One entry in a popup menu. A separator is an item with an empty label, which
109
136
  // is how GMenu, NSMenu and an HMENU each spell it too.
110
137
  struct MenuEntry {
@@ -117,12 +144,58 @@ struct MenuEntry {
117
144
  // inside it are answered in different places on all three desktops.
118
145
  std::string shortcut;
119
146
 
147
+ // One of kMenuRoles in MenuModel.h, or empty for an item the app implements
148
+ // itself. A role is performed by the platform -- the Copy that actually
149
+ // copies -- and still reports its index, so a caller's `onSelect` runs too.
150
+ //
151
+ // A role this platform cannot perform is dropped rather than shown, the same
152
+ // policy the menu bar has: an item that does nothing is worse than one that is
153
+ // not there. `menuRoleSupported` says which, and the three do not agree.
154
+ std::string role;
155
+
156
+ // Nested entries. A parent is not choosable itself: it opens its children.
157
+ //
158
+ // Both a submenu and a role on one entry is a caller's mistake; the submenu
159
+ // wins, because that is the one with something under it to lose.
160
+ std::vector<MenuEntry> submenu;
161
+
162
+ // Whether the entry carries a mark, and which kind.
163
+ //
164
+ // Two fields rather than one `checked` bool, and GTK is the reason. A GMenu
165
+ // item has no checked attribute at all: the mark comes from the shape of the
166
+ // GAction behind it, and GtkPopoverMenu picks a tick or a circle from whether
167
+ // that action is boolean-stateful or string-stateful with a target. So a bool
168
+ // cannot say which to draw, and the kind has to be its own field.
169
+ //
170
+ // `Radio` is a drawing hint rather than a capability. Windows draws a bullet
171
+ // and GTK a circle; NSMenu has no radio item, and the mark macOS puts beside a
172
+ // selected member of an exclusive group is a tick, which is what Apple's own
173
+ // guidance prescribes. All three can mark a chosen item, so neither kind is
174
+ // refused anywhere.
175
+ //
176
+ // Nothing tri-state: NSControlStateValueMixed has no counterpart on the other
177
+ // two, so offering it would be offering something only one desktop has.
178
+ enum class Mark { None, Check, Radio };
179
+ Mark mark{Mark::None};
180
+ bool checked{false};
181
+
120
182
  static MenuEntry separator() {
121
- return MenuEntry{.label = {}, .enabled = false, .shortcut = {}};
183
+ // Assigned rather than a designated initializer. One that names some fields
184
+ // and not others is a -Wmissing-field-initializers error under -Werror, which
185
+ // the GTK build turns on and the AppKit one does not -- so adding `role` and
186
+ // `submenu` compiled on a Mac and failed all three Linux shards. Written this
187
+ // way, the next field costs nothing.
188
+ MenuEntry entry;
189
+ entry.enabled = false;
190
+ return entry;
122
191
  }
123
192
 
124
193
  bool isSeparator() const {
125
- return label.empty();
194
+ return label.empty() && submenu.empty();
195
+ }
196
+
197
+ bool isParent() const {
198
+ return !submenu.empty();
126
199
  }
127
200
  };
128
201
 
@@ -134,6 +207,117 @@ struct MenuRequest {
134
207
  double y{-1.0};
135
208
  };
136
209
 
210
+ // --- Indexing a menu that is now a tree ---------------------------------------
211
+ //
212
+ // `showMenu` answers with an index, and did so when a popup was a flat list. The
213
+ // index is an entry's position in a *pre-order* walk of the whole menu, counting
214
+ // separators and submenu parents -- so for a flat list it is still the position
215
+ // in the vector, and every caller that predates nesting means what it always
216
+ // meant.
217
+ //
218
+ // Hosts use `walkMenuEntries` to tag their items and `menuEntryAt` to find what
219
+ // an index named, which is how a role is performed and how a parent is told from
220
+ // a leaf.
221
+ namespace detail {
222
+
223
+ template <typename Visitor>
224
+ int walkMenuEntriesFrom(const std::vector<MenuEntry> &entries, int next, Visitor &visit) {
225
+ for (const MenuEntry &entry : entries) {
226
+ const int index = next++;
227
+ visit(entry, index);
228
+ if (entry.isParent()) {
229
+ next = walkMenuEntriesFrom(entry.submenu, next, visit);
230
+ }
231
+ }
232
+ return next;
233
+ }
234
+
235
+ } // namespace detail
236
+
237
+ template <typename Visitor>
238
+ void walkMenuEntries(const std::vector<MenuEntry> &entries, Visitor &&visit) {
239
+ detail::walkMenuEntriesFrom(entries, 0, visit);
240
+ }
241
+
242
+ // The run of consecutive radio entries `index` belongs to, as a half-open range
243
+ // of pre-order indexes, or {index, index} when it is not a radio entry.
244
+ //
245
+ // A group is a maximal run of adjacent radio siblings, ended by a separator, by a
246
+ // non-radio entry, or by the end of the level. That is a positional definition
247
+ // rather than an identifier on each item, and it fits all three: Win32's
248
+ // CheckMenuRadioItem wants a contiguous range, GTK wants one action per run, and
249
+ // AppKit wants nothing. It also cannot straddle a GMenu section, since the GTK
250
+ // builder breaks into a new section at every separator.
251
+ //
252
+ // The cost is that two adjacent groups with no separator between them read as
253
+ // one. A separator is what every desktop draws between two groups anyway.
254
+ inline std::pair<int, int> menuRadioRun(const std::vector<MenuEntry> &siblings,
255
+ int firstIndexOfLevel,
256
+ std::size_t at) {
257
+ const int index = firstIndexOfLevel + static_cast<int>(at);
258
+ if (at >= siblings.size() || siblings[at].mark != MenuEntry::Mark::Radio) {
259
+ return {index, index};
260
+ }
261
+ std::size_t begin = at;
262
+ while (begin > 0 && siblings[begin - 1].mark == MenuEntry::Mark::Radio) {
263
+ begin--;
264
+ }
265
+ std::size_t end = at + 1;
266
+ while (end < siblings.size() && siblings[end].mark == MenuEntry::Mark::Radio) {
267
+ end++;
268
+ }
269
+ return {firstIndexOfLevel + static_cast<int>(begin),
270
+ firstIndexOfLevel + static_cast<int>(end)};
271
+ }
272
+
273
+ // How many entries a menu has, counting every level. The number of valid
274
+ // indexes, which is not `entries.size()` once anything nests.
275
+ inline int menuEntryCount(const std::vector<MenuEntry> &entries) {
276
+ int count = 0;
277
+ walkMenuEntries(entries, [&](const MenuEntry &, int) { ++count; });
278
+ return count;
279
+ }
280
+
281
+ inline const MenuEntry *menuEntryAt(const std::vector<MenuEntry> &entries, int index) {
282
+ const MenuEntry *found = nullptr;
283
+ walkMenuEntries(entries, [&](const MenuEntry &entry, int at) {
284
+ if (at == index) {
285
+ found = &entry;
286
+ }
287
+ });
288
+ return found;
289
+ }
290
+
291
+ // Whether an entry is drawn at all.
292
+ //
293
+ // False for a role this desktop cannot perform, and for a parent with nothing
294
+ // under it that would be drawn. A dropped entry is still *counted*, which is the
295
+ // part that matters: JavaScript numbers the list it passed, so an entry
296
+ // disappearing from the count would shift every index after it onto the wrong
297
+ // handler. See walkMenuEntries.
298
+ inline bool menuEntryShown(const MenuEntry &entry);
299
+
300
+ inline bool anyMenuEntryShown(const std::vector<MenuEntry> &entries) {
301
+ for (const MenuEntry &entry : entries) {
302
+ if (menuEntryShown(entry)) {
303
+ return true;
304
+ }
305
+ }
306
+ return false;
307
+ }
308
+
309
+ inline bool menuEntryShown(const MenuEntry &entry) {
310
+ if (!entry.role.empty() && !menuRoleSupported(entry.role)) {
311
+ return false;
312
+ }
313
+ if (entry.isParent()) {
314
+ // An empty submenu is a dead end rather than an item, which is what a parent
315
+ // becomes when every child named a role this desktop does not have.
316
+ return anyMenuEntryShown(entry.submenu);
317
+ }
318
+ return true;
319
+ }
320
+
137
321
  // Shows a popup menu and calls `onChosen` with the index of the entry picked,
138
322
  // or -1 when it was dismissed. Indexes count separators, so they line up with
139
323
  // the vector that was passed in.
@@ -0,0 +1,115 @@
1
+ #include "SafeAreaComponent.h"
2
+
3
+ #include <jsi/jsi.h>
4
+
5
+ namespace facebook::react {
6
+
7
+ extern const char SafeAreaProviderComponentName[] = "RNCSafeAreaProvider";
8
+
9
+ void SafeAreaProviderEventEmitter::onInsetsChange(const Rect &frame,
10
+ const EdgeInsets &insets) const {
11
+ dispatchEvent("insetsChange", [frame, insets](jsi::Runtime &runtime) {
12
+ auto frameOut = jsi::Object(runtime);
13
+ frameOut.setProperty(runtime, "x", frame.origin.x);
14
+ frameOut.setProperty(runtime, "y", frame.origin.y);
15
+ frameOut.setProperty(runtime, "width", frame.size.width);
16
+ frameOut.setProperty(runtime, "height", frame.size.height);
17
+
18
+ auto insetsOut = jsi::Object(runtime);
19
+ insetsOut.setProperty(runtime, "top", insets.top);
20
+ insetsOut.setProperty(runtime, "right", insets.right);
21
+ insetsOut.setProperty(runtime, "bottom", insets.bottom);
22
+ insetsOut.setProperty(runtime, "left", insets.left);
23
+
24
+ auto payload = jsi::Object(runtime);
25
+ payload.setProperty(runtime, "frame", std::move(frameOut));
26
+ payload.setProperty(runtime, "insets", std::move(insetsOut));
27
+ return payload;
28
+ });
29
+ }
30
+
31
+ // Emitted on every layout that produces a real frame, rather than only on a
32
+ // change.
33
+ //
34
+ // A shadow node is cloned per commit, so there is nowhere here to remember what
35
+ // was sent last without carrying Fabric State for it, and State for a value the
36
+ // platform already knows would be machinery for its own sake. The deduplication
37
+ // that matters is already in the library: `setInsets` and `setFrame` both
38
+ // compare before they store, so an identical event is a React state write that
39
+ // bails out without re-rendering, and the loop this would otherwise be settles
40
+ // after one pass.
41
+ //
42
+ // The zero-size guard is not an optimisation. The first layout of a surface can
43
+ // produce an empty frame, and emitting that would unblock the provider's
44
+ // children against a 0x0 frame, which is worse than making them wait one more
45
+ // pass for the real one.
46
+ void SafeAreaProviderShadowNode::layout(LayoutContext layoutContext) {
47
+ ConcreteViewShadowNode::layout(layoutContext);
48
+
49
+ const auto frame = getLayoutMetrics().frame;
50
+ if (frame.size.width <= 0 || frame.size.height <= 0) {
51
+ return;
52
+ }
53
+ // Zero on every desktop: a window has no notch and no home indicator. See the
54
+ // header for what would change that.
55
+ getConcreteEventEmitter().onInsetsChange(frame, EdgeInsets{0, 0, 0, 0});
56
+ }
57
+
58
+ } // namespace facebook::react
59
+
60
+ namespace basalt {
61
+ namespace {
62
+
63
+ // Written once by the host thread before JavaScript runs and read from the JS
64
+ // thread afterwards, which is the ordering every host already has for its
65
+ // window. Plain doubles rather than an atomic pair: a torn read here would
66
+ // produce a slightly wrong first frame that the first `onInsetsChange`
67
+ // immediately replaces, and a mutex on a value read once is not worth it.
68
+ double gWindowWidth = 0;
69
+ double gWindowHeight = 0;
70
+
71
+ } // namespace
72
+
73
+ void setInitialWindowFrame(double width, double height) {
74
+ gWindowWidth = width;
75
+ gWindowHeight = height;
76
+ }
77
+
78
+ DesktopSafeAreaModule::DesktopSafeAreaModule(
79
+ std::shared_ptr<facebook::react::CallInvoker> jsInvoker)
80
+ : TurboModule(kModuleName, std::move(jsInvoker)) {
81
+ methodMap_["getConstants"] = MethodMetadata{0, getConstants};
82
+ }
83
+
84
+ facebook::jsi::Value DesktopSafeAreaModule::getConstants(
85
+ facebook::jsi::Runtime &runtime,
86
+ facebook::react::TurboModule & /*module*/,
87
+ const facebook::jsi::Value * /*args*/,
88
+ size_t /*count*/) {
89
+ auto constants = facebook::jsi::Object(runtime);
90
+ if (gWindowWidth <= 0 || gWindowHeight <= 0) {
91
+ // Absent rather than zero. See setInitialWindowFrame.
92
+ return constants;
93
+ }
94
+
95
+ auto insets = facebook::jsi::Object(runtime);
96
+ insets.setProperty(runtime, "top", 0);
97
+ insets.setProperty(runtime, "right", 0);
98
+ insets.setProperty(runtime, "bottom", 0);
99
+ insets.setProperty(runtime, "left", 0);
100
+
101
+ auto frame = facebook::jsi::Object(runtime);
102
+ frame.setProperty(runtime, "x", 0);
103
+ frame.setProperty(runtime, "y", 0);
104
+ frame.setProperty(runtime, "width", gWindowWidth);
105
+ frame.setProperty(runtime, "height", gWindowHeight);
106
+
107
+ auto metrics = facebook::jsi::Object(runtime);
108
+ metrics.setProperty(runtime, "insets", std::move(insets));
109
+ metrics.setProperty(runtime, "frame", std::move(frame));
110
+
111
+ constants.setProperty(runtime, "initialWindowMetrics", std::move(metrics));
112
+ return constants;
113
+ }
114
+
115
+ } // namespace basalt