basalt-gtk 0.0.0-rc5

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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/cli/runLinux.js +29 -0
  3. package/cli/shared.js +75 -0
  4. package/native/CMakeLists.txt +362 -0
  5. package/native/gtk/ColorSchemeGtk.cpp +58 -0
  6. package/native/gtk/ComponentRegistryGtk.cpp +101 -0
  7. package/native/gtk/FontRegistryFontconfig.cpp +127 -0
  8. package/native/gtk/GtkAnimationChoreographer.cpp +73 -0
  9. package/native/gtk/GtkAnimationChoreographer.h +49 -0
  10. package/native/gtk/GtkDropTarget.cpp +269 -0
  11. package/native/gtk/GtkDropTarget.h +40 -0
  12. package/native/gtk/GtkFocus.cpp +296 -0
  13. package/native/gtk/GtkFocus.h +113 -0
  14. package/native/gtk/GtkImageLoader.cpp +128 -0
  15. package/native/gtk/GtkImageLoader.h +87 -0
  16. package/native/gtk/GtkKeyEvents.cpp +91 -0
  17. package/native/gtk/GtkKeyEvents.h +21 -0
  18. package/native/gtk/GtkMenuBar.cpp +45 -0
  19. package/native/gtk/GtkMountingManager.cpp +1027 -0
  20. package/native/gtk/GtkMountingManager.h +146 -0
  21. package/native/gtk/GtkRunLoopObserver.cpp +77 -0
  22. package/native/gtk/GtkRunLoopObserver.h +41 -0
  23. package/native/gtk/GtkScrollView.cpp +626 -0
  24. package/native/gtk/GtkScrollView.h +209 -0
  25. package/native/gtk/GtkSkiaContext.cpp +248 -0
  26. package/native/gtk/GtkSkiaContext.h +62 -0
  27. package/native/gtk/GtkSkiaModule.cpp +80 -0
  28. package/native/gtk/GtkSkiaModule.h +51 -0
  29. package/native/gtk/GtkSkiaPeer.cpp +285 -0
  30. package/native/gtk/GtkSkiaPeer.h +63 -0
  31. package/native/gtk/GtkTextInput.cpp +428 -0
  32. package/native/gtk/GtkTextInput.h +123 -0
  33. package/native/gtk/GtkTextPeer.cpp +425 -0
  34. package/native/gtk/GtkTextPeer.h +87 -0
  35. package/native/gtk/GtkTitleBar.cpp +273 -0
  36. package/native/gtk/GtkTitleBar.h +96 -0
  37. package/native/gtk/GtkTitleBarLayout.cpp +45 -0
  38. package/native/gtk/GtkTitleBarLayout.h +36 -0
  39. package/native/gtk/GtkTouchDispatcher.cpp +671 -0
  40. package/native/gtk/GtkTouchDispatcher.h +139 -0
  41. package/native/gtk/GtkWindowControl.cpp +223 -0
  42. package/native/gtk/GtkWindowModule.cpp +194 -0
  43. package/native/gtk/GtkWindowModule.h +62 -0
  44. package/native/gtk/PangoTextLayout.cpp +295 -0
  45. package/native/gtk/PangoTextLayout.h +54 -0
  46. package/native/gtk/PangoTextLayoutManager.cpp +140 -0
  47. package/native/gtk/PlatformServicesGtk.cpp +583 -0
  48. package/native/gtk/RnView.cpp +1407 -0
  49. package/native/gtk/RnView.h +339 -0
  50. package/native/gtk/demo_layout_gtk.cpp +57 -0
  51. package/native/gtk/main_gtk.cpp +1981 -0
  52. package/native/gtk/mount_harness_gtk.cpp +143 -0
  53. package/native/tests/TestMainGtk.cpp +25 -0
  54. package/native/tests/notification_stub.cpp +103 -0
  55. package/native/tests/test_accessibility.cpp +251 -0
  56. package/native/tests/test_focus.cpp +194 -0
  57. package/native/tests/test_hittest.cpp +242 -0
  58. package/native/tests/test_image.cpp +135 -0
  59. package/native/tests/test_key_events_gtk.cpp +112 -0
  60. package/native/tests/test_mounting.cpp +415 -0
  61. package/native/tests/test_scrollview.cpp +258 -0
  62. package/native/tests/test_skia_texture.cpp +120 -0
  63. package/native/tests/test_text.cpp +147 -0
  64. package/native/tests/test_textinput.cpp +598 -0
  65. package/native/tests/test_titlebar.cpp +137 -0
  66. package/native/tests/test_titlebar_drag.cpp +138 -0
  67. package/native/tests/test_version.cpp +49 -0
  68. package/native/tests/test_view.cpp +162 -0
  69. package/native/tests/test_viewprops.cpp +254 -0
  70. package/package.json +26 -0
  71. package/react-native.config.js +21 -0
@@ -0,0 +1,1027 @@
1
+ #include "GtkMountingManager.h"
2
+
3
+ #ifdef BASALT_HAS_SKIA
4
+ #include "GtkSkiaPeer.h"
5
+ #endif
6
+
7
+ #include "ComponentRegistry.h"
8
+ #include "ExpoImageComponent.h"
9
+ #include "UIManagerAccess.h"
10
+ #include "PangoTextLayout.h"
11
+
12
+ #include <react/renderer/components/image/ImageEventEmitter.h>
13
+ #include <react/renderer/components/view/AccessibilityProps.h>
14
+ #include <react/renderer/graphics/Transform.h>
15
+ #include <react/renderer/components/scrollview/ScrollViewProps.h>
16
+ #include <react/renderer/components/image/ImageProps.h>
17
+ #include <react/renderer/components/text/ParagraphState.h>
18
+ #include <react/renderer/components/view/ViewProps.h>
19
+ #include <react/renderer/core/ConcreteState.h>
20
+ #include <react/renderer/graphics/Color.h>
21
+
22
+ #include <cassert>
23
+ #include <cstdio>
24
+ #include <iterator>
25
+ #include <map>
26
+ #include <string>
27
+ #include <type_traits>
28
+
29
+ namespace basalt {
30
+
31
+ // `override` proves each signature matches the interface, but not that every
32
+ // pure virtual is implemented -- nothing here instantiates the class. This
33
+ // catches an IMountingManager method going unimplemented as the interface
34
+ // evolves upstream.
35
+ static_assert(!std::is_abstract_v<GtkMountingManager>,
36
+ "GtkMountingManager must implement all of IMountingManager");
37
+
38
+ using facebook::react::ColorComponents;
39
+ using facebook::react::ComponentRegistryFactory;
40
+ using facebook::react::MountingTransaction;
41
+ using facebook::react::ShadowView;
42
+ using facebook::react::ShadowViewMutation;
43
+ using facebook::react::SurfaceId;
44
+ using facebook::react::Tag;
45
+ using facebook::react::ImageEventEmitter;
46
+ using facebook::react::ImageProps;
47
+ using facebook::react::ImageResizeMode;
48
+ using facebook::react::ParagraphState;
49
+ using facebook::react::ViewProps;
50
+
51
+ namespace {
52
+
53
+ // React Native's accessibilityRole is an open string, and its vocabulary is
54
+ // mostly ARIA's, which is also what GTK's roles are modelled on. Anything
55
+ // unrecognised falls back to GENERIC rather than guessing: a wrong role is
56
+ // worse for a screen reader than no role, because it makes the widget announce
57
+ // itself as something it is not.
58
+ GtkAccessibleRole toAccessibleRole(const std::string &role) {
59
+ static const std::unordered_map<std::string, GtkAccessibleRole> kRoles = {
60
+ {"button", GTK_ACCESSIBLE_ROLE_BUTTON},
61
+ {"togglebutton", GTK_ACCESSIBLE_ROLE_TOGGLE_BUTTON},
62
+ {"link", GTK_ACCESSIBLE_ROLE_LINK},
63
+ {"search", GTK_ACCESSIBLE_ROLE_SEARCH_BOX},
64
+ {"image", GTK_ACCESSIBLE_ROLE_IMG},
65
+ {"imagebutton", GTK_ACCESSIBLE_ROLE_BUTTON},
66
+ {"text", GTK_ACCESSIBLE_ROLE_LABEL},
67
+ {"header", GTK_ACCESSIBLE_ROLE_ROW_HEADER},
68
+ {"adjustable", GTK_ACCESSIBLE_ROLE_SLIDER},
69
+ {"alert", GTK_ACCESSIBLE_ROLE_ALERT},
70
+ {"checkbox", GTK_ACCESSIBLE_ROLE_CHECKBOX},
71
+ {"combobox", GTK_ACCESSIBLE_ROLE_COMBO_BOX},
72
+ {"menu", GTK_ACCESSIBLE_ROLE_MENU},
73
+ {"menubar", GTK_ACCESSIBLE_ROLE_MENU_BAR},
74
+ {"menuitem", GTK_ACCESSIBLE_ROLE_MENU_ITEM},
75
+ {"progressbar", GTK_ACCESSIBLE_ROLE_PROGRESS_BAR},
76
+ {"radio", GTK_ACCESSIBLE_ROLE_RADIO},
77
+ {"radiogroup", GTK_ACCESSIBLE_ROLE_RADIO_GROUP},
78
+ {"scrollbar", GTK_ACCESSIBLE_ROLE_SCROLLBAR},
79
+ {"spinbutton", GTK_ACCESSIBLE_ROLE_SPIN_BUTTON},
80
+ {"switch", GTK_ACCESSIBLE_ROLE_SWITCH},
81
+ {"tab", GTK_ACCESSIBLE_ROLE_TAB},
82
+ {"tablist", GTK_ACCESSIBLE_ROLE_TAB_LIST},
83
+ {"list", GTK_ACCESSIBLE_ROLE_LIST},
84
+ {"grid", GTK_ACCESSIBLE_ROLE_GRID},
85
+ {"toolbar", GTK_ACCESSIBLE_ROLE_TOOLBAR},
86
+ {"tooltip", GTK_ACCESSIBLE_ROLE_TOOLTIP},
87
+ {"none", GTK_ACCESSIBLE_ROLE_PRESENTATION},
88
+ {"presentation", GTK_ACCESSIBLE_ROLE_PRESENTATION},
89
+ };
90
+
91
+ const auto it = kRoles.find(role);
92
+ return it == kRoles.end() ? GTK_ACCESSIBLE_ROLE_GENERIC : it->second;
93
+ }
94
+
95
+ RnAccessibleFlag toFlag(bool value) {
96
+ return value ? RN_A11Y_TRUE : RN_A11Y_FALSE;
97
+ }
98
+
99
+ GdkRGBA toRgba(const ColorComponents &components) {
100
+ GdkRGBA rgba;
101
+ rgba.red = components.red;
102
+ rgba.green = components.green;
103
+ rgba.blue = components.blue;
104
+ rgba.alpha = components.alpha;
105
+ return rgba;
106
+ }
107
+
108
+ } // namespace
109
+
110
+ GtkMountingManager::GtkMountingManager()
111
+ : scrollViews_([this](Tag tag) { return eventEmitterForTag(tag); }),
112
+ textInputs_([this](Tag tag) { return eventEmitterForTag(tag); }) {
113
+ // The pull past the top of a list, counted in core/PullToRefresh.h and fired
114
+ // at whichever <RefreshControl> that scroll view has. Identical on all three
115
+ // desktops, which is the point of the two of them being portable.
116
+ scrollViews_.setOverscrollTopHandler([this](Tag tag, double amount) {
117
+ if (amount <= 0.0) {
118
+ pullToRefresh().release(tag);
119
+ return;
120
+ }
121
+ if (pullToRefresh().pull(tag, amount)) {
122
+ fireRefresh(tag);
123
+ }
124
+ });
125
+ }
126
+
127
+ GtkMountingManager::~GtkMountingManager() noexcept {
128
+ // MountingWalk cannot do this itself: by the time a base destructor runs, the
129
+ // GTK half that knows how to release a widget is already gone.
130
+ releaseAllViews();
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Mutation walk
135
+ // ---------------------------------------------------------------------------
136
+
137
+ namespace {
138
+
139
+ // Carries a transaction from the JS thread to the GTK main thread.
140
+ struct PendingMount {
141
+ GtkMountingManager *manager;
142
+ SurfaceId surfaceId;
143
+ MountingTransaction transaction;
144
+ };
145
+
146
+ gboolean applyPendingMount(gpointer data) {
147
+ auto *pending = static_cast<PendingMount *>(data);
148
+ pending->manager->applyTransaction(pending->surfaceId, std::move(pending->transaction));
149
+ delete pending;
150
+ return G_SOURCE_REMOVE;
151
+ }
152
+
153
+ // The same trip for an imperative command. See dispatchCommand below.
154
+ struct PendingCommand {
155
+ GtkMountingManager *manager;
156
+ Tag tag;
157
+ std::string name;
158
+ folly::dynamic args;
159
+ };
160
+
161
+ gboolean applyPendingCommand(gpointer data) {
162
+ std::unique_ptr<PendingCommand> pending{static_cast<PendingCommand *>(data)};
163
+ pending->manager->applyCommand(pending->tag, pending->name, pending->args);
164
+ return G_SOURCE_REMOVE;
165
+ }
166
+
167
+ } // namespace
168
+
169
+ void GtkMountingManager::executeMount(SurfaceId surfaceId, MountingTransaction &&transaction) {
170
+ // This runs on the JS thread: Scheduler::uiManagerDidFinishTransaction queues
171
+ // the mount via RuntimeScheduler::scheduleRenderingUpdate, which drains in
172
+ // the event loop's "update the rendering" step with the jsi::Runtime live.
173
+ // GTK widgets are main-thread-only, so nothing here may touch them.
174
+ //
175
+ // Always queue, never invoke directly even when already on the main thread:
176
+ // g_idle sources at equal priority run in the order they were added, which is
177
+ // what keeps mutation ordering intact. iOS and Android marshal here too.
178
+ auto *pending = new PendingMount{this, surfaceId, std::move(transaction)};
179
+ g_idle_add_full(G_PRIORITY_DEFAULT, applyPendingMount, pending, nullptr);
180
+ }
181
+
182
+ void GtkMountingManager::applyTransaction(SurfaceId surfaceId, MountingTransaction &&transaction) {
183
+ // The walk itself is in core/MountingWalk.h and is shared with every other
184
+ // desktop platform; what is GTK about mounting is below, in the handful of
185
+ // operations it calls back into.
186
+ applyMutations(transaction.getMutations());
187
+
188
+ // Tell the UIManager the transaction is on screen. Anything registered as a
189
+ // mount hook -- Reanimated's is the one that matters here -- is waiting for
190
+ // this, and without it an animated style is computed every frame, committed
191
+ // to the shadow tree and never resumed, so nothing moves.
192
+ //
193
+ // iOS does this from RCTSurfacePresenter and Android from its mounting
194
+ // manager. ReactCxxPlatform does it nowhere, which is a gap in the shared
195
+ // platform rather than in either host: nothing in it had a mount hook until
196
+ // a third-party library brought one.
197
+ if (auto uiManager = sharedUIManager()) {
198
+ uiManager->reportMount(surfaceId);
199
+ }
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // What MountingWalk asks of a platform
204
+ // ---------------------------------------------------------------------------
205
+
206
+ RnView *GtkMountingManager::createView(const ShadowView &shadowView) {
207
+ // The accessible role has to be decided now: GTK4 makes it construct-only,
208
+ // and RnView is one class for every React Native view. This is the reason
209
+ // createView is handed the whole ShadowView rather than just a tag.
210
+ // The role React Native effectively means for this view: what the app asked
211
+ // for, or what the component implies. A <Text> is a label and an <Image> is an
212
+ // image whether or not the app said so, which is what makes an ordinary screen
213
+ // navigable without every developer having annotated it.
214
+ std::string roleName;
215
+ if (const auto accessibility =
216
+ std::dynamic_pointer_cast<const facebook::react::AccessibilityProps>(shadowView.props)) {
217
+ roleName = accessibility->accessibilityRole;
218
+ }
219
+ if (roleName.empty() && shadowView.componentName != nullptr) {
220
+ const std::string_view name(shadowView.componentName);
221
+ if (name == "Paragraph") {
222
+ roleName = "text";
223
+ } else if (name == "Image") {
224
+ roleName = "image";
225
+ }
226
+ }
227
+ GtkAccessibleRole role = toAccessibleRole(roleName);
228
+
229
+ // A <TextInput>'s wrapper is not a control, and should not be an element.
230
+ //
231
+ // The peer inside it is a real GtkText and is already the text box AT-SPI
232
+ // presents; the wrapper around it adds a second stop that says the same
233
+ // thing or nothing. Presentation is how GTK says "I am scaffolding" -- and
234
+ // it has to be decided here because a GtkAccessible role is construct-only,
235
+ // which is the whole reason the AppKit side could do this in
236
+ // applyAccessibility and this one could not.
237
+ //
238
+ // `roleName` is deliberately left empty rather than set to something like
239
+ // "presentation": it is React Native's vocabulary and it prints in
240
+ // describeTree, where AppKit has nothing to print. That asymmetry already
241
+ // broke the cross-host diff once, in phase 53.
242
+ if (roleName.empty() && shadowView.componentName != nullptr &&
243
+ std::string_view(shadowView.componentName) == "TextInput") {
244
+ role = GTK_ACCESSIBLE_ROLE_PRESENTATION;
245
+ }
246
+
247
+ RnView *view = rn_view_new_with_role(static_cast<int>(shadowView.tag), role);
248
+
249
+ // React Native's own name for the same decision, for the tree dump. Set here
250
+ // rather than in applyAccessibility so the two vocabularies always describe
251
+ // the same choice: GTK's role is construct-only, so a role changed after
252
+ // mount would leave the name telling a truth the widget does not.
253
+ rn_view_set_role_name(view, roleName.c_str());
254
+ // A fresh GtkWidget carries a floating reference. Sinking it here makes the
255
+ // registry the owner, so the view survives the gap between a Remove and the
256
+ // Insert that re-parents it.
257
+ g_object_ref_sink(view);
258
+ return view;
259
+ }
260
+
261
+ RnView *GtkMountingManager::createRootView(Tag tag) {
262
+ RnView *root = rn_view_new(static_cast<int>(tag));
263
+ g_object_ref_sink(root);
264
+ return root;
265
+ }
266
+
267
+ void GtkMountingManager::destroyView(RnView *view) {
268
+ g_object_unref(view);
269
+ }
270
+
271
+ void GtkMountingManager::insertChild(RnView *parent, RnView *child, int index) {
272
+ rn_view_insert_child(parent, child, index);
273
+ }
274
+
275
+ void GtkMountingManager::removeChild(RnView *parent, RnView *child) {
276
+ rn_view_remove_child(parent, child);
277
+ }
278
+
279
+ void GtkMountingManager::forgetTag(Tag tag) {
280
+ imageUris_.erase(tag);
281
+ scrollViews_.remove(tag);
282
+ textInputs_.remove(tag);
283
+ switchValues_.erase(tag);
284
+ controlClasses_.erase(tag);
285
+ #ifdef BASALT_HAS_SKIA
286
+ // Unregisters the canvas. This is the one moment a view is known to be
287
+ // finished with, and a canvas left registered is a surface the package
288
+ // keeps rendering into for a widget nobody will draw again.
289
+ forgetSkiaCanvas(tag);
290
+ #endif
291
+ }
292
+
293
+ void GtkMountingManager::dispatchCommand(const ShadowView &shadowView,
294
+ const std::string &commandName,
295
+ const folly::dynamic &args) {
296
+ // This arrives on the JS thread, inside the event loop's rendering update,
297
+ // exactly like executeMount -- so it may not touch a widget either. That was
298
+ // survivable while the only commands were ScrollView's, which only move an
299
+ // adjustment; TextInput's `focus` reaches the platform input method, and on
300
+ // macOS AppKit asserts it is on the main thread and traps the process.
301
+ //
302
+ // Queued at the same priority as a mount, so it stays behind the transaction
303
+ // that created the view it names: g_idle sources at equal priority run in
304
+ // the order they were added.
305
+ auto *pending = new PendingCommand{this, shadowView.tag, commandName, args};
306
+ g_idle_add_full(G_PRIORITY_DEFAULT, applyPendingCommand, pending, nullptr);
307
+ }
308
+
309
+ void GtkMountingManager::applyCommand(Tag tag,
310
+ const std::string &commandName,
311
+ const folly::dynamic &args) {
312
+ assert(onMainThread() && "applyCommand must run on the GTK main thread");
313
+
314
+ if (scrollViews_.dispatchCommand(tag, commandName, args)) {
315
+ return;
316
+ }
317
+ if (textInputs_.dispatchCommand(tag, commandName, args)) {
318
+ return;
319
+ }
320
+ // React DevTools' overlay, answered the same way on all three: see
321
+ // core/DebuggingOverlay.h.
322
+ if (applyOverlayCommand(tag, commandName, args)) {
323
+ return;
324
+ }
325
+ g_debug("dispatchCommand '%s' on tag %d is not implemented",
326
+ commandName.c_str(),
327
+ static_cast<int>(tag));
328
+ }
329
+
330
+ void GtkMountingManager::setUIManager(std::weak_ptr<facebook::react::UIManager> uiManager) noexcept {
331
+ setSharedUIManager(std::move(uiManager));
332
+ }
333
+
334
+ ComponentRegistryFactory GtkMountingManager::getComponentRegistryFactory() {
335
+ // The default factory registers the core components RN ships C++ descriptors
336
+ // for (View, Text, Image, ScrollView, ...). Linux-specific components get
337
+ // added here later.
338
+ return facebook::react::getDefaultComponentRegistryFactory();
339
+ }
340
+
341
+ namespace {
342
+
343
+ RnImageFit toImageFit(ImageResizeMode mode) {
344
+ switch (mode) {
345
+ case ImageResizeMode::Contain:
346
+ return RN_IMAGE_FIT_CONTAIN;
347
+ case ImageResizeMode::Stretch:
348
+ return RN_IMAGE_FIT_STRETCH;
349
+ case ImageResizeMode::Center:
350
+ case ImageResizeMode::None:
351
+ return RN_IMAGE_FIT_CENTER;
352
+ case ImageResizeMode::Repeat:
353
+ return RN_IMAGE_FIT_REPEAT;
354
+ case ImageResizeMode::Cover:
355
+ break;
356
+ }
357
+ return RN_IMAGE_FIT_COVER;
358
+ }
359
+
360
+ } // namespace
361
+
362
+ // React Native's cxx ImageManager is a stub that never produces an
363
+ // ImageResponse, so nothing arrives through ImageState. The URI is read off the
364
+ // props and loaded here instead, which is also how Android does it.
365
+ void GtkMountingManager::applyImage(RnView *view, const ShadowView &shadowView) {
366
+ if (shadowView.componentName == nullptr) {
367
+ return;
368
+ }
369
+
370
+ // Two components draw an image here: React Native's <Image> and expo-image's
371
+ // view, which is an ordinary Fabric component with its own props (see
372
+ // core/ExpoImageComponent.h). Everything past reading the source and the fit
373
+ // off the props is identical, and the only other difference is which emitter
374
+ // reports the result, because the two carry different payloads.
375
+ const std::string_view componentName(shadowView.componentName);
376
+ const bool isExpoImage = componentName == facebook::react::ExpoImageComponentName;
377
+ if (componentName != "Image" && !isExpoImage) {
378
+ return;
379
+ }
380
+
381
+ RnImageFit fit = RN_IMAGE_FIT_COVER;
382
+ facebook::react::ImageSource source{};
383
+ // `tintColor` on React Native's <Image>, and the same prop on expo-image.
384
+ facebook::react::SharedColor tint{};
385
+
386
+ if (isExpoImage) {
387
+ const auto props = std::dynamic_pointer_cast<const facebook::react::ExpoImageProps>(shadowView.props);
388
+ if (props == nullptr) {
389
+ return;
390
+ }
391
+ fit = toImageFit(props->contentFit);
392
+ tint = props->tintColor;
393
+ if (!props->sources.empty()) {
394
+ source = props->sources.front();
395
+ }
396
+ } else {
397
+ const auto props = std::dynamic_pointer_cast<const ImageProps>(shadowView.props);
398
+ if (props == nullptr) {
399
+ return;
400
+ }
401
+ fit = toImageFit(props->resizeMode);
402
+ // React Native's is optional where expo-image's is a plain SharedColor;
403
+ // both spell "no tint" as a falsy SharedColor once unwrapped.
404
+ if (props->tintColor) {
405
+ tint = *props->tintColor;
406
+ }
407
+ if (!props->sources.empty()) {
408
+ source = props->sources.front();
409
+ }
410
+ }
411
+
412
+ // Applied before the load rather than in its callback: the tint is a prop and
413
+ // the texture is a loader's answer, and either can arrive first. Setting it
414
+ // here means a re-render that only changes the colour repaints without
415
+ // touching the image.
416
+ if (RnView *tinted = viewForTag(shadowView.tag); tinted != nullptr) {
417
+ if (tint) {
418
+ const GdkRGBA rgba = toRgba(colorComponentsFromColor(tint));
419
+ rn_view_set_image_tint(tinted, TRUE, &rgba);
420
+ } else {
421
+ rn_view_set_image_tint(tinted, FALSE, nullptr);
422
+ }
423
+ }
424
+
425
+ const std::string uri = source.uri;
426
+ const Tag tag = shadowView.tag;
427
+
428
+ // A mutation that changed only layout must not restart the load, or an
429
+ // <Image> would flicker every time its parent resized. Re-requesting the same
430
+ // URI is cheap -- the loader answers from its cache on this thread -- and it
431
+ // reapplies the fit, which is the only thing that can have changed.
432
+ const auto known = imageUris_.find(tag);
433
+ if (known != imageUris_.end() && known->second == uri) {
434
+ if (!uri.empty()) {
435
+ imageLoader_->load(uri, [this, tag, fit](GdkTexture *texture, const std::string &) {
436
+ if (RnView *target = viewForTag(tag); target != nullptr) {
437
+ rn_view_set_texture(target, texture, fit);
438
+ }
439
+ });
440
+ }
441
+ return;
442
+ }
443
+
444
+ imageUris_[tag] = uri;
445
+
446
+ if (uri.empty()) {
447
+ rn_view_set_texture(view, nullptr, fit);
448
+ return;
449
+ }
450
+
451
+ // Always, rather than only when `shouldNotifyLoadEvents` is set.
452
+ //
453
+ // That prop is Android's signal, and it never arrives here.
454
+ // `Image.android.js` sets it -- which is the Image.js this platform resolves
455
+ // to -- but `ImageViewNativeComponent`'s view config branches on
456
+ // `Platform.OS === 'android'`, and a platform that is neither takes the iOS
457
+ // branch, whose `validAttributes` has no `shouldNotifyLoadEvents` in it. So
458
+ // the prop is filtered out before it reaches C++ and onLoad/onError never
459
+ // fire, silently, on both desktops.
460
+ //
461
+ // iOS does not use the prop at all: RCTImageComponentView emits load events
462
+ // unconditionally and lets the emitter be the thing that knows whether
463
+ // anybody is listening. Doing the same is both correct and cheaper than
464
+ // forking a two-hundred-line view config to change one ternary -- and the
465
+ // emitter lookup below already returns null when nothing is listening.
466
+ if (isExpoImage) {
467
+ if (auto emitter =
468
+ std::dynamic_pointer_cast<const facebook::react::ExpoImageEventEmitter>(eventEmitterForTag(tag))) {
469
+ emitter->onLoadStart();
470
+ }
471
+ } else if (auto emitter =
472
+ std::dynamic_pointer_cast<const ImageEventEmitter>(eventEmitterForTag(tag))) {
473
+ emitter->onLoadStart();
474
+ }
475
+
476
+ imageLoader_->load(uri, [this, tag, fit, source, isExpoImage](GdkTexture *texture,
477
+ const std::string &error) {
478
+ // The view may have been deleted while the image was in flight, which is
479
+ // why this looks the tag up again rather than capturing the widget.
480
+ RnView *target = viewForTag(tag);
481
+ if (target != nullptr) {
482
+ rn_view_set_texture(target, texture, fit);
483
+ }
484
+
485
+ if (texture == nullptr) {
486
+ g_warning("image failed to load: %s (%s)", source.uri.c_str(), error.c_str());
487
+ }
488
+
489
+ if (isExpoImage) {
490
+ auto emitter =
491
+ std::dynamic_pointer_cast<const facebook::react::ExpoImageEventEmitter>(eventEmitterForTag(tag));
492
+ if (emitter == nullptr) {
493
+ return;
494
+ }
495
+ if (texture != nullptr) {
496
+ // The pixel dimensions, which is what expo-image's onLoad reports and
497
+ // what an app sizing itself to an image reads.
498
+ emitter->onLoad(source,
499
+ static_cast<double>(gdk_texture_get_width(texture)),
500
+ static_cast<double>(gdk_texture_get_height(texture)));
501
+ } else {
502
+ emitter->onError(error);
503
+ }
504
+ return;
505
+ }
506
+
507
+ auto emitter = std::dynamic_pointer_cast<const ImageEventEmitter>(eventEmitterForTag(tag));
508
+ if (emitter == nullptr) {
509
+ return;
510
+ }
511
+ if (texture != nullptr) {
512
+ emitter->onLoad(source);
513
+ } else {
514
+ emitter->onError(facebook::react::ImageErrorInfo{.error = error});
515
+ }
516
+ emitter->onLoadEnd();
517
+ });
518
+ }
519
+
520
+ // Accessibility, as AT-SPI and therefore Orca sees it.
521
+ //
522
+ // The role is not here: GTK4's accessible role is construct-only, so it is
523
+ // chosen when the widget is made. See the Create mutation.
524
+ void GtkMountingManager::applyAccessibility(RnView *view, const ShadowView &shadowView) {
525
+ const auto props = std::dynamic_pointer_cast<const facebook::react::AccessibilityProps>(shadowView.props);
526
+ if (props == nullptr) {
527
+ return;
528
+ }
529
+
530
+ // A label given in props wins. Falling back to a Paragraph's own text means a
531
+ // plain <Text> announces itself without the app having to repeat the string
532
+ // in an accessibilityLabel.
533
+ std::string label = props->accessibilityLabel;
534
+ if (label.empty() && shadowView.componentName != nullptr &&
535
+ std::string_view(shadowView.componentName) == "Paragraph") {
536
+ if (const auto state =
537
+ std::dynamic_pointer_cast<const facebook::react::ConcreteState<ParagraphState>>(shadowView.state)) {
538
+ label = state->getData().attributedString.getString();
539
+ }
540
+ }
541
+
542
+ // A <TextInput>'s label belongs on its peer, not on this view.
543
+ //
544
+ // The peer is a real GtkText and is already a text box to AT-SPI in its own
545
+ // right, so it is the thing a screen reader lands on. Left alone the label
546
+ // goes on the wrapper, and the field announces itself as an unnamed text box
547
+ // -- a field the app carefully labelled read out as if it had none. AppKit
548
+ // had the same split and it was visible there: the wrapper carried "your
549
+ // name" and the AXTextField inside it carried nothing.
550
+ //
551
+ // The wrapper's own label is cleared rather than left as a duplicate, so the
552
+ // name is announced once. Its *role* stays what it was built with: unlike
553
+ // AppKit's, a GtkAccessible role is construct-only, so a wrapper that should
554
+ // be presentational cannot become one here. That is the smaller half of the
555
+ // problem and is in docs/BACKLOG.md.
556
+ if (GtkWidget *peer = rn_view_get_editable(view)) {
557
+ gtk_accessible_update_property(GTK_ACCESSIBLE(peer),
558
+ GTK_ACCESSIBLE_PROPERTY_LABEL,
559
+ label.c_str(),
560
+ GTK_ACCESSIBLE_PROPERTY_DESCRIPTION,
561
+ props->accessibilityHint.c_str(),
562
+ -1);
563
+ rn_view_set_accessible_text(view, "", "");
564
+ } else {
565
+ rn_view_set_accessible_text(view, label.c_str(), props->accessibilityHint.c_str());
566
+ }
567
+
568
+ if (props->accessibilityState.has_value()) {
569
+ const auto &state = *props->accessibilityState;
570
+ RnAccessibleFlag checked = RN_A11Y_UNSET;
571
+ switch (state.checked) {
572
+ case facebook::react::AccessibilityState::Checked:
573
+ checked = RN_A11Y_TRUE;
574
+ break;
575
+ case facebook::react::AccessibilityState::Unchecked:
576
+ checked = RN_A11Y_FALSE;
577
+ break;
578
+ case facebook::react::AccessibilityState::Mixed:
579
+ case facebook::react::AccessibilityState::None:
580
+ break;
581
+ }
582
+ rn_view_set_accessible_state(
583
+ view,
584
+ toFlag(state.disabled),
585
+ checked,
586
+ toFlag(state.selected),
587
+ state.expanded.has_value() ? toFlag(*state.expanded) : RN_A11Y_UNSET,
588
+ toFlag(state.busy));
589
+ } else {
590
+ rn_view_set_accessible_state(view, RN_A11Y_UNSET, RN_A11Y_UNSET, RN_A11Y_UNSET, RN_A11Y_UNSET, RN_A11Y_UNSET);
591
+ }
592
+
593
+ // Keyboard focus. `accessible` is the signal because React Native's
594
+ // `focusable` prop never reaches this platform -- see GtkFocus.h -- and
595
+ // because it is what <Pressable> sets on everything it renders. A hidden view
596
+ // is not focusable whatever it says: Tab stopping on something nobody can see
597
+ // is worse than Tab skipping it.
598
+ // A control is focusable whether or not the app said `accessible`.
599
+ //
600
+ // React Native's `focusable` prop never reaches this platform -- ReactCommon
601
+ // parses it only into Android's and tvOS's props -- so `accessible` is the
602
+ // signal, and <Pressable> sets it. <Switch> does not: on a phone the native
603
+ // control is focusable by being a control, and React Native never had to say
604
+ // so. On a desktop a switch that Tab skips is simply broken, so being a
605
+ // control is the signal here too.
606
+ // Disabled is excluded, which is what every desktop does: Tab skips a control
607
+ // that cannot be operated rather than stopping on one that does nothing.
608
+ const basalt::ControlState control = basalt::controlStateOf(shadowView);
609
+ const bool isControl = control.kind == basalt::ControlKind::Switch && !control.disabled;
610
+ rn_view_set_focusable(
611
+ view,
612
+ (props->accessible || isControl) && !props->accessibilityElementsHidden ? TRUE : FALSE);
613
+
614
+ // accessibilityElementsHidden hides the subtree; importantForAccessibility
615
+ // NoHideDescendants is Android's spelling of the same idea.
616
+ const bool hidden = props->accessibilityElementsHidden ||
617
+ props->importantForAccessibility == facebook::react::ImportantForAccessibility::NoHideDescendants;
618
+ rn_view_set_accessible_hidden(view, hidden ? TRUE : FALSE);
619
+ }
620
+
621
+ void GtkMountingManager::applyTextInput(RnView *view, const ShadowView &shadowView) {
622
+ if (shadowView.componentName == nullptr || std::string_view(shadowView.componentName) != "TextInput") {
623
+ return;
624
+ }
625
+ textInputs_.update(view, shadowView);
626
+ }
627
+
628
+ // ---------------------------------------------------------------------------
629
+ // Controls
630
+ // ---------------------------------------------------------------------------
631
+
632
+ namespace {
633
+
634
+ // A colour as CSS, because that is the only way to reach the inside of a GTK
635
+ // control. A GtkSpinner's arc is its `color` and a GtkSwitch's track is its
636
+ // `background-color`; neither is a property, and neither is reachable through
637
+ // the widget API at all.
638
+ std::string cssColor(const float rgba[4]) {
639
+ char buffer[64];
640
+ std::snprintf(buffer,
641
+ sizeof(buffer),
642
+ "rgba(%d,%d,%d,%g)",
643
+ static_cast<int>(rgba[0] * 255.0F + 0.5F),
644
+ static_cast<int>(rgba[1] * 255.0F + 0.5F),
645
+ static_cast<int>(rgba[2] * 255.0F + 0.5F),
646
+ static_cast<double>(rgba[3]));
647
+ return buffer;
648
+ }
649
+
650
+ // The CSS class for a control with these colours, installing a display-wide
651
+ // rule for it the first time that combination is seen.
652
+ //
653
+ // Display-wide rather than per-widget because the per-widget route is
654
+ // `gtk_widget_get_style_context`, deprecated since GTK 4.10 and gone in 5. One
655
+ // provider holding a rule per distinct colour combination costs an app that
656
+ // uses two tinted spinners exactly two rules, and an app that tints nothing
657
+ // costs nothing: the empty state returns no class at all.
658
+ const char *controlCssClass(const basalt::ControlState &state) {
659
+ if (!state.hasForeground && !state.hasTrackOn && !state.hasTrackOff) {
660
+ return nullptr;
661
+ }
662
+
663
+ // Keyed on the rule text, which is the only thing that actually has to be
664
+ // distinct. Never emptied: an app has a handful of tints, not a stream of
665
+ // them, and a rule removed while a widget still carries its class would
666
+ // silently lose the colour.
667
+ static std::map<std::string, std::string> classes;
668
+ static GtkCssProvider *provider = nullptr;
669
+
670
+ std::string body;
671
+ if (state.kind == basalt::ControlKind::Switch) {
672
+ // GtkSwitch draws the track on its own node and the thumb on a child
673
+ // "slider" node. `:checked` is how GTK spells "on", which is what makes
674
+ // React Native's two track colours expressible at all.
675
+ if (state.hasTrackOff) {
676
+ body += " { background-image: none; background-color: " + cssColor(state.trackOff) + "; }";
677
+ }
678
+ if (state.hasTrackOn) {
679
+ body += ":checked { background-image: none; background-color: " + cssColor(state.trackOn) + "; }";
680
+ }
681
+ if (state.hasForeground) {
682
+ body += " > slider { background-color: " + cssColor(state.foreground) + "; }";
683
+ }
684
+ } else if (state.hasForeground) {
685
+ body += " { color: " + cssColor(state.foreground) + "; }";
686
+ }
687
+ if (body.empty()) {
688
+ return nullptr;
689
+ }
690
+
691
+ const auto found = classes.find(body);
692
+ if (found != classes.end()) {
693
+ return found->second.c_str();
694
+ }
695
+
696
+ const std::string name = "rn-control-" + std::to_string(classes.size());
697
+ classes.emplace(body, name);
698
+
699
+ GdkDisplay *display = gdk_display_get_default();
700
+ if (display == nullptr) {
701
+ // No display yet, so nothing is being drawn either. The class is still
702
+ // handed back, and the rule for it arrives with the next one.
703
+ return classes.find(body)->second.c_str();
704
+ }
705
+ if (provider == nullptr) {
706
+ provider = gtk_css_provider_new();
707
+ gtk_style_context_add_provider_for_display(
708
+ display, GTK_STYLE_PROVIDER(provider), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
709
+ }
710
+ std::string css;
711
+ for (const auto &[rule, className] : classes) {
712
+ css += "." + className + rule + "\n";
713
+ }
714
+ gtk_css_provider_load_from_string(provider, css.c_str());
715
+ return classes.find(body)->second.c_str();
716
+ }
717
+
718
+ } // namespace
719
+
720
+ void GtkMountingManager::setHighlights(RnView *view,
721
+ const std::vector<basalt::Highlight> &highlights) {
722
+ // Flattened into the eight-floats-per-rectangle array RnView takes, because
723
+ // that file has no React Native in it and is not about to start.
724
+ std::vector<float> values;
725
+ std::vector<gboolean> filled;
726
+ values.reserve(highlights.size() * 8);
727
+ filled.reserve(highlights.size());
728
+ for (const basalt::Highlight &highlight : highlights) {
729
+ values.push_back(highlight.x);
730
+ values.push_back(highlight.y);
731
+ values.push_back(highlight.width);
732
+ values.push_back(highlight.height);
733
+ values.insert(values.end(), std::begin(highlight.color), std::end(highlight.color));
734
+ filled.push_back(highlight.filled ? TRUE : FALSE);
735
+ }
736
+ rn_view_set_highlights(view,
737
+ values.empty() ? nullptr : values.data(),
738
+ filled.empty() ? nullptr : filled.data(),
739
+ static_cast<int>(highlights.size()));
740
+ }
741
+
742
+ void GtkMountingManager::pressedView(Tag tag) {
743
+ RnView *view = viewForTag(tag);
744
+ if (view == nullptr || rn_view_get_control_kind(view) != RN_CONTROL_SWITCH) {
745
+ return;
746
+ }
747
+ const auto known = switchValues_.find(tag);
748
+ if (known == switchValues_.end() || rn_view_get_control_disabled(view)) {
749
+ return;
750
+ }
751
+
752
+ // Told, and then left alone. React Native's <Switch> is a controlled
753
+ // component: the app's `value` prop is the only thing that moves it, and a
754
+ // switch that flipped itself would show a state its props do not agree with
755
+ // -- which is exactly what an app that ignores onValueChange is supposed to
756
+ // look like. The GtkSwitch never moved, because it never saw the click.
757
+ basalt::emitSwitchChange(eventEmitterForTag(tag), tag, !known->second);
758
+ }
759
+
760
+ void GtkMountingManager::applyControlPeer(RnView *view, const basalt::ControlState &state) {
761
+ const RnControlKind kind =
762
+ state.kind == basalt::ControlKind::Switch ? RN_CONTROL_SWITCH : RN_CONTROL_SPINNER;
763
+ GtkWidget *control = rn_view_set_control(view, kind);
764
+ if (control == nullptr) {
765
+ return;
766
+ }
767
+
768
+ const Tag tag = static_cast<Tag>(rn_view_get_tag(view));
769
+
770
+ if (state.kind == basalt::ControlKind::Switch) {
771
+ switchValues_[tag] = state.on;
772
+ gtk_switch_set_active(GTK_SWITCH(control), state.on ? TRUE : FALSE);
773
+ // Drawn, not driven. The GtkSwitch is taken out of hit testing so that a
774
+ // press lands on the RnView behind it and reaches React Native's touch
775
+ // path, which is what `pressedView` picks up -- and what makes a press on
776
+ // a switch mean the same thing on three desktops, since the Win32 one is
777
+ // painted and has no widget to click at all. It also means the synthesised
778
+ // taps the test suite uses reach it, which a GtkSwitch handling its own
779
+ // input would not.
780
+ //
781
+ // `sensitive` would do this too and would also grey the widget out, which
782
+ // is what `disabled` is for and must stay distinguishable from it.
783
+ gtk_widget_set_can_target(control, FALSE);
784
+ gtk_widget_set_sensitive(control, state.disabled ? FALSE : TRUE);
785
+ } else {
786
+ gtk_spinner_set_spinning(GTK_SPINNER(control), state.on ? TRUE : FALSE);
787
+ // `hidesWhenStopped` defaults to true, and a <RefreshControl> that is not
788
+ // refreshing shows nothing at all -- which is the same rule.
789
+ gtk_widget_set_visible(control, (state.on || !state.hidesWhenStopped) ? TRUE : FALSE);
790
+ }
791
+
792
+ // The tint, if the app asked for one. Removing the previous class matters:
793
+ // an app animating a colour would otherwise accumulate every class it has
794
+ // ever had, and the first one would keep winning.
795
+ if (const auto previous = controlClasses_.find(tag); previous != controlClasses_.end()) {
796
+ gtk_widget_remove_css_class(control, previous->second);
797
+ }
798
+ const char *className = controlCssClass(state);
799
+ if (className != nullptr) {
800
+ gtk_widget_add_css_class(control, className);
801
+ controlClasses_[tag] = className;
802
+ } else {
803
+ controlClasses_.erase(tag);
804
+ }
805
+
806
+ rn_view_set_control_disabled(view, state.disabled ? TRUE : FALSE);
807
+ const std::string described = basalt::describeControl(state);
808
+ rn_view_set_control_description(view, described.c_str());
809
+ }
810
+
811
+ void GtkMountingManager::applyScrollView(RnView *view, const ShadowView &shadowView) {
812
+ if (shadowView.componentName == nullptr || std::string_view(shadowView.componentName) != "ScrollView") {
813
+ return;
814
+ }
815
+ scrollViews_.update(view, shadowView);
816
+ }
817
+
818
+ bool GtkMountingManager::hasComponent(const std::string &name) {
819
+ // Paragraph is the mountable half of <Text>; Text and RawText exist only in
820
+ // the shadow tree, folded into the Paragraph's AttributedString. Image still
821
+ // needs an IImageLoader, and ScrollView a GtkScrolledWindow peer.
822
+ // ScrollView's content child arrives as "ScrollContentView", which the
823
+ // registry rewrites to "View" before it reaches here, so it needs no entry.
824
+ return name == "View" || name == "RootView" || name == "Paragraph" || name == "Image" ||
825
+ name == "ScrollView" || name == "TextInput" || name == "ActivityIndicatorView" ||
826
+ name == "Switch" || name == "ModalHostView" || name == "PullToRefreshView" ||
827
+ name == "UnimplementedNativeView" || name == "DebuggingOverlay";
828
+ }
829
+
830
+ // ---------------------------------------------------------------------------
831
+ // Applying a ShadowView to a widget
832
+ // ---------------------------------------------------------------------------
833
+
834
+ void GtkMountingManager::updateView(RnView *view, const ShadowView &shadowView) {
835
+ applyProps(view, shadowView);
836
+ applyText(view, shadowView);
837
+ applyImage(view, shadowView);
838
+ #ifdef BASALT_HAS_SKIA
839
+ // After applyProps, which is what puts nativeID on the view: a canvas
840
+ // registers itself by that id and cannot attach before it is there.
841
+ //
842
+ // Sharing rn_view_set_texture with applyImage is safe rather than lucky:
843
+ // applyImage returns on anything that is not <Image> or expo-image, so a
844
+ // SkiaPictureView never reaches its texture call. The two write the same
845
+ // field and can never both want it, because no component is both.
846
+ applySkiaCanvas(GTK_WIDGET(view), shadowView);
847
+ #endif
848
+ // The peer first, then accessibility. A <TextInput>'s label belongs on its
849
+ // peer and applyAccessibility can only put it there if the peer exists --
850
+ // and on the mount that creates it, in this order it does.
851
+ applyTextInput(view, shadowView);
852
+ applyAccessibility(view, shadowView);
853
+ applyLayoutMetrics(view, shadowView);
854
+ // Last: the scroll manager clamps its offset against the frame it was just
855
+ // given, and iOS documents the same ordering requirement -- layout before
856
+ // state, or the offset is clamped against a stale size.
857
+ applyScrollView(view, shadowView);
858
+ // After layout for the same reason the scroll view is: a control is centred
859
+ // in the frame it was just given, and a <Modal> commits that frame's size
860
+ // back into its own state.
861
+ applyControls(view, shadowView);
862
+ }
863
+
864
+ // A <Paragraph> carries its text in state, not props: ParagraphShadowNode
865
+ // resolves the whole <Text> subtree into one AttributedString and commits it as
866
+ // ParagraphState, which is why nothing here walks child shadow nodes.
867
+ void GtkMountingManager::applyText(RnView *view, const ShadowView &shadowView) {
868
+ if (shadowView.componentName == nullptr ||
869
+ std::string_view(shadowView.componentName) != "Paragraph") {
870
+ return;
871
+ }
872
+
873
+ const auto state = std::dynamic_pointer_cast<const facebook::react::ConcreteState<ParagraphState>>(shadowView.state);
874
+ if (state == nullptr) {
875
+ return;
876
+ }
877
+
878
+ const auto &data = state->getData();
879
+
880
+ // Measurement already ran through the same builder, with the width Yoga then
881
+ // assigned. Rebuilding it here at that width is what makes the painted lines
882
+ // break where the measured ones did.
883
+ const float width = static_cast<float>(shadowView.layoutMetrics.frame.size.width);
884
+ PangoLayout *layout = basalt::buildTextLayout(data.attributedString, data.paragraphAttributes, width);
885
+
886
+ // Fragments carry their own colours as Pango attributes; this is the fallback
887
+ // for text that set none. React Native's default is opaque black.
888
+ GdkRGBA color{0.0F, 0.0F, 0.0F, 1.0F};
889
+ const auto &fragments = data.attributedString.getFragments();
890
+ if (!fragments.empty() && fragments.front().textAttributes.foregroundColor) {
891
+ color = toRgba(colorComponentsFromColor(fragments.front().textAttributes.foregroundColor));
892
+ }
893
+
894
+ rn_view_set_text_layout(view, layout, &color);
895
+ g_object_unref(layout);
896
+ }
897
+
898
+ void GtkMountingManager::applyProps(RnView *view, const ShadowView &shadowView) {
899
+ const auto props = std::dynamic_pointer_cast<const ViewProps>(shadowView.props);
900
+ if (props == nullptr) {
901
+ return;
902
+ }
903
+
904
+ if (props->backgroundColor) {
905
+ const GdkRGBA rgba = toRgba(colorComponentsFromColor(props->backgroundColor));
906
+ rn_view_set_background_color(view, TRUE, &rgba);
907
+ } else {
908
+ rn_view_set_background_color(view, FALSE, nullptr);
909
+ }
910
+
911
+ rn_view_set_opacity(view, props->opacity);
912
+
913
+ // Only a hidden title bar reads this, to find the drag regions an app marked
914
+ // with <TitleBar.DragRegion>. Win32MountingManager does the same with the
915
+ // same comment, which is the point: the two hosts have to agree about what
916
+ // reaches them, or a header drags on one desktop and not the other.
917
+ rn_view_set_native_id(view, props->nativeId.c_str());
918
+
919
+ // overflow: 'hidden'. React Native's default is 'visible', which is why the
920
+ // phase-1 screenshots show a child outgrowing its shrunk parent.
921
+ rn_view_set_clips_children(view, props->getClipsContentToBounds() ? TRUE : FALSE);
922
+
923
+ rn_view_set_z_index(view, props->zIndex.value_or(0));
924
+
925
+ // Radii and widths depend on the frame -- percentage radii, and the clamping
926
+ // that stops opposite corners overlapping -- so they are resolved against the
927
+ // layout metrics rather than read raw.
928
+ const auto borders = props->resolveBorderMetrics(shadowView.layoutMetrics);
929
+
930
+ // GRAPHENE_SIZE_INIT is a C99 compound literal, which C++ rejects here.
931
+ const auto cornerSize = [](const auto &corner) {
932
+ return graphene_size_t{static_cast<float>(corner.horizontal), static_cast<float>(corner.vertical)};
933
+ };
934
+ const graphene_size_t radii[4] = {
935
+ cornerSize(borders.borderRadii.topLeft),
936
+ cornerSize(borders.borderRadii.topRight),
937
+ cornerSize(borders.borderRadii.bottomRight),
938
+ cornerSize(borders.borderRadii.bottomLeft),
939
+ };
940
+ rn_view_set_border_radii(view, radii);
941
+
942
+ // GTK's border node wants top, right, bottom, left -- the order CSS names
943
+ // them in, and the order React Native's RectangleEdges is not stored in.
944
+ const float widths[4] = {
945
+ static_cast<float>(borders.borderWidths.top),
946
+ static_cast<float>(borders.borderWidths.right),
947
+ static_cast<float>(borders.borderWidths.bottom),
948
+ static_cast<float>(borders.borderWidths.left),
949
+ };
950
+ const auto edgeColor = [](const facebook::react::SharedColor &color) {
951
+ return color ? toRgba(colorComponentsFromColor(color)) : GdkRGBA{0.0F, 0.0F, 0.0F, 0.0F};
952
+ };
953
+ const GdkRGBA colors[4] = {
954
+ edgeColor(borders.borderColors.top),
955
+ edgeColor(borders.borderColors.right),
956
+ edgeColor(borders.borderColors.bottom),
957
+ edgeColor(borders.borderColors.left),
958
+ };
959
+ rn_view_set_borders(view, widths, colors);
960
+
961
+ // resolveTransform folds in transformOrigin, but only when one was set: the
962
+ // default anchor is the view's centre, and the widget layer applies that.
963
+ const auto transform = props->resolveTransform(shadowView.layoutMetrics);
964
+ if (transform == facebook::react::Transform::Identity()) {
965
+ rn_view_set_transform(view, nullptr);
966
+ } else {
967
+ // React Native's matrix is CSS matrix3d order, which puts translation at
968
+ // indices 12..14 -- the same slots graphene uses. Rotations are the
969
+ // transpose of each other, which is why the demo checks a rotation on
970
+ // screen rather than trusting the memory layout.
971
+ float values[16];
972
+ for (int i = 0; i < 16; i++) {
973
+ values[i] = static_cast<float>(transform.matrix[static_cast<size_t>(i)]);
974
+ }
975
+ graphene_matrix_t matrix;
976
+ graphene_matrix_init_from_float(&matrix, values);
977
+ rn_view_set_transform(view, &matrix);
978
+ }
979
+
980
+ // `backfaceVisibility: 'hidden'`, which stops the back of a card being drawn
981
+ // mirrored halfway through a flip. Whether a transform has turned away is
982
+ // core/Backface.h's to decide, so all three hide the same face.
983
+ //
984
+ // Applied after the transform, because the widget has to know the matrix
985
+ // before it can say which way it is facing.
986
+ rn_view_set_hides_back_face(
987
+ view, props->backfaceVisibility == facebook::react::BackfaceVisibility::Hidden);
988
+
989
+ // Hit testing only. `none` becomes GTK's can-target inside the setter; the
990
+ // other two are resolved by GtkTouchDispatcher, which is the only thing that
991
+ // reads them.
992
+ switch (props->pointerEvents) {
993
+ case facebook::react::PointerEventsMode::None:
994
+ rn_view_set_pointer_events(view, RN_POINTER_EVENTS_NONE);
995
+ break;
996
+ case facebook::react::PointerEventsMode::BoxNone:
997
+ rn_view_set_pointer_events(view, RN_POINTER_EVENTS_BOX_NONE);
998
+ break;
999
+ case facebook::react::PointerEventsMode::BoxOnly:
1000
+ rn_view_set_pointer_events(view, RN_POINTER_EVENTS_BOX_ONLY);
1001
+ break;
1002
+ case facebook::react::PointerEventsMode::Auto:
1003
+ rn_view_set_pointer_events(view, RN_POINTER_EVENTS_AUTO);
1004
+ break;
1005
+ }
1006
+
1007
+ // TODO(props): borderStyles (dashed/dotted).
1008
+ }
1009
+
1010
+ void GtkMountingManager::applyLayoutMetrics(RnView *view, const ShadowView &shadowView) {
1011
+ const auto &frame = shadowView.layoutMetrics.frame;
1012
+ rn_view_set_frame(view,
1013
+ static_cast<float>(frame.origin.x),
1014
+ static_cast<float>(frame.origin.y),
1015
+ static_cast<float>(frame.size.width),
1016
+ static_cast<float>(frame.size.height));
1017
+
1018
+ // display: 'none' keeps the node in the shadow tree but takes it out of
1019
+ // layout and painting. gtk_widget_should_layout is false for an invisible
1020
+ // widget, so RnLayout skips it too.
1021
+ rn_view_set_hidden(view,
1022
+ shadowView.layoutMetrics.displayType == facebook::react::DisplayType::None);
1023
+
1024
+ // TODO(layout): pointScaleFactor matters once fractional scaling is wired up.
1025
+ }
1026
+
1027
+ } // namespace basalt