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,428 @@
1
+ #include "GtkTextInput.h"
2
+
3
+ #include "GtkTextPeer.h"
4
+
5
+ #include "PangoTextLayout.h"
6
+
7
+ #include <react/renderer/components/iostextinput/TextInputProps.h>
8
+ #include <react/renderer/components/textinput/TextInputEventEmitter.h>
9
+
10
+ #include <cmath>
11
+ #include <cstdint>
12
+ #include <string_view>
13
+
14
+ namespace basalt {
15
+
16
+ using facebook::react::AttributedString;
17
+ using facebook::react::ShadowView;
18
+ using facebook::react::Tag;
19
+ using facebook::react::TextInputEventEmitter;
20
+ using facebook::react::TextInputProps;
21
+
22
+ GtkTextInputManager::GtkTextInputManager(EmitterLookup lookup) : lookup_(std::move(lookup)) {}
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Mutations
26
+ // ---------------------------------------------------------------------------
27
+
28
+ void GtkTextInputManager::update(RnView *view, const ShadowView &shadowView) {
29
+ const Tag tag = shadowView.tag;
30
+ auto [it, inserted] = entries_.try_emplace(tag);
31
+ Entry &entry = it->second;
32
+
33
+ entry.view = view;
34
+ entry.tag = tag;
35
+ entry.owner = this;
36
+
37
+ const auto props = std::dynamic_pointer_cast<const TextInputProps>(shadowView.props);
38
+ const bool multiline = props != nullptr && props->multiline;
39
+
40
+ // Before the signals, because a field that switches between single and
41
+ // multiline is a different widget and the old one's handlers go with it.
42
+ GtkWidget *peer = rn_view_set_editable(view, TRUE, multiline ? TRUE : FALSE);
43
+ const bool rebuilt = peer != entry.editable;
44
+ entry.editable = peer;
45
+
46
+ if (rebuilt) {
47
+
48
+ GObject *signals = rn_peer_signal_source(entry.editable);
49
+ g_signal_connect(signals, "changed", G_CALLBACK(onChanged), &entry);
50
+ // "activate" is a GtkText signal and a single-line idea: Enter in a
51
+ // multiline field inserts a newline, which is what SubmitBehavior::Newline
52
+ // means and what BaseTextInputProps already reports for one.
53
+ if (!multiline) {
54
+ g_signal_connect(entry.editable, "activate", G_CALLBACK(onActivate), &entry);
55
+ }
56
+
57
+ // Both ends, because either can move on its own: an arrow key moves the
58
+ // caret with the other end following, and shift-arrow moves one and not
59
+ // the other. GtkText has no single "the selection changed" signal.
60
+ g_signal_connect(signals, "notify::cursor-position",
61
+ G_CALLBACK(onSelectionChanged), &entry);
62
+ // A GtkTextBuffer has no "selection-bound" property; it reports both ends
63
+ // moving through "notify::has-selection" instead.
64
+ g_signal_connect(signals,
65
+ multiline ? "notify::has-selection" : "notify::selection-bound",
66
+ G_CALLBACK(onSelectionChanged), &entry);
67
+
68
+ // Capture phase, because onKeyPress has to fire *before* onChange -- which
69
+ // means before GtkText has handled the key and changed the text. In the
70
+ // bubble phase the edit has already happened and the two events arrive the
71
+ // wrong way round.
72
+ GtkEventController *keys = gtk_event_controller_key_new();
73
+ gtk_event_controller_set_propagation_phase(keys, GTK_PHASE_CAPTURE);
74
+ g_signal_connect(keys, "key-pressed", G_CALLBACK(onKeyPressed), &entry);
75
+ gtk_widget_add_controller(entry.editable, keys);
76
+
77
+ GtkEventController *focus = gtk_event_controller_focus_new();
78
+ g_signal_connect(focus, "enter", G_CALLBACK(onFocusEnter), &entry);
79
+ g_signal_connect(focus, "leave", G_CALLBACK(onFocusLeave), &entry);
80
+ gtk_widget_add_controller(entry.editable, focus);
81
+ }
82
+
83
+ // Yoga has resolved border and padding into the content inset; the GtkText is
84
+ // allocated inside it, so `paddingHorizontal` on a field means what it means
85
+ // on a <View>.
86
+ const auto &insets = shadowView.layoutMetrics.contentInsets;
87
+ const GtkBorder border = {
88
+ .left = static_cast<int16_t>(std::lround(insets.left)),
89
+ .right = static_cast<int16_t>(std::lround(insets.right)),
90
+ .top = static_cast<int16_t>(std::lround(insets.top)),
91
+ .bottom = static_cast<int16_t>(std::lround(insets.bottom)),
92
+ };
93
+ rn_view_set_peer_insets(view, &border);
94
+
95
+ if (props == nullptr || entry.editable == nullptr) {
96
+ return;
97
+ }
98
+
99
+ // Controlled component: JavaScript owns the value. Three things have to be
100
+ // true at once, and each fails differently.
101
+ //
102
+ // Setting it must not look like typing, or the change we report provokes a
103
+ // re-render that sets it again and the two chase each other -- `applying`.
104
+ //
105
+ // A prop older than what the user has since typed must not be applied at all,
106
+ // or a fast typist watches characters reorder themselves. That is what
107
+ // React Native counts events for, and dropping such a value *without
108
+ // recording it* is deliberate: the next render, once JavaScript has caught
109
+ // up, applies it.
110
+ //
111
+ // And it is applied when the prop *changes*, not when it differs from the
112
+ // widget, which is the only thing that tells a controlled field from an
113
+ // uncontrolled one. See the header.
114
+ const bool stale = props->mostRecentEventCount < entry.eventCount;
115
+ const bool changed = !entry.sawProps || props->text != entry.lastPropText;
116
+ if (changed && !stale) {
117
+ entry.lastPropText = props->text;
118
+ entry.sawProps = true;
119
+
120
+ char *current = rn_peer_get_text(entry.editable);
121
+ const bool differs = props->text != (current != nullptr ? current : "");
122
+ g_free(current);
123
+ if (differs) {
124
+ entry.applying = true;
125
+ // Preserve the cursor: assigning the text resets it to the start, which
126
+ // sends the caret home on every keystroke of a controlled input.
127
+ const int cursor = rn_peer_get_position(entry.editable);
128
+ rn_peer_set_text(entry.editable, props->text.c_str());
129
+ rn_peer_set_position(entry.editable,
130
+ MIN(cursor, static_cast<int>(props->text.size())));
131
+ entry.applying = false;
132
+ entry.lastReportedText = props->text;
133
+ }
134
+ }
135
+
136
+ // A controlled *selection*, under the same staleness rule the text is under:
137
+ // JavaScript that has not yet seen the last keystroke must not be allowed to
138
+ // drag the caret back to where it thought it was.
139
+ //
140
+ // Applied when it changes rather than whenever it differs, for the reason
141
+ // `text` is: a field that is merely uncontrolled sends no selection at all,
142
+ // and re-asserting one every render would fight the user's own arrow keys.
143
+ if (props->selection.has_value() && !stale) {
144
+ const auto &selection = *props->selection;
145
+ if (!entry.lastPropSelection.has_value() ||
146
+ entry.lastPropSelection->start != selection.start ||
147
+ entry.lastPropSelection->end != selection.end) {
148
+ entry.lastPropSelection = selection;
149
+ entry.applying = true;
150
+ rn_peer_select_region(entry.editable, selection.start, selection.end);
151
+ entry.applying = false;
152
+ entry.lastReportedSelection =
153
+ facebook::react::AttributedString::Range{selection.start, selection.end - selection.start};
154
+ }
155
+ }
156
+
157
+ rn_peer_set_placeholder(entry.editable, props->placeholder.c_str());
158
+
159
+ // GtkText renders in the GTK theme's colour and font, which has nothing to do
160
+ // with the `style` this component was given -- on a dark field that is dark
161
+ // text on dark. A PangoAttrList is how GtkText takes the style instead.
162
+ //
163
+ // fontSizeMultiplier is 1: nothing on this platform scales text for
164
+ // accessibility settings yet, and passing 0 would multiply the size away.
165
+ PangoAttrList *attributes = buildTextAttributes(props->getEffectiveTextAttributes(1.0F));
166
+ rn_peer_set_attributes(entry.editable, attributes);
167
+ pango_attr_list_unref(attributes);
168
+
169
+ // `editable` is the prop; `readOnly` is the newer spelling of its inverse,
170
+ // and React Native honours both.
171
+ const bool writable = props->traits.editable && !props->readOnly;
172
+ rn_peer_set_editable(entry.editable, writable ? TRUE : FALSE);
173
+
174
+ rn_peer_set_visibility(entry.editable, props->traits.secureTextEntry ? FALSE : TRUE);
175
+
176
+ // Zero means no limit, and so does the absurd default React Native uses when
177
+ // the prop is absent -- passing that through would be a limit nobody asked
178
+ // for on a field that had none.
179
+ rn_peer_set_max_length(
180
+ entry.editable,
181
+ (props->maxLength > 0 && props->maxLength < 1000000) ? props->maxLength : 0);
182
+
183
+ if (inserted && props->autoFocus) {
184
+ gtk_widget_grab_focus(entry.editable);
185
+ }
186
+ }
187
+
188
+ void GtkTextInputManager::remove(Tag tag) {
189
+ const auto it = entries_.find(tag);
190
+ if (it == entries_.end()) {
191
+ return;
192
+ }
193
+ // The signal handlers hold a pointer to the Entry. Dropping the editable
194
+ // first takes them with it.
195
+ if (it->second.view != nullptr && RN_IS_VIEW(it->second.view)) {
196
+ rn_view_set_editable(it->second.view, FALSE, FALSE);
197
+ }
198
+ entries_.erase(it);
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Events
203
+ // ---------------------------------------------------------------------------
204
+
205
+ std::shared_ptr<const TextInputEventEmitter> GtkTextInputManager::emitterFor(Tag tag) const {
206
+ return std::dynamic_pointer_cast<const TextInputEventEmitter>(lookup_(tag));
207
+ }
208
+
209
+ TextInputEventEmitter::Metrics GtkTextInputManager::metricsFor(const Entry &entry) const {
210
+ const char *text = entry.editable != nullptr
211
+ ? rn_peer_get_text(entry.editable)
212
+ : "";
213
+
214
+ TextInputEventEmitter::Metrics metrics{};
215
+ metrics.text = text != nullptr ? text : "";
216
+ metrics.eventCount = entry.eventCount;
217
+ metrics.target = entry.tag;
218
+
219
+ // The whole selection, not only the caret. `get_selection_bounds` answers
220
+ // FALSE when nothing is selected, and leaves the out parameters alone -- so
221
+ // the caret position is the fallback and a zero length is the truth about it.
222
+ int start = 0;
223
+ int end = 0;
224
+ if (entry.editable != nullptr &&
225
+ !rn_peer_get_selection_bounds(entry.editable, &start, &end)) {
226
+ start = end = rn_peer_get_position(entry.editable);
227
+ }
228
+ metrics.selectionRange = AttributedString::Range{start, end - start};
229
+
230
+ // The scroll-shaped fields exist because iOS's text view is a scroll view.
231
+ // Nothing here scrolls yet, so they describe a viewport the size of the
232
+ // field, which is true and keeps JavaScript's arithmetic sane.
233
+ const auto width = static_cast<facebook::react::Float>(
234
+ entry.view != nullptr ? gtk_widget_get_width(GTK_WIDGET(entry.view)) : 0);
235
+ const auto height = static_cast<facebook::react::Float>(
236
+ entry.view != nullptr ? gtk_widget_get_height(GTK_WIDGET(entry.view)) : 0);
237
+ metrics.containerSize = {.width = width, .height = height};
238
+ metrics.contentSize = metrics.containerSize;
239
+ metrics.layoutMeasurement = metrics.containerSize;
240
+ metrics.zoomScale = 1.0F;
241
+
242
+ return metrics;
243
+ }
244
+
245
+ void GtkTextInputManager::onChanged(GObject * /*source*/, gpointer userData) {
246
+ auto *entry = static_cast<Entry *>(userData);
247
+ if (entry->applying) {
248
+ // This is a prop being applied, not the user typing.
249
+ return;
250
+ }
251
+
252
+ char *text = rn_peer_get_text(entry->editable);
253
+ const std::string value = text != nullptr ? text : "";
254
+ if (value == entry->lastReportedText) {
255
+ return;
256
+ }
257
+ entry->lastReportedText = value;
258
+ entry->eventCount++;
259
+
260
+ const auto emitter = entry->owner->emitterFor(entry->tag);
261
+ if (emitter != nullptr) {
262
+ emitter->onChange(entry->owner->metricsFor(*entry));
263
+ }
264
+ }
265
+
266
+ void GtkTextInputManager::onActivate(GtkText * /*editable*/, gpointer userData) {
267
+ auto *entry = static_cast<Entry *>(userData);
268
+ const auto emitter = entry->owner->emitterFor(entry->tag);
269
+ if (emitter != nullptr) {
270
+ // Enter. React Native calls this submitEditing, and follows it with
271
+ // endEditing on platforms where the field also gives up focus; GtkText
272
+ // keeps focus on activate, so only the submit is reported.
273
+ emitter->onSubmitEditing(entry->owner->metricsFor(*entry));
274
+ }
275
+ }
276
+
277
+ gboolean GtkTextInputManager::onKeyPressed(GtkEventControllerKey * /*controller*/,
278
+ guint keyval,
279
+ guint /*keycode*/,
280
+ GdkModifierType /*state*/,
281
+ gpointer userData) {
282
+ auto *entry = static_cast<Entry *>(userData);
283
+ if (entry == nullptr || entry->owner == nullptr) {
284
+ return GDK_EVENT_PROPAGATE;
285
+ }
286
+
287
+ // React Native's contract: 'Enter' and 'Backspace' by name, and the typed
288
+ // character otherwise -- including ' ' for space. Keys that produce no
289
+ // character at all, the arrows and the modifiers, send nothing, which is what
290
+ // iOS does too.
291
+ std::string key;
292
+ switch (keyval) {
293
+ case GDK_KEY_Return:
294
+ case GDK_KEY_KP_Enter:
295
+ case GDK_KEY_ISO_Enter:
296
+ key = "Enter";
297
+ break;
298
+ case GDK_KEY_BackSpace:
299
+ key = "Backspace";
300
+ break;
301
+ default: {
302
+ const gunichar character = gdk_keyval_to_unicode(keyval);
303
+ if (character == 0 || !g_unichar_isprint(character)) {
304
+ return GDK_EVENT_PROPAGATE;
305
+ }
306
+ char utf8[7] = {0};
307
+ const gint length = g_unichar_to_utf8(character, utf8);
308
+ key.assign(utf8, static_cast<size_t>(length));
309
+ break;
310
+ }
311
+ }
312
+
313
+ if (auto emitter = entry->owner->emitterFor(entry->tag)) {
314
+ TextInputEventEmitter::KeyPressMetrics metrics{};
315
+ metrics.text = key;
316
+ metrics.eventCount = entry->eventCount;
317
+ emitter->onKeyPress(metrics);
318
+ }
319
+
320
+ // Never handled here: this observes the key on its way to GtkText, which
321
+ // still has to do the editing.
322
+ return GDK_EVENT_PROPAGATE;
323
+ }
324
+
325
+ void GtkTextInputManager::onSelectionChanged(GObject * /*object*/,
326
+ GParamSpec * /*pspec*/,
327
+ gpointer userData) {
328
+ auto *entry = static_cast<Entry *>(userData);
329
+ if (entry == nullptr || entry->owner == nullptr) {
330
+ return;
331
+ }
332
+ // Not while a prop is being pushed in. Applying `text` moves the caret, and
333
+ // reporting that as the user selecting something would make a controlled
334
+ // field fight its own render.
335
+ if (entry->applying) {
336
+ return;
337
+ }
338
+
339
+ const auto metrics = entry->owner->metricsFor(*entry);
340
+ // Both signals fire for one movement, so this collapses the pair into the
341
+ // one event JavaScript should see.
342
+ if (metrics.selectionRange.location == entry->lastReportedSelection.location &&
343
+ metrics.selectionRange.length == entry->lastReportedSelection.length) {
344
+ return;
345
+ }
346
+ entry->lastReportedSelection = metrics.selectionRange;
347
+
348
+ if (auto emitter = entry->owner->emitterFor(entry->tag)) {
349
+ emitter->onSelectionChange(metrics);
350
+ }
351
+ }
352
+
353
+ void GtkTextInputManager::onFocusEnter(GtkEventControllerFocus * /*controller*/, gpointer userData) {
354
+ auto *entry = static_cast<Entry *>(userData);
355
+ const auto emitter = entry->owner->emitterFor(entry->tag);
356
+ if (emitter != nullptr) {
357
+ emitter->onFocus(entry->owner->metricsFor(*entry));
358
+ }
359
+ }
360
+
361
+ void GtkTextInputManager::onFocusLeave(GtkEventControllerFocus * /*controller*/, gpointer userData) {
362
+ auto *entry = static_cast<Entry *>(userData);
363
+ const auto emitter = entry->owner->emitterFor(entry->tag);
364
+ if (emitter != nullptr) {
365
+ emitter->onBlur(entry->owner->metricsFor(*entry));
366
+ emitter->onEndEditing(entry->owner->metricsFor(*entry));
367
+ }
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // Commands
372
+ // ---------------------------------------------------------------------------
373
+
374
+ bool GtkTextInputManager::dispatchCommand(Tag tag,
375
+ const std::string &name,
376
+ const folly::dynamic &args) {
377
+ const auto it = entries_.find(tag);
378
+ if (it == entries_.end()) {
379
+ return false;
380
+ }
381
+ Entry &entry = it->second;
382
+ if (entry.editable == nullptr) {
383
+ return false;
384
+ }
385
+
386
+ if (name == "focus") {
387
+ gtk_widget_grab_focus(GTK_WIDGET(entry.editable));
388
+ return true;
389
+ }
390
+
391
+ if (name == "blur") {
392
+ // GTK has no "unfocus this widget"; focus moves, it is not dropped. Handing
393
+ // it to the window's default is the closest thing, and it makes the focus
394
+ // controller report a leave, so JavaScript still sees onBlur.
395
+ GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(entry.editable));
396
+ if (root != nullptr) {
397
+ gtk_widget_grab_focus(GTK_WIDGET(root));
398
+ }
399
+ return true;
400
+ }
401
+
402
+ if (name == "setTextAndSelection") {
403
+ // [eventCount, text, start, end]. An eventCount older than what the user
404
+ // has since typed means this command is stale and must be dropped, which is
405
+ // the whole reason React Native counts them.
406
+ if (args.isArray() && args.size() >= 2) {
407
+ const int eventCount = static_cast<int>(args[0].asInt());
408
+ if (eventCount < entry.eventCount) {
409
+ return true;
410
+ }
411
+ entry.applying = true;
412
+ const auto text = args[1].isString() ? args[1].asString() : std::string{};
413
+ rn_peer_set_text(entry.editable, text.c_str());
414
+ if (args.size() >= 4 && args[2].isInt()) {
415
+ const int start = static_cast<int>(args[2].asInt());
416
+ const int end = static_cast<int>(args[3].asInt());
417
+ rn_peer_select_region(entry.editable, start, end);
418
+ }
419
+ entry.applying = false;
420
+ entry.lastReportedText = text;
421
+ }
422
+ return true;
423
+ }
424
+
425
+ return false;
426
+ }
427
+
428
+ } // namespace basalt
@@ -0,0 +1,123 @@
1
+ // <TextInput> on GTK4.
2
+ //
3
+ // The native half is React Native's own *iOS* TextInput: its shadow node,
4
+ // props, state and event emitter are pure C++ and measure through a
5
+ // TextLayoutManager, which on this platform is the Pango one. Android's variant
6
+ // includes fbjni and calls into a Java FabricUIManager, so it is unusable here;
7
+ // see docs/DECISIONS.md. The component name is therefore "TextInput", which is
8
+ // what packages/basalt-core' src/overrides/TextInput.js asks for.
9
+ //
10
+ // The editing itself is a real GtkText -- the widget behind GtkEntry -- rather
11
+ // than a cursor drawn on a PangoLayout. That brings input methods, selection,
12
+ // the clipboard, and every keybinding a Linux user expects, none of which is
13
+ // worth reimplementing and all of which is easy to get subtly wrong.
14
+ //
15
+ // The awkward part of a text field is not typing, it is that React Native's
16
+ // <TextInput> is a controlled component: JavaScript owns the value, and the
17
+ // widget must not fight it. Every `changed` signal is reported to JavaScript,
18
+ // which re-renders and sends the text back down as a prop -- so applying that
19
+ // prop must not itself look like the user typing, or the two chase each other.
20
+ // `applying_` is what breaks that loop.
21
+
22
+ #pragma once
23
+
24
+ #include "RnView.h"
25
+
26
+ #include <react/renderer/components/iostextinput/TextInputShadowNode.h>
27
+ #include <react/renderer/core/EventEmitter.h>
28
+ #include <react/renderer/mounting/ShadowView.h>
29
+
30
+ #include <functional>
31
+ #include <optional>
32
+ #include <string>
33
+ #include <unordered_map>
34
+
35
+ namespace basalt {
36
+
37
+ class GtkTextInputManager {
38
+ public:
39
+ using EmitterLookup = std::function<facebook::react::EventEmitter::Shared(facebook::react::Tag)>;
40
+
41
+ explicit GtkTextInputManager(EmitterLookup lookup);
42
+
43
+ // Called for every mutation touching a TextInput. Creates the editable the
44
+ // first time, then applies props.
45
+ void update(RnView *view, const facebook::react::ShadowView &shadowView);
46
+
47
+ void remove(facebook::react::Tag tag);
48
+
49
+ // focus, blur, and setTextAndSelection. Returns false for anything else.
50
+ bool dispatchCommand(facebook::react::Tag tag,
51
+ const std::string &name,
52
+ const folly::dynamic &args);
53
+
54
+ private:
55
+ struct Entry {
56
+ RnView *view{nullptr};
57
+ // A GtkText or a GtkTextView; see GtkTextPeer.h.
58
+ GtkWidget *editable{nullptr};
59
+ facebook::react::Tag tag{0};
60
+ GtkTextInputManager *owner{nullptr};
61
+
62
+ // React Native counts events so it can ignore a prop update that is older
63
+ // than what the user has since typed. Every emitted metric carries it.
64
+ int eventCount{0};
65
+
66
+ // True while a prop is being pushed into the widget, so the `changed`
67
+ // signal it provokes is not reported back as the user typing.
68
+ bool applying{false};
69
+
70
+ std::string lastReportedText;
71
+
72
+ // The selection as JavaScript last saw it. GtkText fires two notifies for
73
+ // one movement, so this is what keeps that from being two events.
74
+ facebook::react::AttributedString::Range lastReportedSelection{0, 0};
75
+
76
+ // The last `selection` prop seen, for the same reason lastPropText exists:
77
+ // applying on change rather than on difference is what leaves an
78
+ // uncontrolled field's own caret alone.
79
+ std::optional<facebook::react::Selection> lastPropSelection{};
80
+
81
+ // The last `text` prop actually seen, and whether one has been seen at all.
82
+ //
83
+ // This is what tells a *controlled* field from an uncontrolled one, which
84
+ // props alone cannot: React Native's TextInput.js sends
85
+ // `text={value ?? defaultValue}`, and an uncontrolled field with no default
86
+ // sends undefined, which arrives here as the empty string -- exactly what a
87
+ // controlled field that JavaScript has cleared sends. Applying it whenever
88
+ // it differs from the widget therefore wipes an uncontrolled field the
89
+ // moment anything else in the tree re-renders, because React Native
90
+ // re-sends `mostRecentEventCount` on every change and that alone produces
91
+ // an Update mutation.
92
+ //
93
+ // So the prop is applied when it *changes*, not when it differs from the
94
+ // widget. A controlled field's value changes as the user types; an
95
+ // uncontrolled one's never does. Found on Windows.
96
+ std::string lastPropText;
97
+ bool sawProps{false};
98
+ };
99
+
100
+ // The sender differs by peer -- the widget for a GtkText, the buffer for a
101
+ // GtkTextView -- so it arrives untyped and goes unused.
102
+ static void onChanged(GObject *source, gpointer userData);
103
+ static void onActivate(GtkText *editable, gpointer userData);
104
+ static gboolean onKeyPressed(GtkEventControllerKey *controller,
105
+ guint keyval,
106
+ guint keycode,
107
+ GdkModifierType state,
108
+ gpointer userData);
109
+ static void onSelectionChanged(GObject *object, GParamSpec *pspec, gpointer userData);
110
+ static void onFocusEnter(GtkEventControllerFocus *controller, gpointer userData);
111
+ static void onFocusLeave(GtkEventControllerFocus *controller, gpointer userData);
112
+
113
+ // Fills in the parts of Metrics every event carries.
114
+ facebook::react::TextInputEventEmitter::Metrics metricsFor(const Entry &entry) const;
115
+
116
+ std::shared_ptr<const facebook::react::TextInputEventEmitter> emitterFor(
117
+ facebook::react::Tag tag) const;
118
+
119
+ EmitterLookup lookup_;
120
+ std::unordered_map<facebook::react::Tag, Entry> entries_;
121
+ };
122
+
123
+ } // namespace basalt