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.
- package/LICENSE +21 -0
- package/cli/runLinux.js +29 -0
- package/cli/shared.js +75 -0
- package/native/CMakeLists.txt +362 -0
- package/native/gtk/ColorSchemeGtk.cpp +58 -0
- package/native/gtk/ComponentRegistryGtk.cpp +101 -0
- package/native/gtk/FontRegistryFontconfig.cpp +127 -0
- package/native/gtk/GtkAnimationChoreographer.cpp +73 -0
- package/native/gtk/GtkAnimationChoreographer.h +49 -0
- package/native/gtk/GtkDropTarget.cpp +269 -0
- package/native/gtk/GtkDropTarget.h +40 -0
- package/native/gtk/GtkFocus.cpp +296 -0
- package/native/gtk/GtkFocus.h +113 -0
- package/native/gtk/GtkImageLoader.cpp +128 -0
- package/native/gtk/GtkImageLoader.h +87 -0
- package/native/gtk/GtkKeyEvents.cpp +91 -0
- package/native/gtk/GtkKeyEvents.h +21 -0
- package/native/gtk/GtkMenuBar.cpp +45 -0
- package/native/gtk/GtkMountingManager.cpp +1027 -0
- package/native/gtk/GtkMountingManager.h +146 -0
- package/native/gtk/GtkRunLoopObserver.cpp +77 -0
- package/native/gtk/GtkRunLoopObserver.h +41 -0
- package/native/gtk/GtkScrollView.cpp +626 -0
- package/native/gtk/GtkScrollView.h +209 -0
- package/native/gtk/GtkSkiaContext.cpp +248 -0
- package/native/gtk/GtkSkiaContext.h +62 -0
- package/native/gtk/GtkSkiaModule.cpp +80 -0
- package/native/gtk/GtkSkiaModule.h +51 -0
- package/native/gtk/GtkSkiaPeer.cpp +285 -0
- package/native/gtk/GtkSkiaPeer.h +63 -0
- package/native/gtk/GtkTextInput.cpp +428 -0
- package/native/gtk/GtkTextInput.h +123 -0
- package/native/gtk/GtkTextPeer.cpp +425 -0
- package/native/gtk/GtkTextPeer.h +87 -0
- package/native/gtk/GtkTitleBar.cpp +273 -0
- package/native/gtk/GtkTitleBar.h +96 -0
- package/native/gtk/GtkTitleBarLayout.cpp +45 -0
- package/native/gtk/GtkTitleBarLayout.h +36 -0
- package/native/gtk/GtkTouchDispatcher.cpp +671 -0
- package/native/gtk/GtkTouchDispatcher.h +139 -0
- package/native/gtk/GtkWindowControl.cpp +223 -0
- package/native/gtk/GtkWindowModule.cpp +194 -0
- package/native/gtk/GtkWindowModule.h +62 -0
- package/native/gtk/PangoTextLayout.cpp +295 -0
- package/native/gtk/PangoTextLayout.h +54 -0
- package/native/gtk/PangoTextLayoutManager.cpp +140 -0
- package/native/gtk/PlatformServicesGtk.cpp +583 -0
- package/native/gtk/RnView.cpp +1407 -0
- package/native/gtk/RnView.h +339 -0
- package/native/gtk/demo_layout_gtk.cpp +57 -0
- package/native/gtk/main_gtk.cpp +1981 -0
- package/native/gtk/mount_harness_gtk.cpp +143 -0
- package/native/tests/TestMainGtk.cpp +25 -0
- package/native/tests/notification_stub.cpp +103 -0
- package/native/tests/test_accessibility.cpp +251 -0
- package/native/tests/test_focus.cpp +194 -0
- package/native/tests/test_hittest.cpp +242 -0
- package/native/tests/test_image.cpp +135 -0
- package/native/tests/test_key_events_gtk.cpp +112 -0
- package/native/tests/test_mounting.cpp +415 -0
- package/native/tests/test_scrollview.cpp +258 -0
- package/native/tests/test_skia_texture.cpp +120 -0
- package/native/tests/test_text.cpp +147 -0
- package/native/tests/test_textinput.cpp +598 -0
- package/native/tests/test_titlebar.cpp +137 -0
- package/native/tests/test_titlebar_drag.cpp +138 -0
- package/native/tests/test_version.cpp +49 -0
- package/native/tests/test_view.cpp +162 -0
- package/native/tests/test_viewprops.cpp +254 -0
- package/package.json +26 -0
- 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
|